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:
@@ -98,4 +98,66 @@ function assertUnder(root, target) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { timestamp, createBackup, atomicWriteFile, assertUnder };
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user