Summary
serveStatic should decode the resolved asset id for the on-disk lookup by default, matching the ecosystem convention used by sirv and serve-static. Today a file whose name contains a literal % is unreachable, and the served id representation diverges from every other Node static server.
This is a followup to #1428 — deliberately not included there, since #1428's scope is exporting resolveDotSegments and this reverses the direction of its fix(static): don't decode the pathname a second time commit (cd85ebf). Should land as its own PR after #1428 merges.
Current behavior (after #1428)
serveStatic computes the asset id as:
const originalId = resolveDotSegments(withoutTrailingSlash(event.url.pathname));
event.url.pathname is decoded once by the event layer (decodePathname, a decodeURI that preserves %25), so the id keeps %25 encoded. Consequences:
| Request |
file on disk |
h3 id (now) |
sirv / serve-static |
/50%25.png |
50%.png |
/50%25.png → 404 |
serves 50%.png (200) |
/files/a%252fb |
a%2fb |
/files/a%252fb |
a%2fb |
The 50%.png case is a regression vs pre-#1428 h3, which did resolveDotSegments(decodeURI(...)) and served it. It was verified empirically against [email protected] and [email protected] (both serve /50%25.png → 50%.png with 200), and against nginx for the separator behavior.
Proposed change
Decode the resolved id for the filesystem lookup after traversal resolution:
// Resolve traversal on the routing representation (%25 preserved) FIRST, so
// `..` is clamped to the root before anything is decoded.
const resolvedId = resolveDotSegments(withoutTrailingSlash(event.url.pathname));
// Then decode for the on-disk id (like sirv / serve-static). `decodeURI` only
// peels the preserved `%25`; reserved separators `%2f`/`%5c` stay literal.
let originalId = resolvedId;
if (originalId.includes("%")) {
try {
originalId = decodeURI(originalId);
} catch {
originalId = resolvedId; // malformed % (only reachable with allowMalformedURL)
}
}
Why the ordering is safe (decode after resolve)
decodeURI decodes %25→% but leaves reserved %2f/%5c literal, so it can never introduce a new / boundary. Any real traversal (%2e%2e, nested %252e%252e, deeply re-encoded %25%32%66) is already collapsed and root-clamped by resolveDotSegments before the decode runs. Verified cases:
| Input |
resolved (pre-decode) |
id after decodeURI |
safe? |
/50%25.png |
/50%25.png |
/50%.png |
✅ intended |
/files/a%252fb |
/files/a%252fb |
/files/a%2fb |
✅ inert %2f text |
/a/..%252fb |
/a/..%252fb |
/a/..%2fb |
✅ single literal segment, not ../ |
/a/%252e%252e/b |
/b |
/b |
✅ traversal collapsed first |
/a/..%25%32%66..%25%32%66secret |
/a/..%252f..%252fsecret |
/a/..%2f..%2fsecret |
✅ literal text, no / boundary |
Decoding before resolve (the old pre-#1428 order) is the unsafe one — that's what let a double-encoded separator collapse into a real boundary — so the fix must keep the decode strictly after resolution.
Tests to update
Both currently pin the %25-preserved behavior and would flip:
test/static.test.ts → does not decode the pathname a second time (/files/a%252fb): expect id a%2fb.
test/static.test.ts → keeps a literal \%` in a filename encoded in the served id (/50%25.png): expect id /50%.png`, and rename accordingly.
Add: an assertion that /a/..%252fb-style crafted inputs stay inert (no ../ in the id) to lock in the ordering guarantee.
Notes / open questions
- This trades the "served id ==
event.url.pathname byte-for-byte" property (that cd85ebf protected) for ecosystem parity. Worth a changelog note: a filesystem-backed getContents no longer needs to decode %25 itself.
decodeURI keeps non-separator reserved escapes (%3f, %23) encoded, so h3 won't be a byte-for-byte match with sirv's decodeURIComponent for the (very rare) filename containing ?/#. Decide whether to match those too or accept the decodeURI scope (recommended: decodeURI, since decoding %2f is exactly what must be avoided).
Summary
serveStaticshould decode the resolved asset id for the on-disk lookup by default, matching the ecosystem convention used bysirvandserve-static. Today a file whose name contains a literal%is unreachable, and the served id representation diverges from every other Node static server.This is a followup to #1428 — deliberately not included there, since #1428's scope is exporting
resolveDotSegmentsand this reverses the direction of itsfix(static): don't decode the pathname a second timecommit (cd85ebf). Should land as its own PR after #1428 merges.Current behavior (after #1428)
serveStaticcomputes the asset id as:event.url.pathnameis decoded once by the event layer (decodePathname, adecodeURIthat preserves%25), so the id keeps%25encoded. Consequences:sirv/serve-static/50%25.png50%.png/50%25.png→ 40450%.png(200)/files/a%252fba%2fb/files/a%252fba%2fbThe
50%.pngcase is a regression vs pre-#1428 h3, which didresolveDotSegments(decodeURI(...))and served it. It was verified empirically against[email protected]and[email protected](both serve/50%25.png→50%.pngwith 200), and againstnginxfor the separator behavior.Proposed change
Decode the resolved id for the filesystem lookup after traversal resolution:
Why the ordering is safe (decode after resolve)
decodeURIdecodes%25→%but leaves reserved%2f/%5cliteral, so it can never introduce a new/boundary. Any real traversal (%2e%2e, nested%252e%252e, deeply re-encoded%25%32%66) is already collapsed and root-clamped byresolveDotSegmentsbefore the decode runs. Verified cases:decodeURI/50%25.png/50%25.png/50%.png/files/a%252fb/files/a%252fb/files/a%2fb%2ftext/a/..%252fb/a/..%252fb/a/..%2fb..//a/%252e%252e/b/b/b/a/..%25%32%66..%25%32%66secret/a/..%252f..%252fsecret/a/..%2f..%2fsecret/boundaryDecoding before resolve (the old pre-#1428 order) is the unsafe one — that's what let a double-encoded separator collapse into a real boundary — so the fix must keep the decode strictly after resolution.
Tests to update
Both currently pin the
%25-preserved behavior and would flip:test/static.test.ts→does not decode the pathname a second time(/files/a%252fb): expect ida%2fb.test/static.test.ts→keeps a literal \%` in a filename encoded in the served id(/50%25.png): expect id/50%.png`, and rename accordingly.Add: an assertion that
/a/..%252fb-style crafted inputs stay inert (no../in the id) to lock in the ordering guarantee.Notes / open questions
event.url.pathnamebyte-for-byte" property (thatcd85ebfprotected) for ecosystem parity. Worth a changelog note: a filesystem-backedgetContentsno longer needs to decode%25itself.decodeURIkeeps non-separator reserved escapes (%3f,%23) encoded, so h3 won't be a byte-for-byte match withsirv'sdecodeURIComponentfor the (very rare) filename containing?/#. Decide whether to match those too or accept thedecodeURIscope (recommended:decodeURI, since decoding%2fis exactly what must be avoided).