cloudflare / cloudflare/workerd

🐛 Bug Report — Runtime APIs: `encodeBody: "manual"` is silently dropped when a Response is rebuilt with an object init, double-encoding the body

Open
#7,066 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
8.7k
Forks
739
Avg merge
2d 20h
Merged PRs (30d)
174

Description

`encodeBody: "manual"` is write-only Response init with no getter, so a wrapper that
rebuilds a response cannot carry it forward and cannot tell that it lost it. The
`content-encoding` header stays visible on the rebuilt response, so the runtime
encodes the body a second time to match, and the response goes out double-encoded.

The surprise is that it depends on the SHAPE of the init. Passing the original
Response as init preserves the flag. Building a plain init object drops it, and
building an init object is what you do the moment you want to add a header.

### Versions

workerd `1.20260811.1` via wrangler `4.123.0` / miniflare `5.20260811.1-alpha`,
`compatibility_date = "2026-08-01"`, `wrangler dev --local`.

### Repro

```js
// 55 bytes of brotli that decode to 17600 bytes of "the quick brown fox ..."
const B64 = "G79EiJwJNk6odze8JjNjo9MgZ3h68g0trEHK5vLCBhw4JJC3Edyh0wpnHx4nSik4wjvxANQHUQ==";
const bytes = () => Uint8Array.from(atob(B64), (c) => c.charCodeAt(0));

const original = () =>
new Response(bytes(), {
headers: { "content-encoding": "br", "content-type": "text/plain" },
encodeBody: "manual",
});

export default {
async fetch(request) {
const p = new URL(request.url).pathname;
if (p === "/a") return original();
if (p === "/b") { const r = original(); return new Response(r.body, r); }
if (p === "/e") { const r = original(); return new Response(r.body, { status: r.status, headers: r.headers }); }
if (p === "/f") {
const r = original();
const h = new Headers(r.headers); h.set("x-added", "1");
return new Response(r.body, { status: r.status, headers: h });
}
if (p === "/g") {
const r = original();
const out = new Response(r.body, r);
out.headers.set("x-added", "1");
return out;
}
},
};
```

### Results

`layers` is how many times the wire bytes have to be brotli-decoded before the
plaintext appears.

| route | how the response is rebuilt | wire | layers |
|---|---|--:|--:|
| `/a` | not rebuilt | 55 B | 1 |
| `/b` | `new Response(r.body, r)` | 55 B | 1 |
| `/e` | `new Response(r.body, { status, headers })` | 59 B | **2** |
| `/f` | same, plus one added header | 59 B | **2** |
| `/g` | `new Response(r.body, r)`, then `out.headers.set(...)` | 55 B | 1 |

Every one of them sends `content-encoding: br`. So on `/e` and `/f`, a client that
decodes once, which is what the header instructs, gets compressed bytes:

```
/a: 17600 bytes, readable text = true first bytes: "the quick brown fox jump"
/e: 55 bytes, readable text = false first bytes: "\x1b\xbfD\t6N\xa8w7\xbc&3c\xa3\xd3 gxz\xf2\r-"
```

There is also no way to see the flag from JavaScript. On the response returned by
`original()`:

```json
{"encodeBody_own": false, "encodeBody_value": null, "in_prototype": false, "keys": []}
```

### Why this is worth a change

A wrapper that adds a header to every response is an ordinary thing to write, and
the object-init form is the obvious way to write it. That form silently converts a
correct precompressed response into a broken one. Nothing throws, the status is
200, the header is right, `content-length` is plausible, and the body is garbage
for any client that honours the encoding.

It is also hard to attribute. The byte counts move by single digits (55 to 59
here, and 13051 to 13047 on a real 47 KB asset, since brotli-of-brotli barely
grows), so the size tells you nothing. The visible symptom is a client failing to
decode a response whose headers look correct, which points at the client, the CDN,
or the compressor before it points at the wrapper.

### Suggested fix

**Expose `encodeBody` as a readable property on Response.** That is the smallest
change that makes this fixable in userland: a wrapper can read it and pass it on,
and a test can assert it survived. Right now the only way to be safe is to know
that the `/b` and `/g` shapes preserve it, which is not written down anywhere I
could find.

Preserving the flag through an object init that carries a `content-encoding`
header would also work, though it is a bigger behavioural change and I assume
there are cases that want the current semantics.

Failing both, documenting the asymmetry between `/b` and `/e` would at least make
it findable. Happy to send a docs PR for that if you would rather start there.

### Related

- #3669, where the option was implemented
- #6835 and #6289, other places `content-encoding` and auto-encoding interact

Contributor guide

Open the contributing guide

Research direction

Start at the Response runtime API implementation, using the JavaScript reproduction to compare the original-Response and object-init paths described in the issue. Trace where encodeBody is stored and copied, then run the /a, /b, /e, /f, and /g cases to verify the flag remains observable and the rebuilt response is not double-encoded.

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
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.