fix(codex): close symlink write escape and validate listBackups scope/type

This commit is contained in:
2026-09-08 17:34:10 +07:00
parent c9541dc509
commit f3a2f41ed7
2 changed files with 126 additions and 7 deletions
+44 -3
View File
@@ -18,7 +18,11 @@
* - 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.
* 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.
* - 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>
@@ -27,7 +31,7 @@
const fs = require("node:fs");
const path = require("node:path");
const { getCodexHome, getProjectCodexDir, getProjectRoot } = require("./codex-home");
const { MAX_FILE_BYTES } = require("./cc-discovery");
const { MAX_FILE_BYTES, isUnder } = require("./cc-discovery");
const { createBackup, atomicWriteFile, assertUnder } = require("./config-write");
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
@@ -118,6 +122,36 @@ 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.
*/
function assertNotSymlinkEscape(target, cwd) {
let real;
try {
real = fs.realpathSync(target);
} catch {
return;
}
const roots = [safeRealpath(getCodexHome()), safeRealpath(getProjectCodexDir(cwd))];
if (!roots.some((root) => isUnder(root, real))) {
throw makeError("EOUTOFROOT", `resolved path escapes the Codex roots: ${real}`);
}
}
// ── Public API ─────────────────────────────────────────────────────────
/**
@@ -135,6 +169,7 @@ function writeArtifact(args) {
const r = resolveTarget(scope, type, name, { cwd });
assertUnder(r.containmentRoot, r.target);
assertNotSymlinkEscape(r.target, cwd);
const existedBefore = fs.existsSync(r.filePath);
const backup = existedBefore
@@ -166,6 +201,7 @@ function deleteArtifact(args) {
if (!fs.existsSync(r.target)) {
throw makeError("ENOTFOUND", `${type}/${name || TYPES[type].file} does not exist`);
}
assertNotSymlinkEscape(r.target, cwd);
const backup = createBackup({
source: r.target,
@@ -188,9 +224,14 @@ function deleteArtifact(args) {
* Returns [{ type, name, file, mtime, size }].
*/
function listBackups(opts = {}) {
const out = [];
const scopes = opts.scope ? [opts.scope] : ["user", "project"];
const types = opts.type ? [opts.type] : Object.keys(TYPES);
for (const scope of scopes) assertScope(scope);
for (const type of types) {
if (!TYPES[type]) throw makeError("EBADTYPE", `unknown type: ${type}`);
}
const out = [];
for (const scope of scopes) {
for (const type of types) {
const root = backupRootFor(scope, type, opts);