You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
workerd UTF-8 decodes incoming HTTP header values before exposing them to JavaScript. Per the Fetch Standard a header value is a byte sequence exposed through the WebIDL ByteString type, so each received byte must surface as exactly one code unit in the range U+0000–U+00FF.
Two consequences:
The two bytes c3 a9 surface as the single code unit U+00E9 instead of U+00C3 U+00A9.
A byte that is not valid UTF-8 is replaced with U+FFFD. The original octet is destroyed and cannot be recovered from JavaScript.
Node.js, Deno, Bun, Chromium, Firefox, and WebKit all expose the received bytes as the specification requires. workerd is the only runtime tested that does not.
Same two inputs, measured. For browsers the analogous path is a response header, read with fetch().headers.get() from a same-origin server that writes the raw octets.
Fetch: the Headers class: get() returns ByteString?, and HeadersInit is defined over ByteString.
WebIDL: ByteString: converting a byte sequence to a JavaScript string maps each byte to the code unit of the same value (isomorphic decode).
No part of that chain applies a UTF-8 decode, and none of it permits substituting U+FFFD.
Why it matters
Header values are not required to be UTF-8. RFC 9110 recommends ASCII but explicitly allows other octets, and existing deployments do carry them (legacy ISO-8859-1 values, opaque tokens, binary values that some proxies pass through).
The U+FFFD substitution is the serious half: it is lossy and irreversible, so a Worker cannot recover the received bytes even by re-encoding. That breaks:
byte-exact protocols. e.g. RFC 9421 HTTP Message Signatures builds a signature base from received field values, so a signature produced or verified on workerd over a non-ASCII field disagrees with every other runtime, and the original octets needed to verify it are unrecoverable.
proxying / pass-through Workers, which cannot faithfully forward a header they received.
The first consequence (c3 a9 → U+00E9) is at least deterministic and reversible by re-encoding; the U+FFFD one is not recoverable at all.
Went through the issue tracker here and:
Behavior is unchanged with the pedantic_wpt compatibility flag enabled.
Related but distinct: If pedantic_wpt is set, reject invalid header names instead of passing them through. #4792 covers the write side, where Headers accepts code points above U+00FF instead of throwing TypeError. This report is about the receive side, which that issue does not cover and which pedantic_wpt does not address. (For the record, pedantic_wpt does not yet make the write side throw either: new Headers({ x: '☃' }) still stores U+2603.)
workerdUTF-8 decodes incoming HTTP header values before exposing them to JavaScript. Per the Fetch Standard a header value is a byte sequence exposed through the WebIDLByteStringtype, so each received byte must surface as exactly one code unit in the rangeU+0000–U+00FF.Two consequences:
c3 a9surface as the single code unitU+00E9instead ofU+00C3 U+00A9.U+FFFD. The original octet is destroyed and cannot be recovered from JavaScript.Node.js, Deno, Bun, Chromium, Firefox, and WebKit all expose the received bytes as the specification requires.
workerdis the only runtime tested that does not.Reproduction
worker.js:config.capnp:Expected vs actual
ByteString)workerdc3 a9U+00C3 U+00A9(length 2)U+00E9(length 1)e9(not valid UTF-8)U+00E9(length 1)U+FFFDbyte lostOther runtimes
Same two inputs, measured. For browsers the analogous path is a response header, read with
fetch().headers.get()from a same-origin server that writes the raw octets.c3 a9e9U+00C3 U+00A9✅U+00E9✅U+00C3 U+00A9✅U+00E9✅U+00C3 U+00A9✅U+00E9✅U+00C3 U+00A9✅U+00E9✅U+00E9❌U+FFFD❌Specification
Headersclass:get()returnsByteString?, andHeadersInitis defined overByteString.ByteString: converting a byte sequence to a JavaScript string maps each byte to the code unit of the same value (isomorphic decode).No part of that chain applies a UTF-8 decode, and none of it permits substituting
U+FFFD.Why it matters
Header values are not required to be UTF-8. RFC 9110 recommends ASCII but explicitly allows other octets, and existing deployments do carry them (legacy ISO-8859-1 values, opaque tokens, binary values that some proxies pass through).
The
U+FFFDsubstitution is the serious half: it is lossy and irreversible, so a Worker cannot recover the received bytes even by re-encoding. That breaks:workerdover a non-ASCII field disagrees with every other runtime, and the original octets needed to verify it are unrecoverable.The first consequence (
c3 a9→U+00E9) is at least deterministic and reversible by re-encoding; theU+FFFDone is not recoverable at all.Went through the issue tracker here and:
pedantic_wptcompatibility flag enabled.new Headers({ x: 'é' })round-trips asU+00C3 U+00A9. The problem is confined to the receive path, which suggests it is in the HTTP-to-JavaScript boundary rather than inHeadersitself.Headersaccepts code points aboveU+00FFinstead of throwingTypeError. This report is about the receive side, which that issue does not cover and whichpedantic_wptdoes not address. (For the record,pedantic_wptdoes not yet make the write side throw either:new Headers({ x: '☃' })still storesU+2603.)Version
cc @jasnell