Skip to content

serveStatic: decode the resolved asset id for on-disk lookup by default (match sirv/serve-static) #1429

Description

@pi0x

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.png404 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.png50%.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.tsdoes not decode the pathname a second time (/files/a%252fb): expect id a%2fb.
  • test/static.test.tskeeps 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions