Skip to content

Commit 21f658f

Browse files
committed
fix: aim the public traversal test at the vector that reaches the guard
The counterfactual did not discriminate: removing the containment check left the test green. `/public/%2E%2E/secret.txt` never enters the public branch at all, because the WHATWG URL parser decodes `%2E%2E` and normalises the dot segment away, so the request arrives as plain `/secret.txt`. The test was observing nothing. An encoded slash survives parsing intact, so `/public/..%2Fsecret.txt` enters the branch with a path `join` then resolves outside appDir/public/, which is what the guard is for. With that vector the test goes red when the guard is removed. The comment this was derived from asserted the opposite ("after URL parsing, which doesn't touch %2E"), so correct it where it now lives rather than carry an inaccurate claim into the extracted function.
1 parent 915c662 commit 21f658f

2 files changed

Lines changed: 19 additions & 7 deletions

File tree

packages/server/src/dev.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2106,14 +2106,17 @@ async function tryServePublicAsset(path, ctx) {
21062106
const p = path === '/favicon.ico' ? '/public/favicon.ico' : (ROOT_ASSETS[path] || path);
21072107
const abs = join(appDir, p);
21082108
// Containment check. `join` normalises `..` segments, so a path
2109-
// like `/public/%2E%2E/secret/x.svg` decodes (after URL parsing,
2110-
// which doesn't touch `%2E`) to `/public/../secret/x.svg` and
2111-
// `join(appDir, ...)` resolves it to `appDir/secret/x.svg`. The
2112-
// resulting `abs` could be inside `appDir` but OUTSIDE `appDir/
2113-
// public/`, exposing files the user reasonably thought were
2109+
// like `/public/..%2Fsecret/x.svg` decodes to `/public/../secret/
2110+
// x.svg` and `join(appDir, ...)` resolves it to `appDir/secret/
2111+
// x.svg`. The resulting `abs` could be inside `appDir` but OUTSIDE
2112+
// `appDir/public/`, exposing files the user reasonably thought were
21142113
// private under their non-public directories. Reject anything
21152114
// that doesn't stay under `appDir/public/` (and the favicon
21162115
// exception, which is already validated above).
2116+
// The live vector encodes the SLASH, not the dots: the WHATWG URL
2117+
// parser decodes `%2E%2E` and normalises the dot segment away, so a
2118+
// `/public/%2E%2E/x` request arrives here as plain `/x` and never
2119+
// enters this branch. `..%2F` survives parsing intact and does.
21172120
const publicRoot = join(appDir, 'public') + sep;
21182121
if (!abs.startsWith(publicRoot)) {
21192122
return new Response(null, { status: 404 });

packages/server/test/dev/public-before-analysis.test.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,21 @@ test('root middleware does not run for /public/* in dev, and does in prod', asyn
113113
});
114114

115115
// COUNTERFACTUAL: drop the containment check from `tryServePublicAsset` and this
116-
// serves the file, which is a directory-traversal hole.
116+
// serves the file, which is a directory-traversal hole. Verified 2026-08-12 at
117+
// 915c662d by removing the guard and watching this go red.
118+
//
119+
// The vector has to use an encoded SLASH (`..%2F`), not encoded dots. The
120+
// WHATWG URL parser decodes `%2E%2E` to `..` and then normalises the dot
121+
// segment away, so `/public/%2E%2E/secret.txt` arrives as `/secret.txt` and
122+
// never enters the public branch at all: asserting on it would pass with the
123+
// guard removed, which is a test that observes nothing. `..%2F` survives
124+
// parsing intact, so the branch is entered with a path that `join` then
125+
// resolves outside `appDir/public/`, which is exactly what the guard is for.
117126
test('the traversal guard travels with the moved code (dev early path)', async () => {
118127
const appDir = makeApp();
119128
writeFileSync(join(appDir, 'secret.txt'), 'nope\n');
120129
const app = await createRequestHandler({ appDir, dev: true });
121-
const res = await app.handle(new Request('http://x/public/%2E%2E/secret.txt'));
130+
const res = await app.handle(new Request('http://x/public/..%2Fsecret.txt'));
122131
assert.equal(res.status, 404, 'a path that escapes appDir/public/ is refused');
123132
assert.notEqual(await res.text(), 'nope\n');
124133
});

0 commit comments

Comments
 (0)