fix(runtime): stop the scan output ancestry walk at root-owned parents - #239
Open
rohanpoudel2 wants to merge 3 commits into
Open
fix(runtime): stop the scan output ancestry walk at root-owned parents#239rohanpoudel2 wants to merge 3 commits into
rohanpoudel2 wants to merge 3 commits into
Conversation
Both ancestry checks walk every parent up to the filesystem root, and both reject a parent that is group- or world-writable without the sticky bit. On a host where / is mode 0777 without the sticky bit, every absolute path contains a failing ancestor, so no output directory can be chosen. 0.1.5 became unusable on such hosts, with no opt-out, in the Node prepare step and again in the Python save step. The check defends against another unprivileged user replacing scan output through a shared parent, which requires a parent that user can write to. A root-owned directory is not one an unprivileged user can create entries in, and not one they can repair either, so refusing to run leaves the host exactly as exposed while making the tool unusable. Stop the walk once it reaches a root-owned parent, after checking that parent, and keep the full walk when the process itself is root, since root can repair the mode. The parent that actually contains the output is still checked, so a world-writable root is still rejected when output is placed directly in it, and a world-writable parent the user owns is still rejected wherever it appears. An attacker who exploits a world-writable root to interpose a tree still fails the existing trusted-owner check, because a directory they create is owned by them and only root can chown to root. Fixes openai#212
Collaborator
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 448769fa8f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…root Stopping the ancestry walk at the first root-owned parent exempted more than the motivating misconfiguration needs. Root ownership is not a boundary on POSIX: renaming or unlinking an entry is authorised by write and execute permission on the containing directory, and only the sticky bit restricts that to the entry's owner, so any user with write permission on a non-sticky parent can rename a root-owned directory out of the way and put their own directory in its place. Skipping every ancestor above the first root-owned one therefore also skipped genuine substitution points, such as a group-writable /usr/local above a root-owned /usr/local/share, which an administrator can repair and a caller can route around. Walk the whole chain again and exempt one ancestor from the group- and world-writable rule: the filesystem root, and only when it is root-owned, the process is not root, and it is not the directory the output sits in. That is the one ancestor every absolute path must pass through and no unprivileged user can repair, and a host that leaves it writable without the sticky bit has already handed every local user the ability to rename /etc, /usr and every other top-level entry, so refusing to run cannot make that host safer. Every other ancestor stays enforced, output placed directly in a writable root is still refused because that placement is the caller's choice, and root still gets the full walk. Substitution also stays detectable rather than merely improbable. A directory an attacker creates carries their uid, and only root can chown to another user, so a forged tree fails the trusted-owner check on the parents and the owner check on the scan directory itself, both of which run again on every resolution.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #212
Problem
Two checks walk every parent up to the filesystem root and reject any that is group- or world-writable without the sticky bit.
requireSecureOutputAncestryinsrc/runtime.ts:and
require_canonical_scan_directoryin_bundled_plugin/scripts/workbench_db.py:Path.parentsends at/, and the Node walk terminates ondirname(current) === current, which is also/. Every absolute path contains/, so on a host where/is mode 0777 without the sticky bit there is no output directory that can pass. The reporter's host is a shared Linux box where/isdrwxrwxrwx root rootby machine policy, with no root access to change it. 0.1.4 worked; 0.1.5 fails at the Node prepare step and, if that is bypassed, again at the Python save step. There is no opt-out.Change
Stop the walk once it reaches a root-owned parent, after checking that parent. Keep the full walk when the process itself is root.
The threat model is another unprivileged user replacing scan output through a shared parent, and that requires a parent the attacker can write to. A root-owned directory is not one an unprivileged user can create entries in, and it is not one they can repair either — so refusing to run leaves the host exactly as exposed while making the tool unusable. When the process is root the strict walk is kept, because root can repair the mode.
This is the second option the issue proposes. I did not take the first (skip root-owned ancestors) or the third (an env-var opt-out) — see below.
What is still rejected
The change deliberately checks the root-owned parent before stopping, so the guarantee that matters is intact:
/results), because that parent is where an attacker can actually rename the entry./tmpcase the sticky rule exists for.requireTrustedOutputAncestoris untouched, so a parent owned by a third user is still rejected.An attacker who does exploit a world-writable
/to interpose a tree — rename/home, recreate it, rebuild the path — still fails the existing trusted-owner check, because every directory they create is owned by them and only root canchownto root. The residual exposure is the time-of-check/time-of-use window, which is identical to the one already accepted for sticky shared parents and is not something walking to/closes.Why not skip root-owned ancestors entirely
The issue's first option is to skip ancestors owned by uid 0. That would accept
/resultson the reporter's host, where any user can rename/resultsoutright. Root ownership does not help when the mode grants world write, so the offending directory has to stay checked when it is the one holding the output. Checking it and then stopping keeps that case rejected.Why not an env-var opt-out
The issue's third option is an opt-out with a documented security note. I looked for precedent and there is none: every
CODEX_SECURITY_*variable insrc/and_bundled_plugin/scripts/configures paths, registries or notices, and not one disables a security check. Adding the first such switch is a product decision about a footgun, and it also does not remove the need for this fix — a user on the reporter's host would have to disable the entire ancestry check, including the parts that were correctly protecting them, to get any scan to run. Bounding the walk keeps the protection and needs no new surface.Verification
Both checks are covered, and the reported host cannot be reproduced without root, so the ancestry is simulated in each case rather than depending on the mode CI gives
/.runtime.test.ts— a new test mocksnode:fs/promises(the pattern the file already uses forlink) with a synthetic chain:/root-owned 0777 non-sticky,/tmproot-owned sticky, and private user-owned directories below. It asserts four things: the walk resolves for output under/tmp, still rejects/results, still rejects a user-owned world-writable parent, and still rejects when run as root.workbench-canonical-paths.test.ts— a new test drives the real Python function through the existingrunPythonProbeharness, patchingPath.lstatto simulate the same chain, and asserts it is accepted when the stopping parent is root-owned and sticky but still rejected when that parent is user-owned and world-writable.With the fixes reverted, each new test fails with the exact message from the issue:
With the fixes:
runtime.test.ts80 pass / 1 skip / 0 fail,workbench-canonical-paths.test.ts4 pass / 2 skip / 0 fail.Full suite on this base: 728 pass / 5 skip / 0 fail.
pnpm run typesandpnpm run formatare clean, andworkbench_db.pycompiles underpython3 -m py_compile.