Skip to content

node:http: bound the header count with the engine's min, not the live Math.min - #40290

Open
robobun wants to merge 1 commit into
mainfrom
farm/71c0e64b/http-header-count-min
Open

robobun wants to merge 1 commit into
mainfrom
farm/71c0e64b/http-header-count-min

Conversation

@robobun

@robobun robobun commented Aug 24, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Since 1.4.0, a user patch of Math.min that changes its result makes every node:http response throw an uncaught TypeError: undefined is not an object (evaluating 'field') from matchKnownFields (_http_incoming.ts). Bun 1.3.14 and Node v26 are not affected.
  • parserOnHeadersComplete in src/js/node/_http_common.ts:125 clamps the header count with the live Math.min. With Math.min off by one, _addHeaderLines reads one pair past the end of the flat [name, value, ...] header array and passes undefined as a field name.

Fix

  • Clamp with $min, the @min link-time constant. It is the engine's own copy of Math.min and user code cannot reach it. This is the same construct internal/fs/streams.ts already uses.
  • Node does the equivalent with its MathMin primordial in lib/_http_common.js.
  • Verified: test/js/node/http/node-http-primoridals.test.ts (new test, fails on stock 1.4.0). Also node-http.test.ts, node-http-parser.test.ts, node-http-maxHeaderSize.test.ts, and the upstream test-http-max-headers-count.js / test-http-parser*.js.

Background

  • maxHeaderPairs is the parser-side cap that implements server.maxHeadersCount. It bounds how many header pairs the IncomingMessage materializes.
  • A $-prefixed name in src/js is a JSC private name. $min resolves at bytecode compile time to a function object the runtime created at global object init, not through a property lookup on Math.
  • The repro: load node:http, then set Math.min = (...a) => orig(...a) + 1 and run one http.get against a local server.

[stamp-90s] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ 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-primoridals.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/170] gen ErrorCode+*.h
[2/170] gen JSBuffer.lut.h
Generating /workspace/bun/build/debug/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[3/170] gen NodeModuleModule.lut.h
Generating /workspace/bun/build/debug/codegen/NodeModuleModule.lut.h from /workspace/bun/src/jsc/modules/NodeModuleModule.cpp
[4/170] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - Fr
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (4448a2e21)

test/js/node/http/node-http-primoridals.test.ts:
closing time
(pass) Overriding Request, Response, Headers, and Blob should not break node:http server [27.21ms]
164 |     ],
165 |     env: bunEnv,
166 |     stderr: "pipe",
167 |   });
168 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
169 |   expect(stdout).toBe("client: got 200 1\n");
                       ^
error: expect(received).toBe(expected)

- "client: got 200 1
+ "UNCAUGHT: undefined is not an object (evaluating 'field')
  "

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/node/http/node-http-primoridals.test.ts:169:18)
(fail) a patched Math.min does not reach IncomingMessage header parsing [43.58ms]

 1 pass
 1 fail
 2 expect() calls
Ran 2 tests across 1 file. [233.00ms]
__F:1: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/node/http/node-http-primoridals.test.ts
bun test v1.4.1 (4448a2e21)

test/js/node/http/node-http-primoridals.test.ts:
closing time
(pass) Overriding Request, Response, Headers, and Blob should not break node:http server [1512.30ms]
(pass) a patched Math.min does not reach IncomingMessage header parsing [2336.78ms]

 2 pass
 0 fail
 4 expect() calls
Ran 2 tests across 1 file. [5.85s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 643ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/130] gen ErrorCode+*.h
[2/130] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[3/130] gen NodeModuleModule.lut.h
Generating /workspace/bun/build/release/codegen/NodeModuleModule.lut.h from /workspace/bun/src/jsc/modules/NodeModuleModule.cpp
[4/130] gen generated_host_exports.rs
generated_host_exports.rs: 120 exports (host=5, lazy=10, generic=105, rust=0); 244 extern-C blocks audited
[5/130] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/run
... (truncated)
diff hotspot
src/js/node/_http_common.ts                     |  2 +-
 test/js/node/http/node-http-primoridals.test.ts | 42 +++++++++++++++++++++++++
 2 files changed, 43 insertions(+), 1 deletion(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                             reads  edits  tests
src/js/node/_http_common.ts                          1      1      0
test/js/node/http/node-http-primoridals.test.ts      1      1      0

… Math.min

parserOnHeadersComplete clamps the header count with Math.min, which user
code can replace. A replacement that returns a different value makes
_addHeaderLines read past the flat [name, value, ...] header array and
matchKnownFields then throws an uncaught TypeError for every response.

Use the $min intrinsic, which is the engine's own copy of Math.min.
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 28 days. After that, they cost $0.25 per reviewed file.

Or wait 6 minutes for your next included review.

View limit details

Limit details: You’ve used all 5 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ef1ef383-68c2-400f-8508-60b82641b179

📥 Commits

Reviewing files that changed from the base of the PR and between aeb1905 and 5b4f9e3.

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

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

@robobun

robobun commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on stock 1.4.0 with the standalone script from the report (load node:http, patch Math.min to return +1, one http.get against a local server): UNCAUGHT: undefined is not an object (evaluating 'field'). With this branch the client gets 200 and nothing is thrown. The new test in test/js/node/http/node-http-primoridals.test.ts fails on stock 1.4.0 and passes with the fix. Waiting for CI.

@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 — one-token swap to the $min intrinsic, matching the pattern already used across src/js/ and Node's own MathMin primordial at the same site.

What was reviewed

  • Confirmed $min is a declared JSC link-time constant (src/js/builtins.d.ts:542) with wide existing use in internal/fs/streams.ts, util/inspect.js, and native-readable.ts.
  • Test isolates the Math.min mutation in a subprocess, uses port: 0/bunEnv, drains all pipes concurrently, and wires uncaughtException so the pre-fix TypeError produces a nonzero exit — it fails for the right reason on the unfixed build.
  • Checked parserOnHeaders (the sibling slow-path) — it clamps via subtraction/comparison, not Math.min, so no parallel site to fix.
Extended reasoning...

Overview

Single-character-class change in src/js/node/_http_common.ts: Math.min(n, maxHeaderPairs) → $min(n, maxHeaderPairs). $min is a JSC private-name intrinsic bound at bytecode compile time to the engine's own Math.min, so a userland reassignment of Math.min can no longer skew the header-pair clamp and push _addHeaderLines past the end of the flat header array. The accompanying test lives in the existing node-http-primoridals.test.ts, which is already the file for node:http tamper-resistance coverage.

Security risks

None introduced. The change removes a userland-influenceable call from a built-in module hot path, which is the direction the repo's tamper-resistance guidance requires for src/js/. No new inputs, no new surface.

Level of scrutiny

Low. This is the canonical primordial-safety pattern ($-prefixed intrinsic replacing a live global lookup). $min is declared in builtins.d.ts and already used at a dozen-plus sites (internal/util/inspect.js, internal/fs/streams.ts, internal/streams/native-readable.ts). Node's upstream lib/_http_common.js uses MathMin at the identical line, so this also tightens Node compat.

Other factors

  • The test spawns a subprocess so the Math.min monkey-patch cannot leak into other tests, uses await using for the process handle, drains stdout/stderr/exited with Promise.all, and asserts stdout before exitCode — all per harness conventions.
  • The uncaughtException handler in the fixture prints the message and exits 1, so the pre-fix behavior (uncaught TypeError: undefined is not an object from matchKnownFields) yields a distinct, non-timeout failure.
  • I checked the neighboring parserOnHeaders slow path in the same file for the same bug class; it bounds via capacity >= headers.length arithmetic rather than Math.min, so there is no sibling site left unfixed.
  • No prior reviewer comments to address; timeline contains only a CodeRabbit rate-limit notice.

@robobun

robobun commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:28 PM PT - Aug 23rd, 2026

✅ @robobun, your commit 5b4f9e3c05d5ae557760d2b35a0dc86757f821ed passed in Build #104645! 🎉


🧪   To try this PR locally:

bunx bun-pr 40290

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

bun-40290 --bun

This branch has not been deployed

No deployments
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