cloudflare / cloudflare/workerd
🐛 Received header values are UTF-8 decoded instead of exposed as byte strings, losing bytes that are not valid UTF-8
- Dominant language
- C++
- Stars
- 8.7k
- Forks
- 739
- Avg merge
- 2d 20h
- Merged PRs (30d)
- 174
Description
`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:
1. The two bytes `c3 a9` surface as the single code unit `U+00E9` instead of `U+00C3 U+00A9`.
2. 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.
## Reproduction
`worker.js`:
```js
const codeUnits = (v) => [...v].map((c) => c.codePointAt(0).toString(16).padStart(4, '0'))
export default {
async fetch(request) {
return Response.json({ received: codeUnits(request.headers.get('x-test') ?? '') })
},
}
```
`config.capnp`:
```capnp
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [ (name = "main", worker = .w) ],
sockets = [ (name = "http", address = "127.0.0.1:8080", http = (), service = "main") ],
);
const w :Workerd.Worker = (
modules = [ (name = "worker", esModule = embed "worker.js") ],
compatibilityDate = "2026-08-04",
);
```
```console
$ workerd serve config.capnp &
$ curl -s -H $'X-Test: \xc3\xa9' localhost:8080
{"received":["00e9"]}
$ curl -s -H $'X-Test: \xe9' localhost:8080
{"received":["fffd"]}
```
## Expected vs actual
| header value bytes on the wire | expected (Fetch / WebIDL `ByteString`) | actual in `workerd` |
| --- | --- | --- |
| `c3 a9` | `U+00C3 U+00A9` (length 2) | `U+00E9` (length 1) |
| `e9` (not valid UTF-8) | `U+00E9` (length 1) | `U+FFFD` byte lost |
## Other 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.
| runtime | `c3 a9` | `e9` |
| --- | --- | --- |
| Node.js 24 | `U+00C3 U+00A9` ✅ | `U+00E9` ✅ |
| Deno | `U+00C3 U+00A9` ✅ | `U+00E9` ✅ |
| Bun | `U+00C3 U+00A9` ✅ | `U+00E9` ✅ |
| Chromium / Firefox / WebKit | `U+00C3 U+00A9` ✅ | `U+00E9` ✅ |
| **workerd** | `U+00E9` ❌ | `U+FFFD` ❌ |
## Specification
- [Fetch: header value](https://fetch.spec.whatwg.org/#concept-header-value): a header value is a **byte sequence**.
- [Fetch: the `Headers` class](https://fetch.spec.whatwg.org/#headers-class): `get()` returns `ByteString?`, and `HeadersInit` is defined over `ByteString`.
- [WebIDL: `ByteString`](https://webidl.spec.whatwg.org/#idl-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](https://www.rfc-editor.org/info/rfc9421/) 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.
- Values *set from script* are handled correctly: `new Headers({ x: 'é' })` round-trips as `U+00C3 U+00A9`. The problem is confined to the receive path, which suggests it is in the HTTP-to-JavaScript boundary rather than in `Headers` itself.
- Related but distinct: #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`.)
## Version
```
workerd 2026-08-04 (npm workerd@1.20260804.1), darwin/arm64
compatibilityDate = "2026-08-04"
```
cc @jasnell @thibmeu
Contributor guide
Research direction
Start with the HTTP-to-JavaScript receive path and the Headers/ByteString behavior described in the issue; script-set values already work, so compare that path with the receive path. Reproduce the c3 a9 and e9 cases using worker.js and config.capnp, then run the relevant tests or add coverage showing that each received byte is preserved and U+FFFD is not substituted.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, javascript
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100