fix(codex): close parent-dir symlink escape in the containment check
This commit is contained in:
+68
-35
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user