|
1 | | -import { describe, test, expect } from "bun:test"; |
| 1 | +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; |
| 2 | +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; |
| 3 | +import { existsSync, realpathSync } from "node:fs"; |
| 4 | +import { tmpdir } from "node:os"; |
| 5 | +import { join } from "node:path"; |
2 | 6 |
|
3 | 7 | import { pathEscapePlugin } from "./path-escape-plugin.js"; |
4 | 8 | import type { ToolCall, ToolResult } from "@intx/types/runtime"; |
@@ -175,4 +179,55 @@ describe("pathEscapePlugin", () => { |
175 | 179 | const args = JSON.parse(String(allowed.content)) as { path: string }; |
176 | 180 | expect(args.path).toBe("/other-repo/README.md"); |
177 | 181 | }); |
| 182 | + |
| 183 | + describe("symlink TOCTOU (CL-6712)", () => { |
| 184 | + let cwd = ""; |
| 185 | + |
| 186 | + beforeEach(async () => { |
| 187 | + cwd = await mkdtemp(join(tmpdir(), "corbits-path-escape-")); |
| 188 | + }); |
| 189 | + |
| 190 | + afterEach(async () => { |
| 191 | + await rm(cwd, { recursive: true, force: true }); |
| 192 | + }); |
| 193 | + |
| 194 | + test("write_file receives the canonical path, unaffected by a later symlink retarget", async () => { |
| 195 | + const realTarget = join(cwd, "real-target"); |
| 196 | + await mkdir(realTarget, { recursive: true }); |
| 197 | + const link = join(cwd, "link"); |
| 198 | + await symlink(realTarget, link); |
| 199 | + |
| 200 | + const plugin = pathEscapePlugin(cwd); |
| 201 | + const next = async (call: ToolCall): Promise<ToolResult> => ({ |
| 202 | + callId: call.id, |
| 203 | + content: JSON.stringify(call.arguments), |
| 204 | + }); |
| 205 | + const handler = plugin.middleware ? plugin.middleware(next) : next; |
| 206 | + |
| 207 | + const result = await handler( |
| 208 | + makeCall("write_file", { path: join("link", "note.txt"), content: "hi" }), |
| 209 | + new AbortController().signal, |
| 210 | + ); |
| 211 | + const args = JSON.parse(String(result.content)) as { path: string }; |
| 212 | + // The path handed to write_file is already the resolved real-target |
| 213 | + // location, not the symlink-relative path. |
| 214 | + expect(args.path).toBe(join(realpathSync(realTarget), "note.txt")); |
| 215 | + |
| 216 | + // An attacker retargets the symlink after the allow check. A writer |
| 217 | + // that (correctly) uses the path it was given above is unaffected — |
| 218 | + // it never re-traverses "link". |
| 219 | + const outside = await mkdtemp(join(tmpdir(), "corbits-path-escape-outside-")); |
| 220 | + await rm(link); |
| 221 | + await symlink(outside, link); |
| 222 | + expect(args.path).not.toContain(outside); |
| 223 | + |
| 224 | + // A real writer using the resolved path lands the bytes at the |
| 225 | + // canonical (safe) location, never under the retargeted symlink. |
| 226 | + await writeFile(args.path, "hi"); |
| 227 | + expect(await readFile(args.path, "utf8")).toBe("hi"); |
| 228 | + expect(existsSync(join(outside, "note.txt"))).toBe(false); |
| 229 | + |
| 230 | + await rm(outside, { recursive: true, force: true }); |
| 231 | + }); |
| 232 | + }); |
178 | 233 | }); |
0 commit comments