Files
Ben Drucker 9c85310151 fix(doc): deny symlink escapes on /api/doc reads (#1437)
Gates /api/doc reads on realpath containment: a path must resolve inside the project both as written and after symlink resolution, else 403. Consolidates the duplicated doc-read logic from both server runtimes into one shared module (packages/shared/doc-resolve.ts, vendored to Pi).

Claude-Session: https://claude.ai/code/session_019GV6EKtzh8Pf9GA2rrBLNf
2026-09-07 10:50:12 -07:00

75 lines
3.1 KiB
TypeScript

/**
* The `/api/doc` containment gate. Normalization has to be two-sided: macOS
* `tmpdir()` is a symlink, so
* realpath containment against a non-realpathed root would deny legitimate
* reads for anyone whose root sits under one.
*/
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getAllowedRootPaths, isPathAllowed } from "./doc-resolve";
const tempDirs: string[] = [];
function makeTempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("isPathAllowed", () => {
test("serves a file inside the root and denies one outside it", () => {
const root = makeTempDir("plannotator-gate-root-");
const outside = makeTempDir("plannotator-gate-outside-");
writeFileSync(join(root, "note.md"), "note\n");
writeFileSync(join(outside, "secret.md"), "secret\n");
const roots = getAllowedRootPaths({ rootPaths: [root] });
expect(isPathAllowed(join(root, "note.md"), roots)).toBe(true);
expect(isPathAllowed(join(outside, "secret.md"), roots)).toBe(false);
});
test("denies a symlink that escapes the root, and a file reached through a symlinked directory", () => {
const root = makeTempDir("plannotator-gate-link-root-");
const outside = makeTempDir("plannotator-gate-link-outside-");
writeFileSync(join(outside, "secret.md"), "secret\n");
symlinkSync(join(outside, "secret.md"), join(root, "link.md"));
symlinkSync(outside, join(root, "linkdir"));
const roots = getAllowedRootPaths({ rootPaths: [root] });
expect(isPathAllowed(join(root, "link.md"), roots)).toBe(false);
expect(isPathAllowed(join(root, "linkdir", "secret.md"), roots)).toBe(false);
});
test("allows a symlinked root under either spelling", () => {
const realFolder = makeTempDir("plannotator-gate-real-");
const linkParent = makeTempDir("plannotator-gate-linkparent-");
const linkFolder = join(linkParent, "docs");
writeFileSync(join(realFolder, "note.md"), "note\n");
symlinkSync(realFolder, linkFolder);
const roots = getAllowedRootPaths({ rootPaths: [linkFolder] });
expect(isPathAllowed(join(linkFolder, "note.md"), roots)).toBe(true);
expect(isPathAllowed(realpathSync(join(realFolder, "note.md")), roots)).toBe(true);
});
test("judges a path whose leaf does not exist by its deepest existing ancestor", () => {
const root = makeTempDir("plannotator-gate-missing-root-");
const outside = makeTempDir("plannotator-gate-missing-outside-");
symlinkSync(outside, join(root, "linkdir"));
mkdirSync(join(root, "real"), { recursive: true });
const roots = getAllowedRootPaths({ rootPaths: [root] });
expect(isPathAllowed(join(root, "real", "absent.md"), roots)).toBe(true);
expect(isPathAllowed(join(root, "absent", "deeper", "absent.md"), roots)).toBe(true);
expect(isPathAllowed(join(root, "linkdir", "absent.md"), roots)).toBe(false);
});
});