Skip to content

fetch: accept HTTP/1.1 responses with more than 256 header fields - #34923

Open
robobun wants to merge 3 commits into
mainfrom
claude/farm/26801824/fetch-response-header-count-cap
Open

fetch: accept HTTP/1.1 responses with more than 256 header fields#34923
robobun wants to merge 3 commits into
mainfrom
claude/farm/26801824/fetch-response-header-count-cap

Conversation

@robobun

@robobun robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Repro

import net from "node:net";
const srv = net.createServer(s => {
  s.once("data", () => {
    let h = "HTTP/1.1 200 OK\r\n";
    for (let i = 0; i < 300; i++) h += `X-H-${i}: v${i}\r\n`;
    s.end(h + "Content-Length: 2\r\n\r\nok");
  });
});
srv.listen(0, "127.0.0.1", async () => {
  const res = await fetch(`http://127.0.0.1:${srv.address().port}/`);
  console.log(res.status, await res.text());
  srv.close();
});
bun:  error: Malformed_HTTP_Response
node: 200 ok

255 fields resolve, 256 and up are rejected as malformed even though the bytes are valid HTTP/1.1. A single 1 MB header value is accepted, so this is a field-count cap, not a byte guard. curl, Node/undici, and bun's own node:http.get all accept 1000+ fields. This hits real origins that send many Set-Cookie lines or per-item cache/rate-limit/debug headers.

Cause

picohttpparser returns -1 both for genuinely invalid input and when num_headers reaches max_headers before the terminating CRLF; picohttp::Response::parse_parts mapped every -1 to ParseResponseError::MalformedHttpResponse. The HTTP client passes a fixed [picohttp::Header; 256] scratch (SHARED_RESPONSE_HEADERS_BUF), so the 257th field fills it and the response is rejected with a misleading error.

Fix

  • parse_parts distinguishes the overflow case (on -1 picohttpparser writes num_headers == max_headers only when the array filled; every other -1 path leaves it strictly below) and returns a new ParseResponseError::TooManyHeaders.
  • handle_on_data_headers retries into an HTTP-thread scratch Vec<picohttp::Header> sized to the \n count of the received bytes (a strict upper bound on the field count). The existing 1 MB byte cap (MAX_RESPONSE_HEADER_BUFFER) stays as the only hard limit on the header block, which matches llhttp's byte-only bound in Node.
  • The WebSocket upgrade client's three match arms treat TooManyHeaders the same as MalformedHttpResponse (a 101 handshake with >128 fields is still rejected, just via the existing InvalidResponse code rather than a compile break).

The common case (≤256 fields) is unchanged: one parse into the fixed array, no allocation, no line-count scan.

Verification

New describe in test/js/bun/http/fetch-header-count-limit.test.ts covers 200/255/300/1000 response header fields plus a genuinely malformed response that must still reject with Malformed_HTTP_Response.

$ USE_SYSTEM_BUN=1 bun test test/js/bun/http/fetch-header-count-limit.test.ts
 5 pass
 3 fail   (255, 300, 1000: "Malformed_HTTP_Response")

$ bun bd test test/js/bun/http/fetch-header-count-limit.test.ts
 8 pass
 0 fail

fetch-redirect.test.ts, headers.test.ts, fetch_headers.test.js, and client-fetch.test.ts (151 tests) pass unchanged.


[review] gate passed · iteration 1 · 5 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/fetch-header-count-limit.test.ts
bun test v1.4.0 (6b2fb977d)

test/js/bun/http/fetch-header-count-limit.test.ts:
(pass) fetch with many headers does not crash [1516.95ms]
(pass) fetch with exactly 250 custom headers sends all of them [832.39ms]
(pass) default headers preserved when user headers overflow the buffer [917.63ms]
DEBUG: Malformed HTTP response:
HTTP/1.1 200 OK
X-H-0: v0
X-H-1: v1
X-H-2: v2
X-H-3: v3
X-H-4: v4
X-H-5: v5
X-H-6: v6
X-H-7: v7
X-H-8: v8
X-H-9: v9
X-H-10: v10
X-H-11: v11
X-H-12: v12
X-H-13: v13
X-H-14: v14
X-H-15: v15
X-H-16: v16
X-H-17: v17
X-H-18: v18
X-H-19: v19
X-H-20: v20
X-H-21: v21
X-H-22: v22
X-H-23: v23
X-H-24: v24
X-H-25: v25
X-H-26: v26
X-H-27: v27
X-H-28: v28
X-H-29: v29
X-H-30: v30
X-H-31: v31
X-H-32: v32
X-H-33: v33
X-H-34: v34
X-H-35: v35
X-H-36: v36
X-H-37: v37
X-H-38: v38
X-H-39: v39
X-H-40: v40
X-H-41: v41
X-H-42: v42
X-H-43: v43
X-H-44: v44
X-H-45: v45
X-H-46: v46
X-H-47: v47
X-H-48: v48
X-H-49: v49
X-H-50: v50
X-H-51: v51
X-
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (bc809505c)

test/js/bun/http/fetch-header-count-limit.test.ts:
(pass) fetch with many headers does not crash [19.40ms]
(pass) fetch with exactly 250 custom headers sends all of them [4.11ms]
(pass) default headers preserved when user headers overflow the buffer [2.71ms]
(pass) fetch accepts responses with more than 256 header fields > 200 header fields [2.88ms]
(pass) fetch accepts responses with more than 256 header fields > 255 header fields [3.69ms]
(pass) fetch accepts responses with more than 256 header fields > 300 header fields [3.64ms]
(pass) fetch accepts responses with more than 256 header fields > 1000 header fields [8.07ms]
(pass) fetch accepts responses with more than 256 header fields > newline-dense body in the same write as a >256-field header block [8.05ms]
(pass) fetch accepts responses with more than 256 header fields > genuinely malformed response still rejects with Malformed_HTTP_Response [7.78ms]

 9 pass
 0 fail
 32 expect() calls
Ran 9 tests across 1 file. [196.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/fetch-header-count-limit.test.ts
bun test v1.4.0 (6b2fb977d)

test/js/bun/http/fetch-header-count-limit.test.ts:
(pass) fetch with many headers does not crash [2203.48ms]
(pass) fetch with exactly 250 custom headers sends all of them [1244.16ms]
(pass) default headers preserved when user headers overflow the buffer [1334.03ms]
(pass) fetch accepts responses with more than 256 header fields > 200 header fields [7704.14ms]
(pass) fetch accepts responses with more than 256 header fields > 255 header fields [7697.93ms]
(pass) fetch accepts responses with more than 256 header fields > 300 header fields [7693.71ms]
(pass) fetch accepts responses with more than 256 header fields > 1000 header fields [7688.88ms]
(pass) fetch accepts responses with more than 256 header fields > newline-dense body in the same write as a >256-field header block [7684.75ms]
DEBUG: Malformed HTTP response:
HTTP/1.1 200 OK
bad header line


(pass) fetch accepts responses with more than 256 header fields > genuinely malformed response still reject
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 720ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compiling�[0m bun_clap v0.0.0 (/workspace/bun/src/clap)
�[1m�[92m   Compiling�[0m bun_valkey v0
... (truncated)
diff hotspot
src/http/InternalState.rs                          |  4 --
 src/http/lib.rs                                    | 75 ++++++++++++++--------
 .../websocket_client/WebSocketUpgradeClient.rs     | 15 ++++-
 src/picohttp/lib.rs                                | 13 ++++
 test/js/bun/http/fetch-header-count-limit.test.ts  | 70 +++++++++++++++++++-
 5 files changed, 144 insertions(+), 33 deletions(-)

gate history · 3 passed · 0 rejected · iteration 1

evidence per changed file
file                                                     reads  edits  tests
src/http/InternalState.rs                                    3      4      0
src/http/lib.rs                                             14     13      0
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs      1      3      0
src/picohttp/lib.rs                                          2      2      0
test/js/bun/http/fetch-header-count-limit.test.ts            2      6      0

picohttpparser returns -1 when the caller-supplied header array fills
before the terminating CRLF, which the Rust wrapper reported as
Malformed_HTTP_Response. The HTTP client passed a fixed 256-slot
scratch array, so any origin sending more than 256 Set-Cookie /
rate-limit / debug header lines was rejected as malformed even though
curl, Node/undici and bun's own node:http.get all accept it.

Distinguish the buffer-full case as ParseResponseError::TooManyHeaders
and, when it fires, reparse into an HTTP-thread scratch Vec sized to
the line count of the received bytes. The existing 1 MB byte cap on
the response header block remains the only hard limit, matching
llhttp's byte-only bound.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7f1740fb-0022-412d-a320-1f2e482c15fa

📥 Commits

Reviewing files that changed from the base of the PR and between e550f2c and 6b2fb97.

📒 Files selected for processing (5)
  • src/http/InternalState.rs
  • src/http/lib.rs
  • src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
  • src/picohttp/lib.rs
  • test/js/bun/http/fetch-header-count-limit.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff is green; waiting on a maintainer.

USE_SYSTEM_BUN=1 bun test test/js/bun/http/fetch-header-count-limit.test.ts  -> 255/300/1000 fields reject as Malformed_HTTP_Response
bun bd test test/js/bun/http/fetch-header-count-limit.test.ts                -> 9 pass

CI build 76842: 194/196 lanes passed; fetch-header-count-limit.test.ts and all fetch/http/compression suites pass on every lane. The one red lane is bun-server.test.ts "should not use 100% CPU when websocket is idle" on darwin-14-aarch64 (CPU samples 3-6%), which is the known threshold flake being fixed in #34576 and is unrelated to this change.

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:08 AM PT - Jul 21st, 2026

@robobun, your commit 6b2fb97 has 1 failures in Build #76842 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34923

That installs a local version of the PR into your bun-34923 executable, so you can run:

bun-34923 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Allow MAX_HEADERS to be configurable. #6982 - Requests configurable MAX_HEADERS limit; this PR raises the effective header count ceiling beyond the previous hard 256-slot cap
  2. NuxtJs - ERROR [unhandledRejection] { [Error: Malformed_HTTP_Response fetching "http://127.0.0.1:44537/_nuxt/?token=" #20484 - Nuxt fetch fails with Malformed_HTTP_Response, likely caused by responses exceeding the 256-header-field limit
  3. Nuxt DevTools connection fails with WebSocket/HMR errors under Bun runtime #18737 - Nuxt DevTools/HMR connections fail with Malformed_HTTP_Response, same root cause of too many response headers

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #6982
Fixes #20484
Fixes #18737

🤖 Generated with Claude Code

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

On the linked issues:

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the two inline nits: checked that every ParseResponseError match site handles the new TooManyHeaders variant (three WebSocket arms updated; http::Error::from has a _ => catch-all so a second TooManyHeaders on reparse surfaces as Response_Headers_Too_Large), and that amount_read stays 0 across the first-parse → reparse (the -1 branch in parse_parts doesn't touch *offset). Traced the overflow-Vec borrow through the 1xx continue and clone_metadata paths — no UAF, just the stale SAFETY comment noted inline.

Extended reasoning...

The two inline findings are nits (documentation accuracy on an unsafe block; bounded over-sizing of a process-lifetime scratch Vec). This note records the additional exhaustiveness/soundness checks so a human reviewer doesn't re-derive them: all four ParseResponseError consumers cover the new variant, the retry's last_len is correctly 0, and the lifetime-erased borrow into the growable overflow Vec is reset before any subsequent resize(). Not approving — the change adds unsafe lifetime erasure into a growable container on the HTTP client hot path, which per REVIEW.md's memory-safety section warrants a maintainer look.

Comment thread src/http/lib.rs
Comment thread src/http/lib.rs
…FETY

Count newlines only up to the first CRLF CRLF so a newline-dense body
delivered in the same read as a >256-field header block does not
inflate the overflow scratch. Update the detach_lifetime SAFETY comment
to name the overflow Vec and the actual invariant.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both earlier concerns (SAFETY comment, body-newline over-sizing) are addressed in bc80950. Beyond the inline nit, I also checked: a second TooManyHeaders from the reparse falls into Err(e) => close_and_fail(e.into()) and surfaces as Response_Headers_Too_Large (no unhandled variant); all four picohttp::Response::parse* call sites (http/lib.rs, three WebSocketUpgradeClient arms, error.rs From impl) handle the new variant; and the picohttpparser -1 && num_headers == max_headers ⇔ overflow invariant holds against the reference impl (the max_headers check runs before parsing each header, so every non-overflow -1 leaves num_headers strictly below capacity).

Extended reasoning...

Prior review's two findings were addressed in bc80950 and the threads are resolved. This run's bug-hunt surfaced one nit (content_encoding_i: u8 can now wrap on >255-field responses), which is dead state — grep confirms its only reader is the vestigial block at lib.rs:3874-3882 and actual decompression is driven by state.encoding, not the index. The note above records the additional checks done this pass so a human reviewer doesn't re-derive them; the change still touches unsafe lifetime-detached HTTP-thread scratch, so leaving the final call to a human.

Comment thread src/http/lib.rs
The field was a u8 header index whose only reader was a no-op block
that rewrote itself and re-stored pending_response with an unchanged
value. With responses now able to carry more than 255 header fields
the u8 narrowing would silently wrap, so delete the field, the flag,
the four writes, and the vestigial block instead of widening a value
nothing consumes. Decompression is driven by state.encoding, which is
unchanged.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three rounds of prior feedback are addressed as of 6b2fb97 (SAFETY comment names the overflow Vec and the reset-before-resize invariant; overflow slot count is bounded to the header block via index_of("\r\n\r\n"); the dead content_encoding_i/did_set_content_encoding state is fully removed — grep confirms no remaining references). I found nothing new this pass, but this touches the HTTP client's unsafe detach_lifetime path with borrows into a new growable process-lifetime static, so a human sign-off is warranted.

What was reviewed:

  • picohttpparser -1 overflow classification: num_headers == max_headers only on the header-array-full path; every other -1 (including status-line failures, which reset num_headers = 0 before parsing) leaves it strictly below. The !src.is_empty() guard covers the degenerate zero-slot case.
  • Reparse fallback: if the overflow reparse itself returns TooManyHeaders (shouldn't — newline count ≥ field count), it falls through to Err(e) => close_and_fail(e.into()) via the existing wildcard From<ParseResponseError>.
  • Loop invariant: pending_response = Some(Response::default()) at line 3730 runs before any overflow.resize() on the next iteration, so the erased borrow into the Vec's old buffer is never read after a realloc.
  • Dead-state removal: no remaining readers of content_encoding_i / did_set_content_encoding anywhere in src/.
Extended reasoning...

Overview

This PR lifts the fixed 256-field cap on HTTP/1.1 response headers in fetch(). It adds a ParseResponseError::TooManyHeaders variant so picohttp::Response::parse_parts can distinguish header-array overflow from a genuinely malformed response, then has handle_on_data_headers (src/http/lib.rs) reparse into a new HTTP-thread Vec<Header> static sized to the header-block line count. The WebSocket upgrade client's three parse sites map the new variant to the existing InvalidResponse code. A follow-on commit deletes the vestigial content_encoding_i: u8 / did_set_content_encoding state whose u8 value space this change would otherwise have overflowed.

Security risks

The new path is bounded: the overflow Vec is sized from newlines in buf[..index_of("\r\n\r\n")], and the header block itself is capped at 1 MB by the pre-existing MAX_RESPONSE_HEADER_BUFFER check on the ShortRead branch. Worst case a hostile server can induce is ~8 MB of retained scratch (≈250K four-byte header lines × 32 B/slot), which is bounded and requires the 256-slot fixed buffer to overflow first — not a DoS vector of note. No new untrusted-input parsing is introduced (same picohttpparser call, larger scratch).

Level of scrutiny

High. The change threads a borrowed &[Header] into a growable Vec's heap buffer through unsafe { response.detach_lifetime() } and stores it in self.state.pending_response across a loop that can resize() that Vec on the next iteration. I traced the invariant (line 3730 overwrites pending_response with Response::default() before any subsequent resize; clone_metadata() deep-copies before the fn returns; the 1xx-continue path nulls pending_response before continue) and it holds — but this is exactly the class of pointer-into-growable-container hazard REVIEW.md's memory-safety section flags, and the SAFETY comment now correctly documents it. A maintainer should confirm they're comfortable with the shape.

Other factors

  • All three prior review findings (stale SAFETY comment, body-newline over-sizing, u8 index wraparound) were addressed in bc80950 and 6b2fb97; each fix is verified in the current diff.
  • Test coverage is good: 200/255/300/1000-field responses with first/last-field spot-checks, an 8 KB LF-dense body in the same write, and a malformed-response negative case. The PR evidence shows the new tests fail on main (Malformed_HTTP_Response at 255+) and pass on the branch under both ASAN-debug and release.
  • The dead-state deletion in InternalState.rs and handle_response_metadata is complete (grep-clean) and decompression is unaffected (state.encoding drives it).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant