Skip to content

node:http server: keep req.rawHeaders complete under maxHeadersCount for <32 fields - #35015

Closed
robobun wants to merge 3 commits into
mainfrom
farm/624e398c/http-maxheaderscount-rawheaders
Closed

robobun wants to merge 3 commits into
mainfrom
farm/624e398c/http-maxheaderscount-rawheaders

Conversation

@robobun

@robobun robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

With server.maxHeadersCount set, Bun's node:http server was truncating req.rawHeaders to maxHeadersCount * 2 entries. Node only clamps the count fed to _addHeaderLines (which builds req.headers), so req.rawHeaders stays complete for requests with fewer than 32 header fields; proxies, request loggers, and header-signature verifiers that read req.rawHeaders were silently losing fields.

const s = http.createServer((rq, rs) => {
  out = { headerKeys: Object.keys(rq.headers).length, rawLen: rq.rawHeaders.length };
  rs.end("ok");
});
s.maxHeadersCount = 6;
// send 12 header fields (Host, Connection, X-H0..X-H9)
// node:  { headerKeys: 6, rawLen: 24 }   (all 12 pairs in rawHeaders)
// bun:   { headerKeys: 6, rawLen: 12 }   (rawHeaders truncated too)

Cause

IncomingMessage.prototype.rawHeaders (the native server's lazy materialization path in _http_incoming.ts) sliced the raw list to maxHeadersCount * 2 before storing it, instead of storing the full list and only clamping kHeadersCount.

Node's actual behavior has a split: the llhttp binding flushes headers to parserOnHeaders in 32-pair batches (kMaxHeaderFieldsCount in node_http_parser.cc), and parserOnHeaders clamps the accumulated list. Fewer than 32 pairs arrive in a single parserOnHeadersComplete call whose headers argument is the full list and bypasses parserOnHeaders, so only the processed count is clamped. Verified against Node v26.3.0:

sent maxHeadersCount req.headers keys req.rawHeaders.length
12 6 6 24 (full)
31 6 6 62 (full)
32 6 6 12 (clamped)
66 50 50 100 (clamped)

Fix

In the rawHeaders getter, store the full raw list and clamp kHeadersCount to maxHeadersCount * 2. Slice the raw list itself only when it carries 32 or more pairs, reproducing Node's split. req.headers and req.headersDistinct iterate to kHeadersCount so they remain clamped. The llhttp-based client path (_http_common.ts) already matches Node and is unchanged.

How did you verify your code works?

New server.maxHeadersCount cases in test/js/node/http/node-http.test.ts assert the exact (headers, rawHeaders.length) pairs observed in Node v26.3.0 across the 32-field boundary and for both access orders. Vendored test-http-rawheaders-limit.js and test-http-max-headers-count.js continue to pass.


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

fails on main (without fix)
ASAN without fix: 4 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/http/node-http.test.ts
bun test v1.4.0 (e52e102d3)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [650.89ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [126.19ms]
(pass) node:http > createServer > request & response body streaming (large) [203.02ms]
(pass) node:http > createServer > request & response body streaming (small) [124.74ms]
(pass) node:http > createServer > listen should return server [30.57ms]
(pass) node:http > createServer > listen callback should be bound to server [39.97ms]
(pass) node:http > createServer > should use the provided port [69.51ms]
(pass) node:http > createServer > should assign a random port when undefined [45.96ms]
(pass) node:http > createServer > option method should be uppercase (#7250) [103.37ms]
(pass) node:http > response > set-cookie works with getHeader [8.05ms]
(pass) node:http > response > set-cookie works with getHeaders [10.94ms]
(pass) node:http > request > should not insert extraneous accept-encoding header [137.59ms]

... (truncated)

release without fix: 1 skipped
bun test v1.4.0-canary.1 (e52e102d3)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [14.63ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [3.98ms]
(pass) node:http > createServer > request & response body streaming (large) [6.75ms]
(pass) node:http > createServer > request & response body streaming (small) [4.80ms]
(pass) node:http > createServer > listen should return server [1.06ms]
(pass) node:http > createServer > listen callback should be bound to server [2.15ms]
(pass) node:http > createServer > should use the provided port [1.61ms]
(pass) node:http > createServer > should assign a random port when undefined [1.42ms]
(pass) node:http > createServer > option method should be uppercase (#7250) [2.91ms]
(pass) node:http > response > set-cookie works with getHeader [0.10ms]
(pass) node:http > response > set-cookie works with getHeaders [0.14ms]
(pass) node:http > request > should not insert extraneous accept-encoding header [2.89ms]
(pass) node:http > request > multiple Set-Cookie headers works #6810 [16.28ms]
(pass) node:http > request > should make a standard GET request when passed string as first arg [
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/http/node-http.test.ts
bun test v1.4.0 (e52e102d3)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [427.33ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [79.00ms]
(pass) node:http > createServer > request & response body streaming (large) [155.84ms]
(pass) node:http > createServer > request & response body streaming (small) [89.44ms]
(pass) node:http > createServer > listen should return server [19.82ms]
(pass) node:http > createServer > listen callback should be bound to server [25.48ms]
(pass) node:http > createServer > should use the provided port [56.63ms]
(pass) node:http > createServer > should assign a random port when undefined [28.83ms]
(pass) node:http > createServer > option method should be uppercase (#7250) [63.99ms]
(pass) node:http > response > set-cookie works with getHeader [5.10ms]
(pass) node:http > response > set-cookie works with getHeaders [6.70ms]
(pass) node:http > request > should not insert extraneous accept-encoding header [100.76ms]
(pas
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 723ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/22] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[2/22] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[3/22] gen JS modules (bundle-modules)
Preprocess modules (7674ms)
Bundle modules (29ms)
Postprocesss modules (161ms)
Bundle Functions (765ms)
Generate Code (93ms)

[8.74s] Bundled "src/js" for production
  2041 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[3/7] 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_output_tags v0.0.0 (/workspace/bun/src/bun_output_tags)
�[1m�[92m   Compiling�[0m bun_windows_sys v0.0.0 (/workspace/bun/src/windows_sys)
�[1m�[92m   Compiling�[0m bun_mimalloc_sys v0.0.0 (/workspace/bun/src/mimalloc_sys)
�[1m�[92m   Compiling�[0m bun_highway v0.0.0 (/workspace/bun/
... (truncated)
diff hotspot
src/js/node/_http_incoming.ts        | 17 ++++++----
 test/js/node/http/node-http-proxy.js |  4 +--
 test/js/node/http/node-http.test.ts  | 66 ++++++++++++++++++++++++++++++++++++
 3 files changed, 79 insertions(+), 8 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                  reads  edits  tests
src/js/node/_http_incoming.ts             4      2      0
test/js/node/http/node-http-proxy.js      1      1      0
test/js/node/http/node-http.test.ts       4      2      0

…for <32 fields

Node's parserOnHeadersComplete passes the full raw header list to
_addHeaderLines with a separately clamped count, so req.rawHeaders stays
complete while req.headers is clamped to server.maxHeadersCount. Only
once the llhttp binding's 32-pair flush batch routes fields through
parserOnHeaders does the raw list itself get clamped.

The native server's lazy rawHeaders getter was slicing the raw list
unconditionally, so proxies/loggers/signature verifiers that read
req.rawHeaders lost fields that Node would have delivered. Clamp only
the processed count for <32 fields and slice the raw list at >=32
fields, matching Node v26.3.0 exactly.
@robobun

robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: diff is green; ready for review.

Repro: bun bd test test/js/node/http/node-http.test.ts -t "server.maxHeadersCount" (fails on main with rawLen: 12 instead of 24/62; passes with this branch).

CI (#77269): the new server.maxHeadersCount cases and the rest of node-http.test.ts pass on every lane. The one red lane is test/js/bun/http/bun-server.test.ts (websocket idle-CPU threshold) on macOS 26 aarch64, which is failing on main and has been handed to main-break triage; the remaining entries are retried flakes (webview animation, git-SSH install, FileHandle GC, repl EPIPE, no-orphans, s3-list-objects), none touching node:http header parsing.

@robobun

robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:49 PM PT - Jul 21st, 2026

❌ @robobun, your commit e52e102 has 1 failures in Build #77269 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35015

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

bun-35015 --bun

@github-actions

Copy link
Copy Markdown
Contributor

No related open issues found for this PR.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

IncomingMessage now clamps parsed header views independently from cached rawHeaders storage for smaller overflows, while larger overflows still truncate the cached array. Tests cover header limits and materialization order; proxy tests use explicit IPv4 loopback routing.

Changes

HTTP header clamping

Layer / File(s) Summary
Header materialization and validation
src/js/node/_http_incoming.ts, test/js/node/http/node-http.test.ts
rawHeaders separates stored-list length from the clamped header-pair count, with tests covering headers, rawHeaders, headersDistinct, and materialization order.

Proxy test loopback routing

Layer / File(s) Summary
IPv4 loopback test routing
test/js/node/http/node-http-proxy.js
The proxy server and downstream request use 127.0.0.1 instead of localhost.

Possibly related PRs

  • oven-sh/bun#34049: Updates proxy test networking from localhost to 127.0.0.1 in a related test file.

Suggested reviewers: cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: preserving complete req.rawHeaders below the 32-field boundary under maxHeadersCount.
Description check ✅ Passed The description includes the required sections and provides a clear implementation rationale plus verification details.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/node/_http_incoming.ts`:
- Around line 221-228: Reduce the comment immediately before the _addHeaderLines
call to exactly three lines, preserving only the invariant that raw headers are
flushed in 32-pair batches and fewer than 32 pairs bypass parserOnHeaders.
Remove the surrounding explanations about parser limits, native delivery, and
proxy/logger behavior.
- Around line 230-236: Update the maxHeadersCount handling near fakeSocketSymbol
so the value is truncated or rejected when non-integer before calculating
maxHeaderPairs. Preserve the existing positive-value guard and ensure doubling
matches Node’s integer-sized << 1 behavior, keeping rawHeaders and kHeadersCount
on an even boundary.

In `@test/js/node/http/node-http.test.ts`:
- Around line 1927-1935: Add a complementary rawHeaders-first test case
alongside the existing headers-first coverage, reading and capturing
req.rawHeaders before accessing req.headers; then assert that headers remains
clamped to the configured maxHeadersCount and headersDistinct reflects the
expected clamped view.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d81a50e6-63c2-4d70-a3fc-77cfec49619c

📥 Commits

Reviewing files that changed from the base of the PR and between 48362e5 and d6a010b.

📒 Files selected for processing (2)
  • src/js/node/_http_incoming.ts
  • test/js/node/http/node-http.test.ts

Comment thread src/js/node/_http_incoming.ts Outdated
Comment thread src/js/node/_http_incoming.ts
Comment thread test/js/node/http/node-http.test.ts

@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.

LGTM — focused Node compat fix with matrix coverage across the 32-field boundary.

What was reviewed:

  • rawHeaders getter now stores the full list and clamps only kHeadersCount; slicing at count >= 64 reproduces Node's llhttp 32-pair flush; << 1 matches Node's fractional coercion.
  • headers/headersDistinct still iterate to kHeadersCount, so parsed views remain clamped in both access orders (matrix is rawHeaders-first, targeted test is headers-first).
  • Client/llhttp path (_http_common.ts) already matches Node and is untouched; fresh server per test via beforeEach so maxHeadersCount doesn't leak between matrix cases.
Extended reasoning...

Overview

This PR changes ~10 lines in the rawHeaders lazy getter in src/js/node/_http_incoming.ts (native-server path only) so that req.rawHeaders is no longer truncated to maxHeadersCount * 2 when fewer than 32 header fields arrive. Instead, the full raw list is stored and only kHeadersCount (which drives req.headers/req.headersDistinct iteration) is clamped. When ≥32 pairs arrive, the raw list is sliced to maxHeaderPairs, reproducing Node's llhttp kMaxHeaderFieldsCount batch flush through parserOnHeaders. New test.each cases in node-http.test.ts pin the exact (headerKeys, rawHeaders.length) pairs observed in Node v26.3.0 across the 31/32 boundary and both access orders.

Security risks

None introduced. The change retains more data in rawHeaders (matching Node) rather than exposing anything new; req.headers clamping is unchanged. maxHeaderPairs > 0 preserves the "0/negative means unlimited" semantics from before.

Level of scrutiny

Low-to-medium. This is a narrow Node.js compat fix in a single accessor, driven by empirical comparison against Node v26.3.0. The << 1 coercion matches Node's parser.maxHeaderPairs = server.maxHeadersCount << 1 (and Bun's own client path at _http_client.ts:989), including fractional truncation — covered by the max: 3.7 matrix row. The headers/headersDistinct getters read this.rawHeaders then loop to this[kHeadersCount], so the headers-first order still sees the clamped count while rawHeaders returns the full stored list; both orders are tested.

Other factors

All three CodeRabbit comments (comment length, << 1 vs * 2, rawHeaders-first coverage) were addressed in 7cce553 and marked resolved. The client/llhttp path in _http_common.ts already implements the batched clamp and is untouched, so there's no sibling site left unfixed. Tests use the existing HTTP Server Security Tests - Advanced harness (fresh new Server() per test via beforeEach, sendRequest wires socket error to reject), so server.maxHeadersCount mutations don't leak across matrix rows. Vendored test-http-rawheaders-limit.js and test-http-max-headers-count.js both exist and per the PR continue to pass.

On hosts where localhost resolves to ::1 first, listen(0, "localhost")
binds IPv6-only while the client request lands on 127.0.0.1 and is
refused. exampleSite already pins 127.0.0.1; do the same here so the
fixture is deterministic regardless of the host's dual-stack ordering.

@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.

LGTM — focused Node compat fix with a verified test matrix.

What was reviewed:

  • The rawHeaders getter change: kHeadersCount is still clamped so req.headers/headersDistinct stay limited; only the raw list retention widens, and only below the 32-pair boundary — matches the Node v26.3.0 table in the description.
  • Checked the llhttp client path in _http_common.ts (parserOnHeadersComplete already passes the full list to _addHeaderLines with a clamped n) — correctly left unchanged.
  • Confirmed test-http-rawheaders-limit.js (66 headers, max=50) still holds under the new count >= 64 slice, and the new matrix covers both access orders plus the fractional maxHeadersCount coercion via << 1.
Extended reasoning...

Overview

This PR fixes IncomingMessage.prototype.rawHeaders on the native node:http server path so that req.rawHeaders is not truncated to server.maxHeadersCount * 2 for requests with fewer than 32 header fields, matching Node's actual behavior where only the count fed to _addHeaderLines is clamped in that case. The change is a ~13-line rewrite inside one accessor in src/js/node/_http_incoming.ts, plus a new describe block in node-http.test.ts and an unrelated localhost → 127.0.0.1 bind in the proxy fixture.

Security risks

None. The fix widens what rawHeaders retains (up to 31 pairs) while keeping req.headers and headersDistinct clamped to maxHeadersCount via the unchanged kHeadersCount iteration bound. The >= 32 pairs slice path is preserved, so the DoS-protection intent of maxHeadersCount is intact — verified against the vendored test-http-rawheaders-limit.js (66 headers → rawHeaders.length still ≤ 100). The << 1 coercion matches Node's own parser.maxHeaderPairs = server.maxHeadersCount << 1, so fractional/negative/zero inputs behave identically to before (the maxHeaderPairs > 0 guard preserves the old "0 means no limit" semantics).

Level of scrutiny

Medium — Node compat correctness in a hot server path, but the change is mechanical and well-localized. The 32-pair threshold is a magic number, but it's documented against kMaxHeaderFieldsCount in node_http_parser.cc and the PR encodes a test matrix of (sent, max) → (headerKeys, rawLen) pairs that were empirically verified against Node v26.3.0, which is exactly how the repo's Node-compat guidance says to derive behavior. The headers/headersDistinct getters iterate to kHeadersCount, so their clamping is unaffected. The client-side llhttp path in _http_common.ts already had the correct split (full headers list, clamped n to _addHeaderLines) and was correctly left alone.

Other factors

All three CodeRabbit findings were addressed in follow-up commits (comment tightened, << 1 coercion, rawHeaders-first ordering in the matrix). The test.each matrix reads rawHeaders first and the standalone test reads headers first, so both materialization orders are covered. The beforeEach creates a fresh Server per case so there's no listener leakage across the matrix. The node-http-proxy.js change is a benign test-fixture hardening (explicit IPv4 bind). No open reviewer threads remain.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #42532.

Node no longer truncates a request that is over server.maxHeadersCount. Since nodejs/node@821688aaa0 (CVE-2026-58044, released in v22.23.2, v24.18.1 and v26.5.1) the parser rejects it. The client gets 431 and the handler does not run. A clientError listener gets HPE_HEADER_OVERFLOW. The same commit removes test-http-rawheaders-limit.js. The table in this PR is the Node v26.3.0 result from before that fix, so a tuned truncation no longer matches Node.

What #42532 carries from this PR:

  • It removes the rawHeaders slice from src/js/node/_http_incoming.ts, so req.rawHeaders is never partial.
  • A request at or below the limit keeps every field in req.rawHeaders and in req.headers. maxHeadersCount = 0 still sets no limit.

I sent the requests of the test matrix in this PR (12 to 66 fields, both access orders, maxHeadersCount of 3.7 and 0) to a build of #42532, to Node v26.5.1 and to Node v26.8.2. The outputs are identical. test/js/node/http/node-http-maxHeadersCount.test.ts in #42532 covers these cases. The node-http-proxy.js change from this PR is already on main.

@robobun robobun closed this Sep 13, 2026
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.

2 participants