Skip to content

fix(parser-sandbox): drop PYTHONPATH/HOME and run parser child unprivileged#2019

Open
Subramaniyajothi6 wants to merge 2 commits into
utksh1:mainfrom
Subramaniyajothi6:fix/parser-sandbox-pythonpath-escape
Open

fix(parser-sandbox): drop PYTHONPATH/HOME and run parser child unprivileged#2019
Subramaniyajothi6 wants to merge 2 commits into
utksh1:mainfrom
Subramaniyajothi6:fix/parser-sandbox-pythonpath-escape

Conversation

@Subramaniyajothi6

Copy link
Copy Markdown
Contributor

Summary

Closes #1804.

parser_sandbox.py runs untrusted third-party parser.py code in a subprocess, but
_sanitised_env() kept PYTHONPATH and HOME in the child environment. An inherited
PYTHONPATH pointing at an attacker-controlled directory let the "sandboxed" parser
import arbitrary modules, and HOME was an equivalent vector via the per-user site
directory (~/.local/lib/pythonX.Y/site-packages). There was also no privilege drop, so
a backend running as root executed untrusted parser code as root.

Changes

  • Lock down the child's import path in _sanitised_env():
    • Stop inheriting PYTHONPATH (direct sys.path injection) and HOME (user-site
      injection).
    • Set PYTHONNOUSERSITE=1 so ~/.local site-packages is ignored even if HOME leaks
      in via another route.
    • Set PYTHONSAFEPATH=1 (3.11+) so the process working directory — which python -c
      otherwise prepends to sys.path — cannot be used to smuggle in modules.
    • Installed dependencies are unaffected: the interpreter's own site-packages is on
      sys.path regardless of PYTHONPATH.
  • Drop privileges for the parser child via a new _privilege_drop_kwargs(): when the
    backend runs as root (os.geteuid() == 0, POSIX only) the child is executed as the
    nobody account (falling back to uid/gid 65534) using subprocess's
    user/group/extra_groups parameters. It's a no-op when the backend is already
    unprivileged or on Windows.
  • Apply the same privilege drop to the existing unshare --user --net capability probe
    (issue Security: plugin subprocess runs with no outbound network isolation #1620) so a host where root can create user namespaces but nobody cannot is
    detected at probe time rather than failing mid-parse.

Tests

  • Flipped the two helper tests that asserted PYTHONPATH/HOME are retained (they
    encoded the vulnerable behaviour) to assert they're dropped; added coverage for the
    two new hardening flags and for _privilege_drop_kwargs() (no-op off-root / non-posix,
    targets nobody, and the 65534 fallback — using an injected fake pwd module so it
    runs on non-POSIX CI too).
  • Added an end-to-end regression test that plants an evilmod on PYTHONPATH and
    confirms the sandboxed parser can no longer import it.

pytest testing/backend/unit/test_parser_sandbox.py testing/backend/unit/test_parser_sandbox_helpers.py → 43 passed. Adjacent sandbox suites pass; ruff check clean. No plugin metadata touched.

Notes

  • Fail-closed: if a privilege drop is intended (root) but the syscall is denied,
    subprocess raises rather than silently running the parser as root.
  • No default guard is weakened — this only tightens the parser sandbox.

@utksh1 please review this pr

@utksh1 utksh1 added level:advanced 55 pts difficulty label for advanced contributor PRs type:security Security work category bonus label type:bug Bug fix work category bonus label area:backend Backend API, database, or service work area:plugins Scanner plugin metadata, schemas, or plugin runtime work area:security Security-sensitive implementation or tests labels Jul 14, 2026

@utksh1 utksh1 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping to nobody can make the parser source unreadable when the backend runs as root and the parser file or its parent directory is root-private. The current tests only inspect kwargs, not an actual root-owned parser execution. Please prove the root execution path can read and run a parser safely (or stage the parser in a read-only accessible location) and add a focused regression test.

…ileged

_sanitised_env() kept PYTHONPATH and HOME in the parser child's environment.
An inherited PYTHONPATH pointing at an attacker-controlled directory let the
"sandboxed" parser import arbitrary modules; HOME was an equivalent vector via
the per-user site directory (~/.local/lib/pythonX.Y/site-packages).

- Drop PYTHONPATH and HOME; set PYTHONNOUSERSITE=1 and PYTHONSAFEPATH=1 to also
  close the user-site and python -c CWD import vectors. Installed dependencies
  still resolve via the interpreter's own site-packages.
- Add _privilege_drop_kwargs(): when the backend runs as root, execute the
  parser child as the nobody account (fallback uid/gid 65534) via subprocess
  user/group/extra_groups. No-op when already unprivileged or on Windows.
- Apply the same drop to the unshare --user --net capability probe so a host
  where root can unshare but nobody cannot is detected at probe time.

Flip the two helper tests that asserted PYTHONPATH/HOME are retained (they
encoded the vulnerable behaviour), add hardening-flag and privilege-drop
coverage, and add an end-to-end regression proving an injected PYTHONPATH
cannot be imported by the sandboxed parser.
…an read it

Dropping the parser child to 'nobody' when the backend runs as root broke
parsing whenever parser.py (or its parent directory) was root-owned and not
world-readable: the unprivileged child could not read the source and the
parse failed. Reads were previously assumed to work because the tests only
inspected the subprocess kwargs, never an actual root-owned execution.

Stage a private copy of parser.py the drop target can read: the file is
copied into a fresh temp dir, chowned to the drop uid/gid and made 0400,
while the directory stays backend-owned and 0711 (traversable, not listable)
so cleanup works whether or not the backend is root. The copy lives only for
the child's lifetime and is removed on exit. When no privilege drop is in
effect the original path is used unchanged. The drop is resolved once and
reused for both staging and the child so they always target the same account.

Tests: _staged_parser is a no-op without a drop and otherwise produces a
faithful, 0400, drop-target-readable copy that is cleaned up; an end-to-end
run under a simulated drop still parses and leaks no staging dir; and a
root-gated regression reproduces the root-private parser.py condition and
proves the child reads+runs it unprivileged.

Addresses review feedback on utksh1#2019.
@Subramaniyajothi6 Subramaniyajothi6 force-pushed the fix/parser-sandbox-pythonpath-escape branch from a6feffc to 37ca5dd Compare July 15, 2026 10:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend Backend API, database, or service work area:plugins Scanner plugin metadata, schemas, or plugin runtime work area:security Security-sensitive implementation or tests level:advanced 55 pts difficulty label for advanced contributor PRs type:bug Bug fix work category bonus label type:security Security work category bonus label

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[#31] parser_sandbox retains PYTHONPATH/HOME (sandbox escape vector)

2 participants