fix(cc-config): close the symlink write escape and the caller-chosen write target
The Claude writer's containment was lexical only: a symlink at ~/.claude/<type>/<name>, or at any ancestor of it, is a string inside the root while the write lands wherever it points. The backup destination had the same exposure, and delete copies through the link before unlinking it. PUT/DELETE /file also honoured ?cwd=, so a project-scope write could be aimed at any directory on disk. PATCH /settings already refused that, with the reasoning in a comment; the two other write routes never adopted it. The guard moves into config-write.js so both writers run one copy.
This commit is contained in:
@@ -35,7 +35,12 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getCodexHome, getProjectCodexDir, getProjectRoot } = require("./codex-home");
|
||||
const { MAX_FILE_BYTES, isUnder } = require("./cc-discovery");
|
||||
const { createBackup, atomicWriteFile, assertUnder } = require("./config-write");
|
||||
const {
|
||||
createBackup,
|
||||
atomicWriteFile,
|
||||
assertUnder,
|
||||
assertNoSymlinkEscape,
|
||||
} = require("./config-write");
|
||||
|
||||
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
|
||||
@@ -165,28 +170,9 @@ function rmTreeSync(p) {
|
||||
* scope for this task (see task-5 fix round 2).
|
||||
*/
|
||||
function assertNotSymlinkEscape(trustedRoot, target, opts = {}) {
|
||||
if (opts.create !== false) fs.mkdirSync(trustedRoot, { recursive: true });
|
||||
const realRoot = fs.realpathSync(trustedRoot);
|
||||
|
||||
let dir = target;
|
||||
let real;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUnder(realRoot, real)) {
|
||||
throw makeError("EOUTOFROOT", `resolved path escapes the Codex root: ${real}`);
|
||||
}
|
||||
// Lives in config-write.js so the Claude writer runs the identical check —
|
||||
// this guard cost four fix rounds to get right and must not exist twice.
|
||||
assertNoSymlinkEscape(trustedRoot, target, { ...opts, label: "Codex" });
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user