fix(codex): close parent-dir symlink escape in the containment check

This commit is contained in:
2026-09-08 17:43:09 +07:00
parent f3a2f41ed7
commit 58623f37c0
2 changed files with 127 additions and 35 deletions
+59
View File
@@ -207,6 +207,65 @@ describe("codex-mutate", () => {
fs.rmSync(outside, { recursive: true, force: true });
});
it("refuses a brand-new artifact whose subdir (not the leaf) is a symlink escaping the root", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
const linkPath = path.join(TMP, "agents");
// "agents" doesn't exist yet in this fresh suite of tests for this scope —
// remove it first if an earlier test already created it as a real dir.
fs.rmSync(linkPath, { recursive: true, force: true });
fs.symlinkSync(outside, linkPath, "dir");
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "agents",
name: "brand-new",
content: "pwned",
cwd: REPO,
}),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.existsSync(path.join(outside, "brand-new.toml")), false);
fs.rmSync(linkPath, { force: true });
fs.rmSync(outside, { recursive: true, force: true });
});
it("still writes correctly when $CODEX_HOME itself is a symlink (a dotfiles checkout)", () => {
const realHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-realhome-"));
const linkHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-linkhome-"));
fs.rmdirSync(linkHome); // symlinkSync needs the link path itself to not exist
fs.symlinkSync(realHome, linkHome, "dir");
const prev = process.env.CODEX_HOME;
process.env.CODEX_HOME = linkHome;
delete require.cache[require.resolve("../lib/codex-home")];
delete require.cache[require.resolve("../lib/codex-mutate")];
const linkedCm = require("../lib/codex-mutate");
try {
const { file, created } = linkedCm.writeArtifact({
scope: "user",
type: "rules",
name: "via-link",
content: "ok",
cwd: REPO,
});
assert.equal(created, true);
assert.equal(fs.readFileSync(file, "utf8"), "ok");
assert.equal(
fs.readFileSync(path.join(realHome, "rules", "via-link.rules"), "utf8"),
"ok",
"the write must land in the real directory the symlinked CODEX_HOME points to"
);
} finally {
process.env.CODEX_HOME = prev;
delete require.cache[require.resolve("../lib/codex-home")];
delete require.cache[require.resolve("../lib/codex-mutate")];
fs.rmSync(linkHome, { force: true });
fs.rmSync(realHome, { recursive: true, force: true });
}
});
for (const badName of ["..", ".", "/etc/passwd", "a/b", "a\0b"]) {
it(`rejects the bad name ${JSON.stringify(badName)} with EBADNAME and writes nothing`, () => {
const target = path.join(TMP, "agents", `${badName.replace(/[/\0]/g, "_")}.toml`);
+68 -35
View File
@@ -18,11 +18,14 @@
* - Writes are atomic via temp file + fs.renameSync (see config-write.js).
* - Names are validated against a strict allowlist regex; resolved paths
* are double-checked to live under the expected containment root before
* any I/O. That string-only check does not see through a symlink, so
* any existing target is additionally realpath-resolved and required to
* land inside a real Codex root (see `assertNotSymlinkEscape`) — a
* symlink that merely reorganises files within the roots (a supported
* install pattern) keeps working; one pointing outside is refused.
* any I/O. That string-only check does not see through a symlink placed
* anywhere along the path — including the type's own subdir (e.g.
* `<root>/skills`) — so the nearest existing ancestor of the target is
* additionally realpath-resolved and required to land inside the
* realpath'd scope root (see `assertNotSymlinkEscape`). A symlink that
* merely reorganises files within the root (a supported install
* pattern, and a symlinked CODEX_HOME itself) keeps working; one
* pointing outside is refused.
* - Codex has no `project-local` scope and no auto-memory: only "user" and
* "project" are valid scopes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -75,10 +78,17 @@ function fixedPathFor(type, scope, opts = {}) {
}
/**
* Resolve the on-disk target for a (scope, type, name) tuple AND the
* containment root used for path-traversal checks.
* Resolve the on-disk target for a (scope, type, name) tuple, the
* containment root used for string-only path-traversal checks, and the
* trusted root used for the realpath-based symlink-escape check.
*
* Returns: { kind: "file" | "dir", target, filePath, containmentRoot }
* `trustedRoot` is deliberately the OUTER root (CODEX_HOME / project .codex
* dir / project root), not `containmentRoot`: for skills/agents/rules,
* `containmentRoot` is `<root>/<subdir>` (e.g. `<root>/skills`), and that
* subdir itself is exactly what an attacker could replace with a symlink —
* checking a target against its own compromised parent would always pass.
*
* Returns: { kind: "file" | "dir", target, filePath, containmentRoot, trustedRoot }
*/
function resolveTarget(scope, type, name, opts = {}) {
const spec = TYPES[type];
@@ -90,14 +100,18 @@ function resolveTarget(scope, type, name, opts = {}) {
throw makeError("EBADNAME", `${type} is a fixed file and takes no name`);
}
const filePath = fixedPathFor(type, scope, opts);
return { kind: "file", target: filePath, filePath, containmentRoot: path.dirname(filePath) };
// No attacker-placeable segment between the trusted root and the fixed
// filename, so the containment root doubles as the trusted root.
const root = path.dirname(filePath);
return { kind: "file", target: filePath, filePath, containmentRoot: root, trustedRoot: root };
}
if (typeof name !== "string" || !NAME_RE.test(name)) {
throw makeError("EBADNAME", `name must match ${NAME_RE}`);
}
const subdirAbs = path.join(rootForScope(scope, opts), spec.subdir);
const root = rootForScope(scope, opts);
const subdirAbs = path.join(root, spec.subdir);
if (spec.kind === "dir") {
const target = path.join(subdirAbs, name);
@@ -106,11 +120,12 @@ function resolveTarget(scope, type, name, opts = {}) {
target,
filePath: path.join(target, spec.filename),
containmentRoot: subdirAbs,
trustedRoot: root,
};
}
const target = path.join(subdirAbs, name + spec.ext);
return { kind: "file", target, filePath: target, containmentRoot: subdirAbs };
return { kind: "file", target, filePath: target, containmentRoot: subdirAbs, trustedRoot: root };
}
/** <root>/codex-config-backups/<type>/, always rooted the same way as the read side's <root>. */
@@ -122,33 +137,51 @@ function rmTreeSync(p) {
fs.rmSync(p, { recursive: true, force: true });
}
/** realpath if it exists, else the plain resolved path — for roots that may not exist yet. */
function safeRealpath(p) {
try {
return fs.realpathSync(p);
} catch {
return path.resolve(p);
}
}
/**
* Symlinks are a supported install pattern (Task 3's reader follows them),
* so a symlink at `target` is not rejected outright — only one that
* dereferences outside both Codex roots. A missing/dangling symlink is a
* fresh create, not an error. Must run before anything reads through
* `target` (backup or write), since string-only containment checks never
* see through a link.
* so a symlink under `trustedRoot` is not rejected outright — only one that
* dereferences outside it. `trustedRoot` itself may be a symlink (a
* dotfiles-managed CODEX_HOME is a normal setup) — that's fine, it just
* means everything is compared against wherever it really points.
*
* `target` may not exist yet (a fresh create). Realpath can't resolve a
* path that isn't there, so this walks up to the nearest ancestor that DOES
* exist, realpath's that, and requires it to land inside the realpath'd
* root. Everything below that existing ancestor is plain path segments this
* module built itself from a NAME_RE-validated name or a fixed literal, so
* they can't smuggle in a symlink — only an EXISTING ancestor can.
*
* `trustedRoot` is created first (mkdirSync recursive — safe, it is never
* attacker-controlled) so the walk is guaranteed to bottom out at or below
* it, never wander past it into whatever happens to contain it.
*
* ponytail: this check and the write/delete that follows are not atomic —
* a symlink swapped in between them could still redirect one write. Closing
* that needs O_NOFOLLOW-style fd work with real platform variance; out of
* scope for this task (see task-5 fix round 2).
*/
function assertNotSymlinkEscape(target, cwd) {
function assertNotSymlinkEscape(trustedRoot, target) {
fs.mkdirSync(trustedRoot, { recursive: true });
const realRoot = fs.realpathSync(trustedRoot);
let dir = target;
let real;
try {
real = fs.realpathSync(target);
} catch {
return;
for (;;) {
try {
real = fs.realpathSync(dir);
break;
} catch {
const parent = path.dirname(dir);
if (parent === dir) {
real = dir; // hit the filesystem root without finding anything real
break;
}
dir = parent;
}
}
const roots = [safeRealpath(getCodexHome()), safeRealpath(getProjectCodexDir(cwd))];
if (!roots.some((root) => isUnder(root, real))) {
throw makeError("EOUTOFROOT", `resolved path escapes the Codex roots: ${real}`);
if (!isUnder(realRoot, real)) {
throw makeError("EOUTOFROOT", `resolved path escapes the Codex root: ${real}`);
}
}
@@ -169,7 +202,7 @@ function writeArtifact(args) {
const r = resolveTarget(scope, type, name, { cwd });
assertUnder(r.containmentRoot, r.target);
assertNotSymlinkEscape(r.target, cwd);
assertNotSymlinkEscape(r.trustedRoot, r.target);
const existedBefore = fs.existsSync(r.filePath);
const backup = existedBefore
@@ -201,7 +234,7 @@ function deleteArtifact(args) {
if (!fs.existsSync(r.target)) {
throw makeError("ENOTFOUND", `${type}/${name || TYPES[type].file} does not exist`);
}
assertNotSymlinkEscape(r.target, cwd);
assertNotSymlinkEscape(r.trustedRoot, r.target);
const backup = createBackup({
source: r.target,