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:
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @file cc-mutate-containment.test.js
|
||||
* @description Path-containment tests for cc-mutate.js's artifact writer.
|
||||
*
|
||||
* `isUnder` compares strings, which a symlink defeats: a link planted at
|
||||
* `~/.claude/skills/<name>` — or at any ancestor of it — is lexically inside
|
||||
* the root while the write lands wherever the link points. The Codex writer
|
||||
* hit both variants (leaf first, then ancestor on a fresh create, where
|
||||
* realpath fails with ENOENT and a naive check waves it through), and the
|
||||
* backup DESTINATION is a third path with the same exposure.
|
||||
*
|
||||
* Every case here asserts the refusal AND that nothing appeared outside the
|
||||
* root — a thrown error with the write already done is not a fix.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-mutate-containment-"));
|
||||
const FAKE_HOME = path.join(TMP, "home", ".claude");
|
||||
const OUTSIDE = path.join(TMP, "outside");
|
||||
|
||||
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||
fs.mkdirSync(OUTSIDE, { recursive: true });
|
||||
|
||||
process.env.CLAUDE_HOME = FAKE_HOME;
|
||||
const ccMutate = require("../lib/cc-mutate");
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 }));
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
|
||||
fs.rmSync(OUTSIDE, { recursive: true, force: true });
|
||||
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||
fs.mkdirSync(OUTSIDE, { recursive: true });
|
||||
});
|
||||
|
||||
const write = (over = {}) =>
|
||||
ccMutate.writeArtifact({
|
||||
scope: "user",
|
||||
type: "skills",
|
||||
name: "victim",
|
||||
content: "NEW",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("cc-mutate: symlink containment on the write path", () => {
|
||||
it("refuses a leaf symlink pointing out of the root, and writes nothing there", () => {
|
||||
const outsideDir = path.join(OUTSIDE, "target");
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outsideDir, "SKILL.md"), "ORIGINAL");
|
||||
fs.mkdirSync(path.join(FAKE_HOME, "skills"), { recursive: true });
|
||||
fs.symlinkSync(outsideDir, path.join(FAKE_HOME, "skills", "victim"));
|
||||
|
||||
assert.throws(
|
||||
() => write(),
|
||||
(e) => e.code === "EOUTOFROOT"
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(outsideDir, "SKILL.md"), "utf8"), "ORIGINAL");
|
||||
});
|
||||
|
||||
it("refuses a symlinked ANCESTOR even when the artifact itself is new", () => {
|
||||
// The case a leaf-only check misses: realpath on a not-yet-existing leaf
|
||||
// throws ENOENT, so the walk has to climb to the symlinked parent.
|
||||
fs.symlinkSync(OUTSIDE, path.join(FAKE_HOME, "skills"));
|
||||
|
||||
assert.throws(
|
||||
() => write({ name: "fresh" }),
|
||||
(e) => e.code === "EOUTOFROOT"
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(OUTSIDE, "fresh")), false);
|
||||
});
|
||||
|
||||
it("refuses to back up through a symlinked backup root", () => {
|
||||
// rmSync/unlink only remove the link, but the backup runs first and
|
||||
// copies THROUGH it — so the destination needs the guard too.
|
||||
fs.mkdirSync(path.join(FAKE_HOME, "skills", "victim"), { recursive: true });
|
||||
fs.writeFileSync(path.join(FAKE_HOME, "skills", "victim", "SKILL.md"), "OLD");
|
||||
fs.symlinkSync(OUTSIDE, path.join(FAKE_HOME, "cc-config-backups"));
|
||||
|
||||
assert.throws(
|
||||
() => write(),
|
||||
(e) => e.code === "EOUTOFROOT"
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(OUTSIDE), []);
|
||||
// the original is untouched — the backup failing must not have written
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(FAKE_HOME, "skills", "victim", "SKILL.md"), "utf8"),
|
||||
"OLD"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a delete whose target is a symlink out of the root", () => {
|
||||
const outsideFile = path.join(OUTSIDE, "SKILL.md");
|
||||
fs.writeFileSync(outsideFile, "ORIGINAL");
|
||||
fs.mkdirSync(path.join(FAKE_HOME, "skills"), { recursive: true });
|
||||
fs.symlinkSync(OUTSIDE, path.join(FAKE_HOME, "skills", "victim"));
|
||||
|
||||
assert.throws(
|
||||
() => ccMutate.deleteArtifact({ scope: "user", type: "skills", name: "victim" }),
|
||||
(e) => e.code === "EOUTOFROOT"
|
||||
);
|
||||
assert.equal(fs.readFileSync(outsideFile, "utf8"), "ORIGINAL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cc-mutate: symlinks that stay inside the root still work", () => {
|
||||
it("writes through a symlink whose target is inside the root", () => {
|
||||
// Reorganising within ~/.claude is legitimate; the rule is where the path
|
||||
// lands, not whether a link was involved.
|
||||
const real = path.join(FAKE_HOME, "real-skills");
|
||||
fs.mkdirSync(real, { recursive: true });
|
||||
fs.symlinkSync(real, path.join(FAKE_HOME, "skills"));
|
||||
|
||||
const res = write({ name: "ok" });
|
||||
assert.equal(res.created, true);
|
||||
assert.equal(fs.readFileSync(path.join(real, "ok", "SKILL.md"), "utf8"), "NEW");
|
||||
});
|
||||
|
||||
it("works when CLAUDE_HOME itself is a symlink — the dotfiles case", () => {
|
||||
// A dotfiles-managed ~/.claude is ordinary. Comparing an unresolved root
|
||||
// against a resolved target would reject every write on such a machine.
|
||||
const realHome = path.join(TMP, "real-home");
|
||||
fs.rmSync(realHome, { recursive: true, force: true });
|
||||
fs.mkdirSync(realHome, { recursive: true });
|
||||
const linkedHome = path.join(TMP, "linked-home");
|
||||
fs.rmSync(linkedHome, { recursive: true, force: true });
|
||||
fs.symlinkSync(realHome, linkedHome);
|
||||
|
||||
process.env.CLAUDE_HOME = linkedHome;
|
||||
try {
|
||||
const res = write({ name: "ok" });
|
||||
assert.equal(res.created, true);
|
||||
assert.equal(fs.readFileSync(path.join(realHome, "skills", "ok", "SKILL.md"), "utf8"), "NEW");
|
||||
} finally {
|
||||
process.env.CLAUDE_HOME = FAKE_HOME;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user