Skip to content

node:http2: let additionalHeaders() send a 1xx block on a HEAD request - #43449

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/9f9d5226/http2-additional-headers-head
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/9f9d5226/http2-additional-headers-head

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • ServerHttp2Stream#additionalHeaders() throws ERR_HTTP2_PAYLOAD_FORBIDDEN when the request method is HEAD. Node v26.3.0 sends the 1xx block.
  • With no 'checkContinue' listener, the compat API calls writeContinue() inside its own 'stream' handler (src/js/node/http2.ts:1247). One HEAD request with expect: 100-continue is then an uncaught exception in the server.
  • The cause is a payload check in additionalHeaders() (src/js/node/http2.ts:3480, from feat(node:http2) Implement HTTP2 server support #14286). The status is already known to be 1xx there, so only the this.headRequest arm can match.

Fix

Background

  • An informational block is a HEADERS frame with a 1xx :status (100, 103). A server sends it before the final response headers.
  • additionalHeaders() sends it. The compat methods writeContinue(), writeInformation() and writeEarlyHints() call additionalHeaders().
  • headRequest is true on a server stream whose request method is HEAD. That response has no body, so respond() ends the stream and the file responders throw ERR_HTTP2_PAYLOAD_FORBIDDEN.
Notes

Shortest repro. Start the server, then send one request with curl. Bun 1.4.3 prints error: Responses with 100 status must not have a payload and exits with code 1. Node v26.3.0 and this branch answer HTTP/2 100 and HTTP/2 200.

import http2 from "node:http2";
const server = http2.createServer((req, res) => res.end("ok"));
server.listen(0, "127.0.0.1", () => console.log(server.address().port));
curl --http2-prior-knowledge -I -H 'expect: 100-continue' http://127.0.0.1:$PORT/

The stack on bun 1.4.3 is additionalHeaders <- writeContinue <- onServerStream <- emit <- streamHeaders. No user code is on it, so a try/catch in the request handler does not help.

Core API repro. Node v26.3.0 prints HEAD additionalHeaders: sent and HEAD client got info headers: 103. Bun 1.4.3 prints HEAD additionalHeaders: ERR_HTTP2_PAYLOAD_FORBIDDEN and the client never gets the 103. With this change the output is identical to node.

import http2 from "node:http2";
const srv = http2.createServer();
srv.on("stream", (st, headers) => {
  let result = "sent";
  try { st.additionalHeaders({ ":status": 103, link: "</style.css>; rel=preload" }); } catch (e) { result = e.code; }
  console.log(headers[":method"], "additionalHeaders:", result);
  st.respond({ ":status": 200 });
  st.end();
});
srv.listen(0, "127.0.0.1", async () => {
  const c = http2.connect("http://127.0.0.1:" + srv.address().port);
  for (const method of ["GET", "HEAD"]) {
    await new Promise((resolve, reject) => {
      const r = c.request({ ":path": "/", ":method": method });
      r.on("headers", h => console.log(method, "client got info headers:", h[":status"]));
      r.on("response", h => console.log(method, "client got response:", h[":status"]));
      r.on("error", reject); r.on("close", resolve); r.resume(); r.end();
    });
  }
  c.close(); srv.close();
});

Other checks:


[human-review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 failed, 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.3 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [1364.10ms]
(pass) node none > Client Basics > should be able to send a POST request [849.18ms]
(pass) node none > Client Basics > constants [24.76ms]
(pass) node none > Client Basics > getDefaultSettings [11.59ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [22.04ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [7.50ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [6.90ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [10.47ms]
(pass) node none > Client Basics > should be able to send data using end [894.06ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [877.62ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receivin
... (truncated)

release without fix: 2 failed, 6 skipped
bun test v1.4.3-canary.1 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [2.17ms]
(pass) node none > Client Basics > getDefaultSettings [0.25ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.39ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.20ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [0.05ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [0.09ms]
(pass) node none > Client Basics > is possible to abort request [2.36ms]
(pass) node none > Client Basics > aborted event should work with abortController [1.17ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.92ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [1.05ms]
(pass) node none > Client Basics > should fail to connect over HTTP/1.1 [43.87ms]
(skip) node none > Client Basics > should not leak memory
(pass) node none > Client Basics > headers cannot be bigge
... (truncated)
passes on PR (with fix)
ASAN with fix: 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.3 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [1196.88ms]
(pass) node none > Client Basics > should be able to send a POST request [801.50ms]
(pass) node none > Client Basics > constants [21.67ms]
(pass) node none > Client Basics > getDefaultSettings [8.58ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [21.90ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [8.27ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [6.03ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [7.87ms]
(pass) node none > Client Basics > should be able to send data using end [792.67ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [793.95ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving 
... (truncated)

release with fix: 6 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     fc7082c220
  features     lto, baseline

23 deps, 131 codegen, 1176 objects in 2280ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1250] gen bindgenv2
[2/1250] gen ErrorCode+*.h
[3/1250] gen .bind.ts → GeneratedBindings.cpp
[4/1250] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[5/1250] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[6/1250] gen ProcessBindingBuffer.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingBuffer.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingBuffer.cpp
[7/1250] install /workspace/bun
bun install v1.4.3-canary.1 (367d939d9)

Checked 22 installs across 61 packages (no changes) [206.00ms]
[8/1250] gen ProcessBindingFs.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingFs.lut.h from /worksp
... (truncated)
diff hotspot
src/js/node/http2.ts                  | 10 ------
 test/js/node/http2/node-http2.test.js | 64 +++++++++++++++++++++++++++++++++++
 2 files changed, 64 insertions(+), 10 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                   reads  edits  tests
src/js/node/http2.ts                       5      1     18
test/js/node/http2/node-http2.test.js      5      3     18

additionalHeaders() threw ERR_HTTP2_PAYLOAD_FORBIDDEN when the request
method was HEAD. node throws that error from respondWithFD() and
respondWithFile() only. A 1xx block never carries a payload.

The compat API calls additionalHeaders() from writeContinue(),
writeInformation() and writeEarlyHints(). With no 'checkContinue'
listener it calls writeContinue() from inside its 'stream' handler, so a
HEAD request with expect: 100-continue was an uncaught exception in the
server.
@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on bun 1.4.3 against node v26.3.0:

  • On a HEAD request, stream.additionalHeaders({ ":status": 103, link: "</style.css>; rel=preload" }) throws ERR_HTTP2_PAYLOAD_FORBIDDEN under bun. Under node the call returns and the client gets a 'headers' event with status 103.
  • http2.createServer((req, res) => res.end("ok")) plus one curl --http2-prior-knowledge -I -H 'expect: 100-continue' request: bun prints error: Responses with 100 status must not have a payload and exits with code 1. Node answers HTTP/2 100 and HTTP/2 200.

The repro scripts are in the Notes block of the PR description.

Fail-before: USE_SYSTEM_BUN=1 bun test test/js/node/http2/node-http2.test.js -t "additionalHeaders\(\) sends a 1xx block on a HEAD request|compat server answers a HEAD request" gives 2 fail. With this branch, bun bd test on the same filter gives 2 pass. The debug build with src/ at main also gives 2 fail.

Not in this PR: #43447 (other :status differences from Node) and #43448 (respond() calls in the file responder error paths).

CI: build 118324 passed, 181 of 181 jobs.

@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: bafa5687-92a4-4cad-a42a-9081b809c7f0

📥 Commits

Reviewing files that changed from the base of the PR and between 26e7a4b and fc7082c.

📒 Files selected for processing (2)
  • src/js/node/http2.ts
  • test/js/node/http2/node-http2.test.js
💤 Files with no reviewable changes (1)
  • src/js/node/http2.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.


Walkthrough

The change removes payload-forbidden validation from HTTP/2 additionalHeaders() and adds regression tests for 103 Early Hints and 100-continue handling on HEAD requests.

Changes

HTTP/2 HEAD request handling

Layer / File(s) Summary
Informational headers and regression coverage
src/js/node/http2.ts, test/js/node/http2/node-http2.test.js
additionalHeaders() no longer rejects headers for 204, 205, 304, or HEAD payload restrictions. Tests verify 103 Early Hints before the final 200 response and successful 100-continue handling for HEAD requests.

Suggested reviewers: cirospaciari

Priority: ⬇️ Low

🚥 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 clearly and concisely describes the main change: allowing additionalHeaders() to send a 1xx block for HEAD requests.
Description check ✅ Passed The description explains the problem, fix, scope, and verification results. It does not use the exact template headings, but it provides the required information in equivalent sections.

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

@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 — a small, well-scoped deletion that brings additionalHeaders() in line with Node, with tests for both the core API and the compat writeContinue() path.

What was reviewed:

  • The removed block in src/js/node/http2.ts (additionalHeaders): the 204/205/304 arms were unreachable after the preceding 1xx range check, and Node's additionalHeaders() has no headRequest payload guard, so only the divergent arm was live.
  • Sibling ERR_HTTP2_PAYLOAD_FORBIDDEN guards in respondWithFD/respondWithFile (lines ~3340, ~3399) and the respond() check at ~3594 are untouched and still match Node; the status constants remain in use there.
  • Callers writeContinue/writeInformation/writeEarlyHints and the compat onServerStream path (line 1247) — the uncaught-exception scenario on HEAD + expect: 100-continue is now exercised by the second test; the client-side continue/headers emission (~5111) supports the asserted event order.
  • Candidate concerns about sending a 1xx HEADERS frame on a HALF_CLOSED_REMOTE or pushed HEAD stream were examined and ruled out as pre-existing behavior shared with GET streams, not introduced here.
Extended reasoning...

Overview

The PR removes a ten-line guard from ServerHttp2Stream.additionalHeaders() in /home/claude/bun/src/js/node/http2.ts. The guard sat inside the hasStatus branch after the code had already rejected any status outside 100–199, so the 204/205/304 comparisons could never match; the only live arm was this.headRequest, which threw ERR_HTTP2_PAYLOAD_FORBIDDEN for every 1xx block on a HEAD request. Node's additionalHeaders() performs the destroyed/headersSent/101/1xx-range checks and then sends the block; the payload-forbidden error is only raised from respondWithFD() and respondWithFile(). Those sibling guards (and the related one in respond()) are left intact in this diff, and the HTTP_STATUS_* constants they use remain referenced, so nothing becomes dead. Two tests were appended to /home/claude/bun/test/js/node/http2/node-http2.test.js: a core-API test asserting a 103 headers event followed by a 200 response on a HEAD request, and a compat-server test asserting continue then response 200 for a HEAD request carrying expect: 100-continue.

Security risks

None introduced. The change is in the direction of robustness: before, any peer could send a HEAD request with expect: 100-continue to a compat-API server with no checkContinue listener and trigger an uncaught exception from inside the internal 'stream' handler (onServerStream → response.writeContinue() → additionalHeaders()), with no user frame on the stack to catch it. Removing the throw closes that remote crash trigger. No input validation is weakened — the 101 and 1xx-range checks still run first.

Level of scrutiny

Low-to-moderate. This is a pure deletion in a node: compat module, with the reference implementation (Node's lib/internal/http2/core.js) as the spec, and the deleted code was demonstrably divergent from it. I checked the callers (writeContinue, writeInformation, writeEarlyHints, onServerStream) and the client-side header dispatch (continue emitted for status 100, headers emitted for 1xx before the response) to confirm the tests' asserted event sequences are what the runtime produces. The bug hunt exited on dry_streak with no findings; the four candidates it investigated (1xx frames on HALF_CLOSED_REMOTE or pushed HEAD streams, double 200 blocks when no :status is given) are all behaviors that already exist for GET streams on the base branch and are not caused by this diff. The PR description explicitly names the remaining :status divergences as tracked separately, which satisfies the "deliberate exclusion must be stated" convention.

Other factors

I was not able to execute the test file in this environment (no debug build present and test execution was not permitted), so my verification of the tests is static: they use port: 0, wire error on both client and request to reject, resolve on close, assert exact event arrays with toEqual, and clean up in finally. Per the PR description both tests fail on stock Bun, which is consistent with the code path — on the base branch the first test would record ["additionalHeaders threw", "ERR_HTTP2_PAYLOAD_FORBIDDEN"] and the second would surface an uncaught exception. The changed paths are not covered by .github/CODEOWNERS, and the timeline shows no outstanding third-party objections.

Jarred-Sumner added a commit that referenced this pull request Oct 21, 2026
…6270)

### What does this PR do?

Hardening follow-up to #46021. It fixes regressions from #46021 and
#44618 that were never released, includes more of the open pull requests
that fix a crash, and fixes a few crashes that had no pull request.

#### Regressions fixed (none was released)

- `spyOn(Bun, "$")`, and the same for `"sql"`, `"postgres"` and `"SQL"`,
threw "does not support accessor properties yet".
- Async `fs.read`, `fs.write` and `zlib.brotliCompress` on a view over a
`WebAssembly.Memory` could lose data or copy far more than they needed.
- `node:vm` with `vm.constants.DONT_CONTEXTIFY`: `this` in host
accessors, prototype handling, `util.types.isProxy`, `Bun.deepEquals`,
`assert.deepEqual` and `console.log` of a context.
- `zlib` `stream.flush()` threw for arguments it used to accept, and
streams had a new enumerable property.
- `Bun.Transpiler` `exports.replace` with the name `"default"`.
- `process.env.X = 3000` could store the number once the function was
JIT-compiled.
- `node:sqlite` and `bun:sqlite` could lose rows at exit.
- Dev server: stylesheets shared between pages after a build error.
- Shell `cp` of a file onto itself exited 1.
- `error.stack`: the frame of `require()` reads `at anonymous (unknown)`
again.
- `import * as process from "process"` followed by `process.on(...)`
threw at load. The EventEmitter methods that the module exports now act
on `process`.
- `delete res.statusCode; res.end()` in a `node:http` handler threw. It
sends 200, as in Node. (from #46128)
- `node:tls`, `node:https` and `node:http2`: connection teardown and
write callbacks behave as in 1.4.3 and Node again.

#### Crash fixes

Bundler, parser and CSS: #42286, #46092, #46097, #46177, #46239, #36334,
#43024, #40769.
`bun test`, `--hot` and `--watch`: #30776, #38206, #38974.
Shell: #43183, #37743, #46168, #46207, #34900.
`node:http`, `node:http2`, `Bun.serve`, streams: #43449, #45380, #44457,
#42197, #42485, #38082, #38124, #46074, #45999, #46045.
`node:vm`, native addons, workers: #46129, #46198, #45615, #33412,
#38031.
Dev server: #42790, #42850, #39488.
Other: #35991, #38030, #38586, #40526, #41830, #46029, #46078, #46114,
and the bug of #42215.

#### Behavior changes

- `crypto.randomFill(view, cb)` reports `ERR_CRYPTO_OPERATION_FAILED` if
it could not fill the view. It used to report success.
- A function registered twice on `process` runs twice, as in Node.
(#41830)
- Under `--hot`, a reload after an uncaught exception cancels the timers
that the failed run created. The new run's timers fire, and so do timers
and `fs.watchFile` watchers that earlier runs kept on `globalThis`. The
reload also resets the exit code to 0. (#38206)
- The parent of a Worker no longer gets an `'error'` event for a failed
`Bun.serve` request in that Worker. The error is printed, as on the main
thread.
- `hash.write()` after `digest()` drops the chunk, as in Node. (#35991)
- `http2` over TLS with `server.emit("connection", socket)`:
`session.socket` is a `TLSSocket` and a `'connection'` listener fires,
as in Node. (#38124)
- A `slice()` with no type of a body `Blob` has `type === ""`, as in
Node. (#46114)
- `util.types.isProxy(globalThis)` is false, as in Node.
- A write on a `node:net` or `node:tls` socket that is still pending
when the connection closes calls back with `ECANCELED`, as in Node.
- `HTMLRewriter.on()` throws for a selector that is nested too deeply.
- `Bun.Image`: `maxPixels` also bounds PNG metadata, `metadata()` of a
GIF reports the size that gets decoded, and a path with a NUL byte
throws. (#40526)
- `bun build --no-bundle` with the `base64` or `dataurl` loader prints
an error. (#36334)
- Bundled output for a default-exported function or class expression
changes to `var file_default = function name() {}`, as in esbuild.
`.name` is unchanged. (#46097)
- A Proxy in the prototype chain of a `node:vm` global is allowed. jsdom
30 with `runScripts` needs this.
- `bun:sqlite` commits a write that `iterate()` left open when the main
thread exits. An explicit `BEGIN` is still rolled back.
- When a Worker stops while a native addon's async work is still
running, the addon's finalizers run after that work is done, as in Node.
- Shell: `mv -n` and `cp -n` don't overwrite, and `cp` reads `R`, `v`
and `n` anywhere in a flag group.

#### Performance

- Ordinary `node:vm` contexts run at the same speed. With
`DONT_CONTEXTIFY`, stores to globals and reads of inherited names in a
hot loop cost 10 to 20 % more than on `main`.
- A `process.env` write costs 20 to 35 % less than after #46021.
- An async read into a `WebAssembly.Memory` view looks through the live
memories: about 1.5 µs more with one memory.

#### Not included

- Taken in part, so these should stay open for the rest: #36334, #38974,
#39488, #40526, #40769, #42485, #45615, #45999, #46029, #46092, #46114,
#46168, #46207, #46239.
- Needs a WebKit change: #34655 #39204 #40170 #40253 #41587 #41900
#42699 #42897 #42903 #43084 #43813 #44518 #44949 #45770 #45804 #45827.
- #44723 (covered by #46097), #41675, #42753, #38762, #41475, #46016.

### How did you verify your code works?

- Every item was reproduced first on release builds of `main`, of its
parent and of 1.4.3, and compared with Node v26 where Node has the API.
- Every fix has a test that fails on `main` and passes on a debug + ASAN
build of this branch, except where that isn't practical (huge inputs,
code that only runs on Windows or macOS).
- The regressions were checked on grids of calls against the build
before #46021 and against 1.4.3.
- Bun's own test suites and Node's `test-vm-*`, `test-http2-*` and
`test-zlib-*` files for the areas touched.
@robobun

robobun commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #46270 (commit a950fc5) landed this change on main.

  • main has the same deletion of the payload check in additionalHeaders() (src/js/node/http2.ts). ERR_HTTP2_PAYLOAD_FORBIDDEN is now thrown only in respondWithFile() and respondWithFD().
  • main has a test for it: additionalHeaders() sends a 1xx block on a HEAD request in test/js/node/http2/node-http2.test.js.
  • Checked on a debug build of main at 5c6d4e2. The two tests from this PR pass. The repro steps from the description (the core API script and the curl request with expect: 100-continue) give the same result as Node v26.3.0.

Only in this PR, and tests only: a core API variant of the 103 test (server.on("stream"), HEAD with no expect header), and a separate compat test with a bare res.end() handler. The test on main covers both paths through one compat handler.

Reopen this PR if something is missing.

@robobun robobun closed this Oct 23, 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.

1 participant