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:
2026-09-09 08:30:45 +07:00
parent 5720e8d5f8
commit 2ade30f670
6 changed files with 320 additions and 37 deletions
+62 -8
View File
@@ -551,16 +551,70 @@ describe("/api/cc-config", () => {
});
it("PUT /file edits memory CLAUDE.md without a name", async () => {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
// chdir, not `?cwd=`: project-scope writes resolve against the SERVER's
// cwd on purpose, so a request cannot pick the destination. Passing
// `?cwd=` here would have written into the repo's own CLAUDE.md.
const prev = process.cwd();
process.chdir(FAKE_PROJECT);
try {
const { status, body } = await fetchJson("/api/cc-config/file", {
method: "PUT",
body: { scope: "project", type: "memory", content: "# new project memory" },
}
);
assert.equal(status, 200);
assert.ok(body.backupPath, "previous CLAUDE.md should be backed up");
assert.equal(fs.readFileSync(body.file, "utf8"), "# new project memory");
});
assert.equal(status, 200);
assert.ok(body.backupPath, "previous CLAUDE.md should be backed up");
assert.equal(fs.readFileSync(body.file, "utf8"), "# new project memory");
assert.equal(body.file, path.join(FAKE_PROJECT, "CLAUDE.md"));
} finally {
process.chdir(prev);
}
});
it("PUT /file ignores ?cwd= — a caller cannot choose where a write lands", async () => {
// The hole this closes: `?cwd=<anywhere>` with scope "project" dropped a
// file into any directory on disk. PATCH /settings already refused it;
// PUT/DELETE did not.
const victim = path.join(TMP, "victim-project");
fs.mkdirSync(victim, { recursive: true });
fs.writeFileSync(path.join(victim, "CLAUDE.md"), "UNTOUCHED");
const prev = process.cwd();
process.chdir(FAKE_PROJECT);
try {
const { status, body } = await fetchJson(
`/api/cc-config/file?cwd=${encodeURIComponent(victim)}`,
{
method: "PUT",
body: { scope: "project", type: "memory", content: "PWNED" },
}
);
assert.equal(status, 200);
assert.equal(body.file, path.join(FAKE_PROJECT, "CLAUDE.md"), "must ignore the query cwd");
assert.equal(fs.readFileSync(path.join(victim, "CLAUDE.md"), "utf8"), "UNTOUCHED");
} finally {
process.chdir(prev);
}
});
it("DELETE /file ignores ?cwd= too", async () => {
const victim = path.join(TMP, "victim-delete");
fs.mkdirSync(victim, { recursive: true });
fs.writeFileSync(path.join(victim, "CLAUDE.md"), "UNTOUCHED");
fs.writeFileSync(path.join(FAKE_PROJECT, "CLAUDE.md"), "# server-cwd memory");
const prev = process.cwd();
process.chdir(FAKE_PROJECT);
try {
const { status } = await fetchJson(`/api/cc-config/file?cwd=${encodeURIComponent(victim)}`, {
method: "DELETE",
body: { scope: "project", type: "memory" },
});
assert.equal(status, 200);
assert.equal(fs.existsSync(path.join(victim, "CLAUDE.md")), true, "victim must survive");
assert.equal(fs.existsSync(path.join(FAKE_PROJECT, "CLAUDE.md")), false);
} finally {
process.chdir(prev);
}
});
it("DELETE /file backs up and removes a single-file agent", async () => {