fix(codex): close symlink escape on GET /file, pin project writes to server cwd

This commit is contained in:
2026-09-08 20:07:57 +07:00
parent 32839332f2
commit 29ed86d672
7 changed files with 246 additions and 89 deletions
+111
View File
@@ -211,6 +211,42 @@ describe("/api/codex-config", () => {
assert.equal(body.error.code, "READ_DENIED");
});
it("file endpoint refuses a leaf symlink that escapes CODEX_HOME (no secret leaks)", async () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-outside-"));
fs.writeFileSync(path.join(outside, "secret.rules"), "TOP SECRET LEAF");
const linkPath = path.join(FAKE_HOME, "rules", "leaf-link.rules");
fs.symlinkSync(path.join(outside, "secret.rules"), linkPath, "file");
try {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(linkPath)}`
);
assert.equal(status, 400);
assert.equal(body.error.code, "READ_DENIED");
assert.doesNotMatch(JSON.stringify(body), /TOP SECRET LEAF/);
} finally {
fs.rmSync(outside, { recursive: true, force: true });
}
});
it("file endpoint refuses an ancestor symlink that escapes CODEX_HOME (no secret leaks)", async () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-outside-"));
fs.writeFileSync(path.join(outside, "secret.txt"), "TOP SECRET ANCESTOR");
const linkDir = path.join(FAKE_HOME, "evil-dir");
fs.symlinkSync(outside, linkDir, "dir");
try {
const target = path.join(linkDir, "secret.txt");
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
);
assert.equal(status, 400);
assert.equal(body.error.code, "READ_DENIED");
assert.doesNotMatch(JSON.stringify(body), /TOP SECRET ANCESTOR/);
} finally {
fs.rmSync(linkDir, { force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
it("file endpoint requires a path", async () => {
const { status, body } = await fetchJson("/api/codex-config/file");
assert.equal(status, 400);
@@ -267,6 +303,56 @@ describe("/api/codex-config", () => {
);
});
it("PUT /file ignores ?cwd= for project scope — writes to the server's own cwd, not a caller-chosen dir", async () => {
const victim = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-victim-"));
const originalCwd = process.cwd();
const serverCwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-servercwd-"));
process.chdir(serverCwd);
try {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(victim)}`,
{
method: "PUT",
body: { scope: "project", type: "memory", content: "PWNED" },
}
);
assert.equal(status, 200);
assert.equal(fs.existsSync(path.join(victim, "AGENTS.md")), false, "must not write to ?cwd=");
assert.equal(body.file, path.join(serverCwd, "AGENTS.md"));
assert.equal(fs.readFileSync(body.file, "utf8"), "PWNED");
} finally {
process.chdir(originalCwd);
fs.rmSync(victim, { recursive: true, force: true });
fs.rmSync(serverCwd, { recursive: true, force: true });
}
});
it("DELETE /file ignores ?cwd= for project scope", async () => {
const victim = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-victim-"));
fs.writeFileSync(path.join(victim, ".codex-marker"), "x"); // proves this dir wasn't touched
const originalCwd = process.cwd();
const serverCwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-servercwd-"));
fs.mkdirSync(path.join(serverCwd, ".codex", "rules"), { recursive: true });
fs.writeFileSync(path.join(serverCwd, ".codex", "rules", "victim.rules"), "real");
process.chdir(serverCwd);
try {
const { status } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(victim)}`,
{
method: "DELETE",
body: { scope: "project", type: "rules", name: "victim" },
}
);
assert.equal(status, 200);
assert.equal(fs.existsSync(path.join(victim, ".codex-marker")), true, "?cwd= dir left alone");
assert.equal(fs.existsSync(path.join(serverCwd, ".codex", "rules", "victim.rules")), false);
} finally {
process.chdir(originalCwd);
fs.rmSync(victim, { recursive: true, force: true });
fs.rmSync(serverCwd, { recursive: true, force: true });
}
});
it("PUT /file rejects a write whose name escapes the root with 400, not 500", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
@@ -364,6 +450,31 @@ describe("/api/codex-config", () => {
}
});
it("GET /mcp reports unparseable CLI JSON as 502, not a type-confused 200", async () => {
const restore = stubCodex('echo "not json"');
try {
const { status, body } = await fetchJson("/api/codex-config/mcp");
assert.equal(status, 502);
assert.equal(body.error.code, "ECLIBADOUTPUT");
} finally {
restore();
}
});
it("POST /mcp/add rejects a non-array args with 400, not a 500", async () => {
const restore = stubCodex(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/codex-config/mcp/add", {
method: "POST",
body: { name: "docs", command: "node", args: "oops" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADARG");
} finally {
restore();
}
});
it("POST /mcp/add runs the CLI with the given argv", async () => {
const restore = stubCodex(ECHO_ARGV);
try {