Skip to content

Commit 65d275b

Browse files
committed
test(public-assets): cover node reader path resolution and traversal safety
1 parent 0256e17 commit 65d275b

1 file changed

Lines changed: 87 additions & 0 deletions

File tree

test/unit/public-assets.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { fileURLToPath } from "node:url";
2+
import { dirname, normalize, resolve } from "pathe";
3+
import { afterEach, describe, expect, it } from "vitest";
4+
import type { Nitro } from "nitro/types";
5+
6+
import publicAssets from "../../src/build/virtual/public-assets.ts";
7+
8+
// Simulated runtime layout of a built bundle.
9+
const SERVER_MAIN = "file:///app/server/index.ts";
10+
const PUBLIC_DIR = "/app/public";
11+
12+
// Crafted manifest mirroring the prerender/manifest decode chain:
13+
// a real (attacker-plantable) public file whose decoded id looks like a `..` traversal.
14+
// `path` is always the build-time, validated, manifest-relative path to a real file inside `public/`.
15+
const ASSETS: Record<string, { path: string }> = {
16+
"/index.html": { path: "../public/index.html" },
17+
// Manifest key is the decoded id (`/%2e%2e/...`); the stored path still points at the
18+
// literal on-disk file inside `public/` (`%252e%252e`), never the server bundle.
19+
"/%2e%2e/server/index.ts": { path: "../public/%252e%252e/server/index.ts" },
20+
};
21+
22+
// Return the `node` reader source, asserting the production selection logic in
23+
// `#nitro/virtual/public-assets` routes `serveStatic: true` (used by all node-based
24+
// presets) to it.
25+
function nodeReaderTemplate(): string {
26+
const nitro = {
27+
options: { serveStatic: true, baseURL: "/", publicAssets: [] },
28+
} as unknown as Nitro;
29+
const templates = publicAssets(nitro);
30+
const main = (
31+
templates.find((t) => t.id === "#nitro/virtual/public-assets")!.template as () => string
32+
)();
33+
expect(main).toContain(`from "#nitro/virtual/public-assets-node"`);
34+
35+
const reader = templates.find((t) => t.id === "#nitro/virtual/public-assets-node")?.template;
36+
if (typeof reader !== "function") {
37+
throw new Error("node reader template not found");
38+
}
39+
return reader() as string;
40+
}
41+
42+
// Evaluate the generated `readAsset` template against the real node helpers it
43+
// imports, recording the filesystem target it asks `fs` to read.
44+
function loadReadAsset(template: string) {
45+
const reads: string[] = [];
46+
const fsp = {
47+
readFile: (p: string | URL) => {
48+
reads.push(typeof p === "string" ? p : fileURLToPath(p));
49+
return Promise.resolve(new Uint8Array());
50+
},
51+
};
52+
// Strip the (single-line) imports and bridge their bindings in as args instead.
53+
const body = template.replace(/^\s*import\s.*$/gm, "").replace(/export\s+function/, "function");
54+
(globalThis as any).__nitro_main__ = SERVER_MAIN;
55+
const readAsset = new Function(
56+
"fsp",
57+
"fileURLToPath",
58+
"resolve",
59+
"dirname",
60+
"assets",
61+
`${body}\nreturn readAsset;`
62+
)(fsp, fileURLToPath, resolve, dirname, ASSETS) as (id: string) => Promise<unknown>;
63+
return { readAsset, reads };
64+
}
65+
66+
describe("virtual/public-assets node reader", () => {
67+
afterEach(() => {
68+
delete (globalThis as any).__nitro_main__;
69+
});
70+
71+
it("serves a normal public asset from within public/", async () => {
72+
const { readAsset, reads } = loadReadAsset(nodeReaderTemplate());
73+
await readAsset("/index.html");
74+
expect(normalize(reads.at(-1)!)).toBe(`${PUBLIC_DIR}/index.html`);
75+
});
76+
77+
// The node reader reads `assets[id].path` (a build-time validated, public-relative
78+
// path), never the decoded `id`. So even a manifest key that looks like a `..`
79+
// traversal resolves to its real on-disk file inside public/, never the server bundle.
80+
it("reads via the manifest path, so a traversal-looking key stays inside public/", async () => {
81+
const { readAsset, reads } = loadReadAsset(nodeReaderTemplate());
82+
await readAsset("/%2e%2e/server/index.ts");
83+
const resolved = normalize(reads.at(-1)!);
84+
expect(resolved.startsWith(`${PUBLIC_DIR}/`)).toBe(true);
85+
expect(resolved).not.toBe("/app/server/index.ts");
86+
});
87+
});

0 commit comments

Comments
 (0)