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
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 }).
- 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
Summary
The
H3Eventconstructor normalizes percent-encoded pathnames in place (introduced in 6f6dc09 to prevent middleware bypass). On web-standard targets this only affectsevent.url— the originalRequest.urlstring is immutable and keeps the wire encoding. On Node (srvx), however, the normalization mutates the shared, runtime-providedreq._urlobject, and srvx'sNodeRequestURLpathname 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
%2Fvs/distinctly, proxies forwarding the exact wire path, audit logging).Observed behavior for
GET /h%65llo?q=%41event.url.pathname/hello/helloevent.req.urlhttp://…/h%65llo?q=%41(original)http://…/hello?q=%41(decoded)node.req.url(event.runtime.node.req.url)/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
src/event.tsreuses the runtime-provided parsed URL and mutates it in place:_url, so a freshFastURLis created andRequest.urlstays untouched. On Node,_urlis srvx's cachedNodeRequestURL, shared withreq.url(get url() { return this._url.href }).NodeRequestURLpathname setter additionally syncs the raw Node request:this.#req.url = this._url.pathname + this._url.search, so evenevent.runtime.node.req.urlis side-effected.Note: the decode itself is idempotent (
decodePathnamepreserves%25; covered intest/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.urlshould be the normalized URL on every runtime, whileevent.req.url(and the rawnode.req.url) should keep the original wire encoding on every runtime — matching the web semantics whereRequest.urlis immutable.Possible fixes
_url; when normalization is needed, clone into a freshFastURL(e.g.new FastURL(decodedHref)) and keepreq._urlpristine. Costs one extra URL object only for requests whose pathname contains%.#req.urlin theNodeRequestURLpathname setter, so at least the raw Node request stays untouched. This alone doesn't fixevent.req.url, so the h3-side clone is likely needed either way.Related
requestWithURLleaking the stale_urlthrough its proxy (same underlying sharing)