|
1 | 1 | import { strict as assert } from "node:assert"; |
2 | | -import { test } from "node:test"; |
| 2 | +import { |
| 3 | + existsSync, |
| 4 | + mkdirSync, |
| 5 | + mkdtempSync, |
| 6 | + realpathSync, |
| 7 | + rmSync, |
| 8 | + symlinkSync, |
| 9 | + writeFileSync, |
| 10 | +} from "node:fs"; |
| 11 | +import { tmpdir } from "node:os"; |
| 12 | +import { join, resolve } from "node:path"; |
| 13 | +import { after, before, test } from "node:test"; |
3 | 14 |
|
4 | 15 | import { isContainedIn, resolveContainedCopies } from "./registry-target-paths.mjs"; |
5 | 16 |
|
6 | | -const ROOT = "/tmp/hf-catalog-demo"; |
7 | | -const always = () => true; |
| 17 | +// Real fixtures rather than string cases: the second escape this guards is a |
| 18 | +// symlink, which only exists on a filesystem. A purely lexical test suite is |
| 19 | +// exactly what stayed green through the first version of this check. |
| 20 | +let sandbox; |
| 21 | +let project; |
8 | 22 |
|
9 | | -test("ordinary manifest entries are copied", () => { |
10 | | - const copies = resolveContainedCopies( |
11 | | - ROOT, |
12 | | - [{ path: "demo.html", target: "compositions/demo.html" }], |
13 | | - always, |
14 | | - ); |
15 | | - assert.deepEqual(copies, [[`${ROOT}/demo.html`, `${ROOT}/compositions/demo.html`]]); |
| 23 | +before(() => { |
| 24 | + sandbox = mkdtempSync(join(tmpdir(), "hf-registry-paths-")); |
| 25 | + project = join(sandbox, "project"); |
| 26 | + mkdirSync(join(project, "nested"), { recursive: true }); |
| 27 | + mkdirSync(join(sandbox, "outside"), { recursive: true }); |
| 28 | + writeFileSync(join(project, "demo.html"), "<html>\n"); |
| 29 | + writeFileSync(join(sandbox, "secret.txt"), "runner secret\n"); |
| 30 | + symlinkSync(join(sandbox, "outside"), join(project, "escape")); |
| 31 | + symlinkSync(join(sandbox, "secret.txt"), join(project, "leak.txt")); |
| 32 | + symlinkSync(join(project, "nested"), join(project, "inward")); |
16 | 33 | }); |
17 | 34 |
|
18 | | -// Both fields are attacker-controlled: catalog-previews.yml runs on |
19 | | -// pull_request for any registry change, so the manifest arrives from the PR. |
| 35 | +after(() => rmSync(sandbox, { recursive: true, force: true })); |
| 36 | + |
| 37 | +const allow = (files) => resolveContainedCopies(project, files, existsSync); |
| 38 | + |
| 39 | +test("an ordinary manifest entry is copied", () => { |
| 40 | + // Compared against the real path: the helper resolves the project root, which |
| 41 | + // matters on macOS where the temp directory is itself a symlink. |
| 42 | + const real = realpathSync(project); |
| 43 | + assert.deepEqual(allow([{ path: "demo.html", target: "compositions/demo.html" }]), [ |
| 44 | + [resolve(real, "demo.html"), resolve(real, "compositions/demo.html")], |
| 45 | + ]); |
| 46 | +}); |
| 47 | + |
| 48 | +test("traversal that returns inside the project is allowed", () => { |
| 49 | + assert.equal(allow([{ path: "nested/../demo.html", target: "out/demo.html" }]).length, 1); |
| 50 | +}); |
| 51 | + |
| 52 | +// Lexical escapes. |
20 | 53 |
|
21 | 54 | test("a traversing path cannot read outside the project", () => { |
22 | | - const copies = resolveContainedCopies( |
23 | | - ROOT, |
24 | | - [{ path: "../../../../etc/passwd", target: "leak.txt" }], |
25 | | - always, |
26 | | - ); |
27 | | - assert.deepEqual(copies, []); |
| 55 | + assert.deepEqual(allow([{ path: "../secret.txt", target: "stolen.txt" }]), []); |
28 | 56 | }); |
29 | 57 |
|
30 | 58 | test("a traversing target cannot write outside the project", () => { |
31 | | - const copies = resolveContainedCopies( |
32 | | - ROOT, |
33 | | - [{ path: "demo.html", target: "../../../../home/runner/.bashrc" }], |
34 | | - always, |
35 | | - ); |
36 | | - assert.deepEqual(copies, []); |
| 59 | + assert.deepEqual(allow([{ path: "demo.html", target: "../pwned.txt" }]), []); |
37 | 60 | }); |
38 | 61 |
|
39 | 62 | test("an absolute path or target is refused on either side", () => { |
40 | | - assert.deepEqual( |
41 | | - resolveContainedCopies(ROOT, [{ path: "/etc/passwd", target: "leak.txt" }], always), |
42 | | - [], |
43 | | - ); |
44 | | - assert.deepEqual( |
45 | | - resolveContainedCopies(ROOT, [{ path: "demo.html", target: "/etc/cron.d/x" }], always), |
46 | | - [], |
47 | | - ); |
| 63 | + assert.deepEqual(allow([{ path: "/etc/passwd", target: "stolen.txt" }]), []); |
| 64 | + assert.deepEqual(allow([{ path: "demo.html", target: "/tmp/pwned.txt" }]), []); |
48 | 65 | }); |
49 | 66 |
|
50 | | -test("traversal that returns inside the project is allowed", () => { |
51 | | - const copies = resolveContainedCopies( |
52 | | - ROOT, |
53 | | - [{ path: "nested/../demo.html", target: "out/demo.html" }], |
54 | | - always, |
55 | | - ); |
56 | | - assert.deepEqual(copies, [[`${ROOT}/demo.html`, `${ROOT}/out/demo.html`]]); |
| 67 | +test("a sibling directory sharing the project's prefix is still outside", () => { |
| 68 | + assert.equal(isContainedIn(project, "../project-evil/x"), false); |
57 | 69 | }); |
58 | 70 |
|
59 | | -test("a sibling directory sharing the project's prefix is still outside", () => { |
60 | | - assert.equal(isContainedIn(ROOT, "../hf-catalog-demo-evil/x"), false); |
| 71 | +// Symbolic escapes. resolve()/relative() do not follow links, so every case |
| 72 | +// below passed the first, lexical-only version of this check. |
| 73 | + |
| 74 | +test("a symlinked target directory cannot be written through", () => { |
| 75 | + assert.deepEqual(allow([{ path: "demo.html", target: "escape/pwned.txt" }]), []); |
| 76 | +}); |
| 77 | + |
| 78 | +test("a symlinked source file cannot be read through", () => { |
| 79 | + assert.deepEqual(allow([{ path: "leak.txt", target: "stolen.txt" }]), []); |
61 | 80 | }); |
62 | 81 |
|
63 | | -test("containment does not depend on the file existing", () => { |
64 | | - assert.equal(isContainedIn(ROOT, "../../etc/passwd"), false); |
65 | | - assert.deepEqual( |
66 | | - resolveContainedCopies(ROOT, [{ path: "../../etc/passwd", target: "x" }], () => true), |
67 | | - [], |
68 | | - ); |
| 82 | +test("a symlink is refused even when it points back inside the project", () => { |
| 83 | + // Rejected rather than followed: nothing in the registry needs a symlink, and |
| 84 | + // allowing one means trusting its target not to change before the copy. |
| 85 | + assert.deepEqual(allow([{ path: "demo.html", target: "inward/a.txt" }]), []); |
| 86 | +}); |
| 87 | + |
| 88 | +test("a deeper path through a symlinked component is refused", () => { |
| 89 | + assert.deepEqual(allow([{ path: "demo.html", target: "escape/a/b/c.txt" }]), []); |
69 | 90 | }); |
70 | 91 |
|
71 | 92 | test("incomplete entries are skipped rather than resolved", () => { |
72 | | - assert.deepEqual( |
73 | | - resolveContainedCopies(ROOT, [{ path: "demo.html" }, { target: "x" }, {}], always), |
74 | | - [], |
75 | | - ); |
| 93 | + assert.deepEqual(allow([{ path: "demo.html" }, { target: "x" }, {}]), []); |
| 94 | +}); |
| 95 | + |
| 96 | +test("containment does not depend on the candidate existing", () => { |
| 97 | + assert.equal(isContainedIn(project, "../../etc/passwd"), false); |
| 98 | + assert.equal(isContainedIn(project, "not-created-yet/file.txt"), true); |
76 | 99 | }); |
0 commit comments