fix(codex): close symlink escape on GET /file, pin project writes to server cwd
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user