Skip to content

Percent-decode normalization side-effects the original request URL on Node (runtime divergence) #1432

Description

@pi0x

Summary

The H3Event constructor normalizes percent-encoded pathnames in place (introduced in 6f6dc09 to prevent middleware bypass). On web-standard targets this only affects event.url — the original Request.url string is immutable and keeps the wire encoding. On Node (srvx), however, the normalization mutates the shared, runtime-provided req._url object, and srvx's NodeRequestURL pathname setter writes the mutation back into the raw Node request. The result is a runtime divergence, and on the Node path there is no viable way left to read the original undecoded URL.

This matters for consumers that need the raw incoming URL to apply their own decoding semantics (e.g. treating %2F vs / distinctly, proxies forwarding the exact wire path, audit logging).

Observed behavior for GET /h%65llo?q=%41

Accessor web target node target (srvx)
event.url.pathname /hello /hello
event.req.url http://…/h%65llo?q=%41 (original) http://…/hello?q=%41 (decoded)
raw node.req.url (event.runtime.node.req.url) n/a /hello?q=%41 (decoded, write-back)

Current behavior is pinned by test/event.test.ts → "normalizes percent-encoded pathname and syncs the raw node url" (added in 4a218a8 / 8410ec9) — that test documents the divergence and should be updated alongside the fix.

Root cause

  1. src/event.ts reuses the runtime-provided parsed URL and mutates it in place:
    const _url = (req as { _url?: URL })._url;
    const url = _url && _url instanceof URL ? _url : new FastURL(req.url);
    if (url.pathname.includes("%")) {
      url.pathname = decodePathname(url.pathname); // mutates shared object on node
    }
    On web there is no _url, so a fresh FastURL is created and Request.url stays untouched. On Node, _url is srvx's cached NodeRequestURL, shared with req.url (get url() { return this._url.href }).
  2. srvx's NodeRequestURL pathname setter additionally syncs the raw Node request: this.#req.url = this._url.pathname + this._url.search, so even event.runtime.node.req.url is side-effected.

Note: the decode itself is idempotent (decodePathname preserves %25; covered in test/unit/event.test.ts), so this is purely an observability/consistency problem, not a double-decode one.

Expected

Node and other targets should behave identically. event.url should be the normalized URL on every runtime, while event.req.url (and the raw node.req.url) should keep the original wire encoding on every runtime — matching the web semantics where Request.url is immutable.

Possible fixes

  • In h3: never mutate the runtime-provided _url; when normalization is needed, clone into a fresh FastURL (e.g. new FastURL(decodedHref)) and keep req._url pristine. Costs one extra URL object only for requests whose pathname contains %.
  • In srvx: drop (or gate) the write-back to the raw #req.url in the NodeRequestURL pathname setter, so at least the raw Node request stays untouched. This alone doesn't fix event.req.url, so the h3-side clone is likely needed either way.

Related

  • 6f6dc09 introduced the in-place normalization
  • d21d93c fixed requestWithURL leaking the stale _url through its proxy (same underlying sharing)
  • 4a218a8 / 8410ec9 added tests pinning the current (divergent) behavior

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