Skip to content

🐛 Received header values are UTF-8 decoded instead of exposed as byte strings, losing bytes that are not valid UTF-8 #6927

Description

@panva

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+0000U+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:

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:

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",
);
$ 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

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 a9U+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: 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.)

Version

workerd 2026-08-04 (npm workerd@1.20260804.1), darwin/arm64
compatibilityDate = "2026-08-04"

cc @jasnell

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