2ade30f670
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.
164 lines
5.4 KiB
JavaScript
164 lines
5.4 KiB
JavaScript
/**
|
|
* @file config-write.js
|
|
* @description Shared safe-write primitives for config writers (cc-mutate.js
|
|
* for Claude Code, and any Codex equivalent): path containment, timestamped
|
|
* backup-before-mutate, and atomic file write. Extracted out of cc-mutate.js
|
|
* so both writers share one copy of this security-sensitive code instead of
|
|
* diverging copies.
|
|
*
|
|
* Hard constraints (do not relax without a follow-up review):
|
|
* - Backups happen BEFORE the mutation and land outside the tree being
|
|
* backed up (caller passes `backupRoot`).
|
|
* - Writes are atomic via temp file + fs.renameSync. Tmp is removed on
|
|
* any failure path.
|
|
* - `assertUnder` is the single containment check callers must run on any
|
|
* resolved path before touching disk.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { isUnder } = require("./cc-discovery");
|
|
|
|
function timestamp() {
|
|
return new Date().toISOString().replace(/[:]/g, "-");
|
|
}
|
|
|
|
function copyDirSync(src, dst) {
|
|
fs.mkdirSync(dst, { recursive: true });
|
|
for (const ent of fs.readdirSync(src, { withFileTypes: true })) {
|
|
const s = path.join(src, ent.name);
|
|
const d = path.join(dst, ent.name);
|
|
if (ent.isDirectory()) copyDirSync(s, d);
|
|
else if (ent.isFile()) fs.copyFileSync(s, d);
|
|
// symlinks/sockets/etc skipped intentionally — these surfaces are
|
|
// text-file-only by spec
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Always-on backup. Copies `source` (file or directory tree) into
|
|
* `backupRoot` as `<name>.<ts>.bak`. Returns the backup path, or null if
|
|
* `source` does not exist (e.g. brand-new file/skill).
|
|
*/
|
|
function createBackup({ source, backupRoot, type, name }) {
|
|
if (!fs.existsSync(source)) return null;
|
|
fs.mkdirSync(backupRoot, { recursive: true });
|
|
const stamp = timestamp();
|
|
const dst = path.join(backupRoot, `${name}.${stamp}.bak`);
|
|
if (fs.statSync(source).isDirectory()) {
|
|
copyDirSync(source, dst);
|
|
} else {
|
|
fs.copyFileSync(source, dst);
|
|
}
|
|
return dst;
|
|
}
|
|
|
|
/**
|
|
* Atomic write: tmp file → fsync (best-effort) → rename. Tmp is unlinked
|
|
* on any failure path. Caller is responsible for ensuring parent dir exists.
|
|
*/
|
|
function atomicWriteFile(filePath, content) {
|
|
const dir = path.dirname(filePath);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
|
let fd;
|
|
try {
|
|
fd = fs.openSync(tmp, "wx");
|
|
fs.writeSync(fd, content);
|
|
try {
|
|
fs.fsyncSync(fd);
|
|
} catch {
|
|
// fsync may fail on some filesystems / tmpfs — non-fatal
|
|
}
|
|
fs.closeSync(fd);
|
|
fd = null;
|
|
fs.renameSync(tmp, filePath);
|
|
} catch (err) {
|
|
try {
|
|
if (fd != null) fs.closeSync(fd);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** Throw EOUTOFROOT unless `target` resolves to `root` or a descendant. */
|
|
function assertUnder(root, target) {
|
|
if (!isUnder(root, target)) {
|
|
const err = new Error("resolved path is outside containment root");
|
|
err.code = "EOUTOFROOT";
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The containment check for WRITE paths. `assertUnder` compares strings, which
|
|
* a symlink defeats: a link planted at `<root>/skills/<name>` — or at any
|
|
* ancestor of it — is lexically inside the root while the write lands wherever
|
|
* the link points.
|
|
*
|
|
* So resolve instead of compare. Walk up from `target` to the first component
|
|
* that actually exists, realpath THAT, and require the result inside the
|
|
* realpath'd root. Checking only `target` is not enough: on a create the leaf
|
|
* does not exist yet, `realpathSync` fails with ENOENT, and a symlinked
|
|
* ancestor sails through.
|
|
*
|
|
* A symlink that stays inside the root still works — the rule is where the
|
|
* path lands, not whether a link was involved.
|
|
*
|
|
* Read paths deliberately do NOT use this. Reading through a link shows you a
|
|
* file your own config directory points at, which is what the CLIs do;
|
|
* writing through one clobbers something unrelated.
|
|
*
|
|
* ponytail: check-then-use, so a local attacker who swaps a directory for a
|
|
* symlink between this call and the write still wins. Closing that needs
|
|
* O_NOFOLLOW-style fd work with real platform variance.
|
|
*
|
|
* @param {string} trustedRoot the outermost root the write must stay inside —
|
|
* never a subdirectory an attacker could replace with a link.
|
|
* @param {string} target the path about to be written, created or removed.
|
|
* @param {{create?: boolean, label?: string}} [opts] `create: false` skips
|
|
* materialising the root (a read-side caller must not have side effects).
|
|
*/
|
|
function assertNoSymlinkEscape(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)) {
|
|
const err = new Error(`resolved path escapes the ${opts.label || "config"} root: ${real}`);
|
|
err.code = "EOUTOFROOT";
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
timestamp,
|
|
createBackup,
|
|
atomicWriteFile,
|
|
assertUnder,
|
|
assertNoSymlinkEscape,
|
|
};
|