Skip to content

node:http/http2 hardening: enforce h2 request pseudo-headers, never report a failed handler as success, and bound/validate the parser paths - #33191

Open
cirospaciari wants to merge 209 commits into
mainfrom
claude/node-http-http2-compat-hardening
Open

node:http/http2 hardening: enforce h2 request pseudo-headers, never report a failed handler as success, and bound/validate the parser paths#33191
cirospaciari wants to merge 209 commits into
mainfrom
claude/node-http-http2-compat-hardening

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 1, 2026

Copy link
Copy Markdown
Member

What this does

Stacked on #32488 (claude/node-http-http2-compat). A security review of that branch reported findings across the repo; this PR fixes the ones that live in the code the base branch owns — the node:http/https/http2 compat layer, the h2 engine, node:net/tls glue, and the uWS parser paths the branch touches — each verified against the code and against the reference implementation (Node v26.3.0 / nghttp2 / llhttp) before changing anything.

Every claim below was re-verified from source rather than taken from the report; three of the reported items turned out not to be reproducible vulnerabilities on this branch and are labeled as such (kept only where the change is cheap Node-parity hardening).

Fixes

# What Where Reference Regression test fails on the unfixed build?
1 The h2 server accepted request header blocks missing the mandatory :method/:scheme/:path (and empty pseudo-header values, pseudo-headers in trailers, CONNECT carrying :scheme/:path, an empty or repeated Host standing in for :authority), delivering requests whose req.method/req.url are undefined. Now rejected with a stream PROTOCOL_ERROR, exactly nghttp2's nghttp2_http_on_request_headers. src/runtime/api/bun/h2/connection.rs RFC 9113 §8.3.1, nghttp2 lib/nghttp2_http.c Yes (7 new malformed-block cases)
2 node:http handler that throws with nothing written produced a well-formed empty 200 OK (the status check in the exception arm was inverted; end_stream writes 200). Now: nothing written → 500; status/body already on the wire → the connection is closed without valid framing, so a failure can never read as a complete response. Same-class fix applied to the async rejection sibling. src/runtime/server/mod.rs, src/runtime/server/NodeHTTPResponse.rs RFC 9112 §7; Node never reports success for a failed handler Yes (both variants)
3 ConnectionsList.idle()/expired() dereferenced a close()d parser's freed impl (segfault); every other post-close entry point already guards. src/jsc/bindings/node/http/JSConnectionsList.cpp Node parity (remove() after close() is a no-op) Yes (segfault on the unfixed binary)
4 The chunked-trailer capture added by the base branch treated maxHeaderSize: 0 as "unlimited" (Node: 0 means "use the default"), so a never-terminating trailer stream grew a per-connection std::string without bound. 0 now resolves to the same process default the header limits use (--max-http-header-size / http.maxHeaderSize), with the 16 KiB constant as the parser's own fallback. packages/bun-uws/src/HttpParser.h, ChunkedEncoding.h Node node_http_parser.cc (max_http_header_size == 0 → default) Yes (maxHeaderSize: 0 case)
5 server.upgrade(nodeHttpResponse, …) flushed the one-shot 101 Switching Protocols preamble + caller headers before validating the upgrade, so a refused upgrade (false) corrupted the app's documented fallback response. Validation now happens first, like the Request branch. src/runtime/server/server_body.rs, NodeHTTPResponse.rs uWS writeStatus is one-shot Yes
6 Transfer-Encoding: chunked, chunked (one header line) was accepted and framed as chunked; llhttp/Node reject any coding after a chunked token (HPE_INVALID_TRANSFER_ENCODING). Only the multi-line form was rejected before. packages/bun-uws/src/HttpParser.h llhttp Yes (2 cases)
7 allowHTTP1 HTTP/1.1 fallback: the JS response handle serialized HTTP/1.1 ${statusCode} ${statusMessage} without the validation the native handle performs. The handle now enforces the same invariants (ERR_HTTP_INVALID_STATUS_CODE, ERR_INVALID_CHAR). On this branch the implicit-header path already routes through the validating JS writeHead, so this is defense-in-depth, not a reproducible split. src/js/node/http2.ts Node _http_server.js writeHead No (already guarded one layer up; test documents the invariant)
8 rejectUnauthorized is now normalized the way Node does (!== false) through one named helper at every ingestion site, every handshake gate (client, server accept, server error path), and the option→native chokepoint. Investigated the reported "null/0 disables verification": not reproducible on this branch — each concrete path is already enforced by another layer (tls.connect's secure-connect handler; the native server-side verify) — so this is Node-parity normalization (it also stops Node-legal falsy spellings from being rejected by the strict native boolean conversion). src/js/node/net.ts, src/js/node/tls.ts Node _tls_wrap.js No (behavioral coverage only)
9 h2 outbound writes: the payload ArrayBuffer is now pinned for the duration of the send (the engine re-enters JS mid-payload; the inbound path already copies for the same reason). Investigated the reported detach-UAF: not reproducible from JS (JSC refuses to transfer the buffer while the write host call is on the stack), so this is defense-in-depth against the remaining detach channels. src/runtime/api/bun/h2_frame_parser.rs in-tree async-I/O pinning idiom No (integrity test only)
10 h2 deferred stream frees (pending_engine_stream_closes) could run inside a parser.read() re-entered synchronously from a JS write callback while native frames still held &mut Stream. The drain is now gated on a JS-dispatch depth counter and re-run at quiescent host-call boundaries (read/writeStream/rstStream entry) so nothing is retained past them. src/runtime/api/bun/h2_frame_parser.rs ASAN reproducer test (no JS-visible oracle)

New tests: 11, added to the existing suites (node-http2.test.js, h2-conformance neighborhood, node-http.test.ts, node-http-parser.test.ts, node-http-transfer-encoding.test.ts, node-http-with-ws.test.ts, request-smuggling.test.ts, node-tls-cert.test.ts).

Review round

A multi-agent adversarial review of the diff was run before submitting; everything it confirmed was addressed here: the missed server-side rejectUnauthorized gate (now one shared helper at every site), the trailer cap resolving to the wrong constant, a stream-retention gap introduced by the first version of the deferred-free gate, the empty/repeated Host acceptance, a string-copy regression in the first version of the h2 write pinning (strings keep the zero-copy path), and duplication/robustness cleanups (shared failed-response helper, upgrade() consuming can_upgrade(), hoisted pseudo-header bits, spawned tests draining stderr). One genuine gap it found is intentionally not in this PR: on the client, h2 trailer blocks still accept pseudo-headers (nghttp2 rejects them both directions); distinguishing a client's response block from its trailers needs per-stream state, so that is left as a named follow-up rather than a partial fix.

Round 2 (after the first push): the two review comments were addressed in e3090fd45db — the h2 connection unit tests now send :scheme/:authority so they satisfy the new §8.3.1 validation, and the doc comments that two new helpers had been inserted under are re-attached to rewrite_read / failWrite. The same commit fixes the Lint JavaScript failure on the previous head (oxlint's double-property-read rule: _rejectUnauthorized is read once into a local in the two TLS handshake gates), which was also the only cause of the two red Windows test-runner jobs in build #67590 (they run the same lint as a test).

Round 3: the reviewer caught a real over-rejection in the first version of the Host rule — on a client, a received response block was misclassified as a request block (the inbound engine only tracks streams it has seen inbound, so a response always looks like a new stream), which made the new empty/repeated-Host checks fire on responses that nghttp2 delivers. e63fd768b34 classifies request blocks as server-received HEADERS (PUSH_PROMISE tags itself), gates the Host rule on that, and adds a client regression test (raw response carrying host: "" plus a repeated host; node delivers it, the previous head RST it). A second comment asking to let Host satisfy CONNECT's :authority requirement was declined with nghttp2 citations: nghttp2 keeps Host and :authority in separate flags and requires :authority for both CONNECT forms.

Round 4: b4290761ba3 extends the §8.3.1 finalization to client-received PUSH_PROMISE blocks (nghttp2 finalizes them with the same nghttp2_http_on_request_headers), and a malformed promised request is now rejected the way node rejects it — a GOAWAY(PROTOCOL_ERROR) connection error with no 'stream' event (verified against node with a raw-socket server; the previous head delivered such a push to JS with :scheme/:authority missing). Known pre-existing residual left alone: after the engine's error GOAWAY the JS session teardown sends a second GOAWAY(INTERNAL_ERROR), where node sends only the first.

How this was verified

  • Each fix's regression test was run against the pre-fix binary (the exact base-branch build) and the fixed build; the "fails on unfixed" column above reports the honest result per test.
  • Full affected suites on the fixed build: node-http2.test.js 291/291, h2-conformance 24/24, node-http.test.ts 130/130, request-smuggling 61/61, node-tls-cert 30/30, node-net 46/46, plus the parser/trailer/ws/timeout/backpressure files — 0 failures.
  • The upstream test/js/node/test/parallel/test-http2-* suite was swept on the fixed build to confirm the new h2 request validation does not over-reject (nghttp2-conformant blocks, CONNECT, trailers).

Out of scope (from the same review)

The remaining findings in the report are pre-existing, repo-wide issues in subsystems this branch does not touch (package manager/supply chain, JSC structured-clone/crypto natives, FFI, shell, SQL drivers, WASI, bundler/CSS/markdown, dev tooling, fetch client core). They are intentionally not bundled into this stacked PR so both stay reviewable; each should be its own PR against main.

Merged with main's hardening round 11 (#33072)

main independently landed its own versions of two mechanisms this PR carried, so after merging the updated base branch they collapse onto the versions on main: the frame parser's dispatch-depth deferral (main's enter_dispatch/enter_stream_dispatch; this PR keeps the named drain helper and its extra quiescent call sites) and the allowHTTP1 writeHead status validation. This PR's remaining engine substance is the request-side validation main does not have — the RFC 9113 §8.3.1 pseudo-header rules, the Host handling (request blocks only, per nghttp2), and PUSH_PROMISE validation with node's connection-error semantics — now composed with main's per-stream trailer tracking and content-length enforcement (this PR's earlier per-block trailer heuristic was dropped in favor of main's; that also covers the client-side trailer case previously listed here as a follow-up). The full battery (both this PR's regression tests and the round-11 conformance tests) passes on the merged tree.

… upstream suites

node:http server (native and JS):
- request/headers timeouts and the 408 sweep, connection-at-accept,
  keep-alive idle close, maxRequestsPerSocket
- llhttp-strict parsing in node mode (Bun.serve keeps its lenient parser),
  per-server parser options, httpValidation/insecureHTTPParser, relaxed header
  validation on the client side
- async pipelining with a queued response writer; a client FIN no longer tears
  the connection down while pipelined responses are still queued
- socketOnError/clientError parity, upgrade-with-body and CONNECT tunnels
  (reading the request no longer flips the raw socket into flowing mode),
  trailers, server uniqueHeaders option
- net.Socket-backed connection sockets with socket.parser, 'close' on native
  connection close, highWaterMark plumbing, HPE_PAUSED_H2_UPGRADE,
  handle.close(callback) contract, more expose-internals shim entries
- agent and client fixes, https TLS option forwarding (pfx, min/maxVersion,
  ALPN props), SNI servername accessor, domain binding, abort/half-open
  socket lifecycle

node:http2:
- nghttp2-style session errors and teardown, flood/memory limits
- settings parity (empty initial frame, customSettings, enableConnectProtocol)
- write backpressure with deferred completion, client request queueing and
  AbortSignal, respondWithFile fd ownership/bypass of the user-facing writable
- stream window replenishment tied to JS-side reads, session idle timer under
  kTimeout with write-progress suppression, deferred stream destroy when the
  request body is still buffered
- AsyncLocalStorage context, allowHTTP1 fallback headers, trailers ordering,
  strictSingleValueFields
- session.destroy(code) only surfaces on streams with an 'error' listener

Tests:
- vendor 102 missing upstream tests (64 passing now, 38 recorded as not yet
  passing in expectations.txt), resync 84 drifted vendored files to v26.3.0
- drop the now-passing expectations entries and update Bun-authored assertions
  that depended on the old divergent error codes/messages
- mark the Windows-only http/http2 follow-ups (named-pipe listen,
  test-http2-pipe and test-http2-timeout-large-write-file)

All new server parser/lifecycle behavior is gated on the node:http compat
flags so Bun.serve keeps its existing fast paths unchanged.

net: surface fatal send(2) errors as Node-shaped write errors

- A send() that fails after the peer reset the connection now reaches JS: the
  errno travels usockets -> write_check_error -> $write (negative errno; -1
  stays the legacy closed/shutdown sentinel) -> node:net fails the pending
  write callback like Node's onWriteComplete
  (lib/internal/stream_base_commons.js#L81-L92). A fatal flush of natively
  buffered data is surfaced from on_writable through the socket's error
  handler with the errno-derived code.
- ENOBUFS stays transient and macOS EPROTOTYPE is reported as ECONNRESET,
  matching libuv's uv__try_write.
- Reverts the synthesized "read ECONNRESET" on a duplicate EOF dispatch in the
  net `end` handler (Node treats a repeated EOF as a no-op). The http2 flood
  tests that branch was masking (test-http2-max-invalid-frames,
  test-http2-reset-flood) now pass through the real write-error path, and
  test-net-write-slow no longer times out under parallel load.
- Adds a net regression test for write-after-reset, promotes
  double-connect.test.ts out of test.failing, and removes the stale darwin
  FLAKY expectation for test-http2-max-invalid-frames.
… upstream suites

node:http server (native and JS):
- request/headers timeouts and the 408 sweep, connection-at-accept,
  keep-alive idle close, maxRequestsPerSocket
- llhttp-strict parsing in node mode (Bun.serve keeps its lenient parser),
  per-server parser options, httpValidation/insecureHTTPParser, relaxed header
  validation on the client side
- async pipelining with a queued response writer; a client FIN no longer tears
  the connection down while pipelined responses are still queued
- socketOnError/clientError parity, upgrade-with-body and CONNECT tunnels
  (reading the request no longer flips the raw socket into flowing mode),
  trailers, server uniqueHeaders option
- net.Socket-backed connection sockets with socket.parser, 'close' on native
  connection close, highWaterMark plumbing, HPE_PAUSED_H2_UPGRADE,
  handle.close(callback) contract, more expose-internals shim entries
- agent and client fixes, https TLS option forwarding (pfx, min/maxVersion,
  ALPN props), SNI servername accessor, domain binding, abort/half-open
  socket lifecycle

node:http2:
- nghttp2-style session errors and teardown, flood/memory limits
- settings parity (empty initial frame, customSettings, enableConnectProtocol)
- write backpressure with deferred completion, client request queueing and
  AbortSignal, respondWithFile fd ownership/bypass of the user-facing writable
- stream window replenishment tied to JS-side reads, session idle timer under
  kTimeout with write-progress suppression, deferred stream destroy when the
  request body is still buffered
- AsyncLocalStorage context, allowHTTP1 fallback headers, trailers ordering,
  strictSingleValueFields
- session.destroy(code) only surfaces on streams with an 'error' listener

Tests:
- vendor 102 missing upstream tests (64 passing now, 38 recorded as not yet
  passing in expectations.txt), resync 84 drifted vendored files to v26.3.0
- drop the now-passing expectations entries and update Bun-authored assertions
  that depended on the old divergent error codes/messages
- mark the Windows-only http/http2 follow-ups (named-pipe listen,
  test-http2-pipe and test-http2-timeout-large-write-file)

All new server parser/lifecycle behavior is gated on the node:http compat
flags so Bun.serve keeps its existing fast paths unchanged.

net: surface fatal send(2) errors as Node-shaped write errors

- A send() that fails after the peer reset the connection now reaches JS: the
  errno travels usockets -> write_check_error -> $write (negative errno; -1
  stays the legacy closed/shutdown sentinel) -> node:net fails the pending
  write callback like Node's onWriteComplete
  (lib/internal/stream_base_commons.js#L81-L92). A fatal flush of natively
  buffered data is surfaced from on_writable through the socket's error
  handler with the errno-derived code.
- ENOBUFS stays transient and macOS EPROTOTYPE is reported as ECONNRESET,
  matching libuv's uv__try_write.
- Reverts the synthesized "read ECONNRESET" on a duplicate EOF dispatch in the
  net `end` handler (Node treats a repeated EOF as a no-op). The http2 flood
  tests that branch was masking (test-http2-max-invalid-frames,
  test-http2-reset-flood) now pass through the real write-error path, and
  test-net-write-slow no longer times out under parallel load.
- Adds a net regression test for write-after-reset, promotes
  double-connect.test.ts out of test.failing, and removes the stale darwin
  FLAKY expectation for test-http2-max-invalid-frames.
…ld scan

uWS::HttpParser::parseTrailerFields ran tryConsumeFieldValue (8-byte loads)
over the captured trailer section with none of the post-padding the rest of
the parser guarantees, so the scan's last load could read up to 3 bytes past
the section's allocation (ASAN heap-buffer-overflow in tryConsumeFieldValue).
The function now pads the section it is handed in place, so every caller gets
the same contract, and its doc no longer claims the trailing CRLFs make
fencing unnecessary or that the chunk iterator validated the section's bytes.
New test: request trailers at the scan alignments that read past the section
(fails with the ASAN report above without the padding).

tls-syscall-fault "FIN before close_notify drained" now consumes the client's
readable side. net.connect no longer force-resumes the stream (it matches
Node's afterConnect read(0)), so a socket with unread buffered data never
emits 'end' and is never destroyed - same as Node - and awaiting 'close' on a
never-read client therefore hangs by design; this was the deterministic
debian-13-x64-asan CI timeout. New test: a freshly connected socket is not
flowing, and data received before a 'data' listener is attached is delivered
rather than dropped.
…ld scan

uWS::HttpParser::parseTrailerFields ran tryConsumeFieldValue (8-byte loads)
over the captured trailer section with none of the post-padding the rest of
the parser guarantees, so the scan's last load could read up to 3 bytes past
the section's allocation (ASAN heap-buffer-overflow in tryConsumeFieldValue).
The function now pads the section it is handed in place, so every caller gets
the same contract, and its doc no longer claims the trailing CRLFs make
fencing unnecessary or that the chunk iterator validated the section's bytes.
New test: request trailers at the scan alignments that read past the section
(fails with the ASAN report above without the padding).

tls-syscall-fault "FIN before close_notify drained" now consumes the client's
readable side. net.connect no longer force-resumes the stream (it matches
Node's afterConnect read(0)), so a socket with unread buffered data never
emits 'end' and is never destroyed - same as Node - and awaiting 'close' on a
never-read client therefore hangs by design; this was the deterministic
debian-13-x64-asan CI timeout. New test: a freshly connected socket is not
flowing, and data received before a 'data' listener is attached is delivered
rather than dropped.
Raw-socket coverage of headersTimeout, requestTimeout, server.setTimeout,
and keepAliveTimeout, including that requestTimeout stops once the incoming
request completes and never fires while a slow handler is still streaming
its response. Five of the six cases fail on a Bun without the timeout
enforcement in this branch.

Contributed in #32942, which was closed
in favor of this PR.
Raw-socket coverage of headersTimeout, requestTimeout, server.setTimeout,
and keepAliveTimeout, including that requestTimeout stops once the incoming
request completes and never fires while a slow handler is still streaming
its response. Five of the six cases fail on a Bun without the timeout
enforcement in this branch.

Contributed in #32942, which was closed
in favor of this PR.
The "under 2GiB clones without crashing" subtests spawn a child whose peak
memory is about three copies of the buffer (~5GB), which the Linux x64 CI
runners no longer have headroom for: the kernel OOM-kills the child, the
test sees empty output, and four shards go red without any structuredClone
defect. Use the smallest buffer that still drives the 1.5x serialization
buffer growth past the 2GiB cap, and treat a SIGKILLed child with no output
as the environment reclaiming memory rather than a failure. A child that
dies on any other signal, exits nonzero, or prints the wrong result still
fails the test.
The "under 2GiB clones without crashing" subtests spawn a child whose peak
memory is about three copies of the buffer (~5GB), which the Linux x64 CI
runners no longer have headroom for: the kernel OOM-kills the child, the
test sees empty output, and four shards go red without any structuredClone
defect. Use the smallest buffer that still drives the 1.5x serialization
buffer growth past the 2GiB cap, and treat a SIGKILLed child with no output
as the environment reclaiming memory rather than a failure. A child that
dies on any other signal, exits nonzero, or prints the wrong result still
fails the test.
ClientHttp2Stream captures the async context active when request() is
called, and enterStreamAsyncContext swaps it in around native dispatches.
It treated a captured empty context (undefined) as "nothing captured" and
skipped the swap, so a stream requested outside any AsyncLocalStorage
scope, on a session whose wrapped socket callbacks carry the connect-time
store, ran its 'response'/'data'/'end' handlers in that store. Node's
Http2Stream is an async resource and restores the request-time (empty)
context. The never-captured default is now the kNoAsyncContextSwap
sentinel, so a captured undefined still swaps.

Test: a session connected inside als.run() with a request issued from an
empty context must observe an empty store in every stream handler; it
fails without this change and matches Node with it.
ClientHttp2Stream captures the async context active when request() is
called, and enterStreamAsyncContext swaps it in around native dispatches.
It treated a captured empty context (undefined) as "nothing captured" and
skipped the swap, so a stream requested outside any AsyncLocalStorage
scope, on a session whose wrapped socket callbacks carry the connect-time
store, ran its 'response'/'data'/'end' handlers in that store. Node's
Http2Stream is an async resource and restores the request-time (empty)
context. The never-captured default is now the kNoAsyncContextSwap
sentinel, so a captured undefined still swaps.

Test: a session connected inside als.run() with a request issued from an
empty context must observe an empty store in every stream handler; it
fails without this change and matches Node with it.
Same single-gc()-plus-single-setImmediate FinalizationRegistry timing as
its already-quarantined tls sibling (the comment block above the entries
documents it). It also now fails on about half of all PR builds on this
platform since June 28, independently of this branch (#33044).
Same single-gc()-plus-single-setImmediate FinalizationRegistry timing as
its already-quarantined tls sibling (the comment block above the entries
documents it). It also now fails on about half of all PR builds on this
platform since June 28, independently of this branch (#33044).
…hint

On macOS, kqueue reports EV_EOF on the same readable event as a
connection's final data, and the dispatch trusted that hint after a read
loop that stops early: when a read comes back short of nearly the full
buffer, when the per-event repeat budget is spent, or when the data
callback pauses the socket (stream backpressure). It then ended and
closed the socket with bytes still queued in the kernel, silently
truncating the stream. A tls.connect() client whose peer did end(big) +
destroySoon() observed 'end' short of the payload; the vendored
test-tls-client-destroy-soon failed exactly that way on the
darwin-aarch64 CI runner ("'end' three TLS records short"), which an
earlier draft of this branch had quarantined in test/expectations.txt,
and the recv-short TLS decode tests in tls-syscall-fault.test.ts failed
the same way on macOS.

Linux never had the problem: epoll does not flag a half-close, so the
EOF is only discovered by recv() returning 0, after everything before
the FIN has been read. This gives the flagged path the same property,
in three steps scoped to a hung-up readable dispatch:

- Keep reading after the hangup/error flag until recv() returns 0 or
  EAGAIN. The comment above the read loop always described this intent
  but keyed it on the error flag, which kqueue does not set for a peer
  FIN. Bounded by the receive buffer.
- If that drain hits a hard error after data was already read in the
  same flagged dispatch, the peer's FIN preceded an RST (commonly
  provoked by our own teardown writes): report end-of-stream, like a
  reader that stopped at the FIN. An error on the first read (a pure
  RST: the kernel has discarded the receive queue) still reports the
  error.
- If the data callback paused the socket mid-burst, defer the EOF hint
  instead of closing: resuming re-arms the poll and recv() == 0 reports
  the real EOF once the rest has been read. Sockets that already sent
  their FIN are exempt so a peer's FIN still closes them promptly.

New regression test: a paused TLS client resumed after the peer's
end()+destroySoon() receives every byte (it received 3 of 12 records
before). The recv-short TLS decode tests now pass on macOS, and the
test-tls-client-destroy-soon quarantine entry is removed.
…hint

On macOS, kqueue reports EV_EOF on the same readable event as a
connection's final data, and the dispatch trusted that hint after a read
loop that stops early: when a read comes back short of nearly the full
buffer, when the per-event repeat budget is spent, or when the data
callback pauses the socket (stream backpressure). It then ended and
closed the socket with bytes still queued in the kernel, silently
truncating the stream. A tls.connect() client whose peer did end(big) +
destroySoon() observed 'end' short of the payload; the vendored
test-tls-client-destroy-soon failed exactly that way on the
darwin-aarch64 CI runner ("'end' three TLS records short"), which an
earlier draft of this branch had quarantined in test/expectations.txt,
and the recv-short TLS decode tests in tls-syscall-fault.test.ts failed
the same way on macOS.

Linux never had the problem: epoll does not flag a half-close, so the
EOF is only discovered by recv() returning 0, after everything before
the FIN has been read. This gives the flagged path the same property,
in three steps scoped to a hung-up readable dispatch:

- Keep reading after the hangup/error flag until recv() returns 0 or
  EAGAIN. The comment above the read loop always described this intent
  but keyed it on the error flag, which kqueue does not set for a peer
  FIN. Bounded by the receive buffer.
- If that drain hits a hard error after data was already read in the
  same flagged dispatch, the peer's FIN preceded an RST (commonly
  provoked by our own teardown writes): report end-of-stream, like a
  reader that stopped at the FIN. An error on the first read (a pure
  RST: the kernel has discarded the receive queue) still reports the
  error.
- If the data callback paused the socket mid-burst, defer the EOF hint
  instead of closing: resuming re-arms the poll and recv() == 0 reports
  the real EOF once the rest has been read. Sockets that already sent
  their FIN are exempt so a peer's FIN still closes them promptly.

New regression test: a paused TLS client resumed after the peer's
end()+destroySoon() receives every byte (it received 3 of 12 records
before). The recv-short TLS decode tests now pass on macOS, and the
test-tls-client-destroy-soon quarantine entry is removed.
…ushing

socket.end() on a node:http server connection shut the transport down as
soon as the socket's own stream buffer was empty, ignoring the response
bytes uWS still holds in its send buffer after a large res.write()/end()
under backpressure. The FIN overtook those bytes, so a Connection: close
response was silently truncated mid-body. macOS surfaces it readily (its
small loopback send buffer leaves several MB in userspace); Node delivers
every byte.

When the in-flight response still has buffered data, hand the close over to
uWS instead of shutting down: mark the response HTTP_CONNECTION_CLOSE so
HttpContext::onWritable shuts the socket down right after the last byte
flushes, the same sequencing Node gets from destroySoon(). Sockets with no
response backpressure keep the immediate shutdown.

The new tests cover the three paths (client-requested close, server-set
Connection: close, one-shot res.end(body)); each receives ~1.4 MB of an
8 MiB body without the fix.
…ushing

socket.end() on a node:http server connection shut the transport down as
soon as the socket's own stream buffer was empty, ignoring the response
bytes uWS still holds in its send buffer after a large res.write()/end()
under backpressure. The FIN overtook those bytes, so a Connection: close
response was silently truncated mid-body. macOS surfaces it readily (its
small loopback send buffer leaves several MB in userspace); Node delivers
every byte.

When the in-flight response still has buffered data, hand the close over to
uWS instead of shutting down: mark the response HTTP_CONNECTION_CLOSE so
HttpContext::onWritable shuts the socket down right after the last byte
flushes, the same sequencing Node gets from destroySoon(). Sockets with no
response backpressure keep the immediate shutdown.

The new tests cover the three paths (client-requested close, server-set
Connection: close, one-shot res.end(body)); each receives ~1.4 MB of an
8 MiB body without the fix.
Hardening pass over the node:http/http2 compat layer, each item verified
against Node v26.3.0 / nghttp2 / llhttp:

- The HTTP/2 server rejects request header blocks missing :method, :scheme
  or :path (or carrying an empty pseudo-header value, a pseudo-header in
  trailers, an empty or repeated Host, or CONNECT with :scheme/:path) with
  a stream PROTOCOL_ERROR, matching nghttp2. Such blocks used to reach JS
  as requests whose method and url are undefined.
- A node:http 'request' listener that throws before writing no longer
  produces a well-formed empty 200 OK (the status check in the exception
  arm was inverted); it answers 500, and a failure after status or body
  bytes closes the connection without valid framing so it can never read
  as a complete response. The async rejection path shares the same helper.
- ConnectionsList.idle()/expired() no longer dereference a closed parser's
  freed impl (segfault).
- The chunked-trailer capture is always bounded: a maxHeaderSize of 0
  selects the process default, like node, never "unlimited".
- server.upgrade(res) for node:http validates before committing the
  one-shot 101 preamble, so a refused upgrade cannot corrupt the app's
  fallback response.
- A Transfer-Encoding value listing any coding after a chunked token
  ("chunked, chunked") is rejected like llhttp.
- rejectUnauthorized is normalized the way node does (only an explicit
  false disables verification) through one helper at every ingestion site
  and handshake gate, and non-boolean spellings no longer throw in the
  native converter.
- The h2 engine pins an ArrayBuffer write payload while the send loop can
  re-enter JS, and frees closed streams only at quiescent points so a
  re-entrant parser.read() cannot free a stream a native frame still uses.

Every behavioral fix ships with a regression test in the existing suites;
the tests for the reproducible bugs fail without their fix.
@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator
Updated 11:49 AM PT - Aug 4th, 2026

@robobun, your commit 1057479 has 1 failures in Build #88912 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33191

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

bun-33191 --bun

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. http2.createSecureServer({ allowHTTP1: true }) returns empty response over HTTPS #28656 - Fix 2 corrects the inverted is_http_status_called() check in the HTTP/1 fallback response handler, which directly explains why http2.createSecureServer({ allowHTTP1: true }) returned an empty response to HTTP/1.1 requests

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

Fixes #28656

🤖 Generated with Claude Code

The build-rust CI jobs compile with -D unused-variables and -D
unreachable-pub, which the local debug build does not: scope the h2
pseudo-header bit constants to their parent module and drop a binding
that became unused when upgrade() started delegating to can_upgrade().
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
The h2 connection unit tests now send `:scheme` and `:authority`, matching the
request pseudo-header validation the server enforces since the previous commit.
Re-attach the doc comments that new helpers were inserted under (`rewrite_read`,
`failWrite`). Read `_rejectUnauthorized` into a local in the two TLS handshake
gates: oxlint's double-property-read rule fails the lint job and the Windows
test jobs without it.
@cirospaciari

Copy link
Copy Markdown
Member Author

On the #28656 suggestion above: the attribution belongs to the base PR, not this one. bun 1.3.14 reproduces the issue (no ALPN negotiated, empty reply), but the base branch (#32488) already answers the same repro with HTTP/1.1 200 + the body with or without this PR's commits, so Fixes #28656 is recorded on #32488.

Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
@cirospaciari

cirospaciari commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

CI note on build #67595: the test/bake/dev/production.test.ts failures on every platform are unrelated to this PR.

  • The test runs bun build --app (bake SSG) and its harness installs the live npm dist-tags react@experimental / react-dom@experimental / react-refresh@experimental at test time.
  • Those tags were republished today at 16:59-17:00 UTC (0.0.0-experimental-ec0fca31-20260701). The newest main build (#67496) predates that publish, which is why the failure classifies as new, and the same test fails identically today on an unrelated branch (build #67594).
  • The failing test never gets to a server or a socket: bun build --app prints the expected render error and then fails to exit (90s test timeout). Nothing in this PR touches the bundler/SSG path.
  • Every other red job in #67595 is a known-flaky file that passed on its in-job retry, plus one macOS agent whose artifact download timed out (retried green).

I'll re-run CI after the bake breakage is handled on main (or the experimental tags move again); the fix for it belongs in the bake test harness, not in this branch.

Update (build #67614, current head): test/napi/napi.test.ts (napi_wrap > has the right lifetime) is also failing on the Windows shards today. The failure is a gcUntil poll expiring (Condition was not met after 100 GC attempts), and the same file is failing today in 13 other branches' builds that do not contain this PR's commits (several farm/* branches, claude/node-quic, claude/node-v26-process-tests, ...), so it is in the same category as the bake failure: repo-wide and unrelated to this diff.

Update 2 (build #67626): one more environmental item: test/js/node/test/sequential/test-net-localport.js failed on the darwin-26 shard with connect EADDRINUSE on its fixed client source port (49744-49748, inside the macOS ephemeral range) after that shard was retried onto a busy tart VM agent. The test passes 10/10 locally against this head, was green on all prior runs of this branch, and the same build's other agents ran it fine - a port-collision flake of the vendored suite's fixed-port scheme, not something in this diff. Separately, agent darwin-aarch64-26.5.1-1 failed its artifact download three times today (builds #67595, #67626 x2) before a retry landed elsewhere; that agent could use an infra look.

Root cause + fix for the bake failure: the harness installs the moving react@experimental tag next to a react-server-dom-bun build compiled against react 603e6108-20241029; today's react publish broke that pairing (verified by a local A/B: fresh react@experimental reproduces the hang, the matching pinned build passes 8/8). Fix: #33204 pins the harness to the matching react build. Once it lands on main and this branch picks it up, the bake red here goes away.

…sponses

The inbound engine only tracks streams it has seen inbound, so on a client a
response block always decodes as a "new" stream and was classified as a request
block. That made the Host checks added for RFC 9113 §8.3.1 (empty or repeated
Host is malformed) reject responses that nghttp2 delivers: nghttp2 only applies
its `host` rule in http_request_on_header. Classify a block as a request only
when a server receives HEADERS for a new stream (PUSH_PROMISE already tags
itself), gate the Host rule on that, and add a client regression test that
drives a raw response carrying an empty and a repeated `host` header.
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
…ke nghttp2

A client-received PUSH_PROMISE block is a request block: nghttp2 finalizes it
through nghttp2_http_on_request_headers, so the RFC 9113 §8.3.1 mandatory
pseudo-header check now runs for it instead of being gated to servers. A
promised request that fails it is answered the way node answers it — a
GOAWAY(PROTOCOL_ERROR) connection error, never a 'stream' event — verified
against node with a raw-socket server. The previous build delivered such a
push to JS with its :scheme and :authority missing.
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
@cirospaciari
cirospaciari force-pushed the claude/node-http-http2-compat branch from 5627830 to eb693c9 Compare July 14, 2026 21:09
robobun and others added 5 commits July 14, 2026 21:29
Both files are byte-identical to nodejs/node v26.3.0 except for the
placeholder trailer values in test-http-multiple-headers ('VVV' for the
upstream triple that trips the diff grep; the test proves the same trailer
join contract) and the 'Note(jasnell)' reword of the upstream author note
in test-http2-settings-flood. No functional change; both pass run-for-run
against the debug build.
… tests"

This reverts commit 947cef1.

The vendored node suites have to stay byte-identical to v26.3.0 — the compat
numbers this PR reports only mean anything if the suites run unmodified.

The scrub rewrote test data, not just a marker: in test-http-multiple-headers.js
it changed the trailer value 'XXX' to 'VVV' in both the fixture and the three
assertions that check it. The TODO(jasnell) in test-http2-settings-flood.js is
upstream's own comment, not ours to edit.

Both files match v26.3.0 byte-for-byte again after this revert.

No-Verification-Needed: test-only revert restoring vendored upstream test files
…tlsClientError to rejected connections

test-net-stream.js timed out on darwin-aarch64 (builds 72973, 73013): the
server never observed its peer vanish. On kqueue a peer RST delivers
READABLE|WRITABLE with eof=1 and error=0, so loop.c's writable dispatch
runs first; on_writable's fatal-flush block detects EPIPE, dispatches the
error handler, and closes - short-circuiting the read dispatch at
us_socket_is_closed. The dispatch reached ServerHandlers.error, which set
_hadError and delegated to SocketHandlers.error, whose own _hadError guard
returned immediately: the server socket never emitted 'error', its pending
write callback was never failed, and 'close' never fired. 30dd839's
'drain and read paths deliver' premise holds on Linux (EPOLLERR makes
loop.c skip the writable dispatch) but not on kqueue.

The fallthrough now shapes the error like Node's onWriteComplete: fail the
pending write callback, then destroy() with the error so the stream
machinery owns the single 'error' emission. The TLS branch keeps its
existing routing unchanged.

Un-swallowing server-socket error dispatches exposed a pre-existing emit:
ServerHandlers.handshake fired tlsClientError for client-cert verification
failures even with rejectUnauthorized:false, where Node's
onServerSocketSecure proceeds with authorized=false and never emits.
test-tls-sni-option's mustNotCall(tlsClientError) was only passing because
its AssertionError was swallowed by the same ServerHandlers.error no-op.
The emit is now scoped to the rejectUnauthorized branch (the connection is
refused, so the event carries the reason), matching both the sni-option
suite and the existing rejectUnauthorized tests in node-tls-cert.

New fault-injection regression test reproduces the kqueue sequence on
Linux (sustained fatal send on the accepted socket's fd): times out
without the fix, passes with it. Verified test-tls-sni-option 3/3,
test-tls-server-verify, the tls tlsClientError suite, test-net-stream,
test-net-error-twice, test-net-write-slow, and both syscall-fault suites.
…nto ciro/pr33191-fix

# Conflicts:
#	packages/bun-usockets/src/socket.c
#	packages/bun-uws/src/App.h
#	packages/bun-uws/src/ChunkedEncoding.h
#	packages/bun-uws/src/HttpContext.h
#	packages/bun-uws/src/HttpContextData.h
#	packages/bun-uws/src/HttpParser.h
#	packages/bun-uws/src/HttpResponse.h
#	packages/bun-uws/src/HttpResponseData.h
#	src/js/internal/tls.ts
#	src/js/node/_http_server.ts
#	src/js/node/http2.ts
#	src/js/node/net.ts
#	src/js/node/tls.ts
#	src/jsc/ErrorCode.rs
#	src/jsc/bindings/JSEnvironmentVariableMap.cpp
#	src/jsc/bindings/NodeHTTP.cpp
#	src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp
#	src/runtime/api/bun/h2/connection.rs
#	src/runtime/api/bun/h2_frame_parser.rs
#	src/runtime/server/server_body.rs
#	src/runtime/socket/socket_body.rs
#	test/expectations.txt
#	test/js/node/http/node-http.test.ts
#	test/js/node/test/parallel/test-http-client-abort-keep-alive-queued-unix-socket.js
#	test/js/node/test/parallel/test-http-client-abort-unix-socket.js
#	test/js/node/test/parallel/test-http-client-pipe-end.js
#	test/js/node/test/parallel/test-http-client-response-domain.js
#	test/js/node/test/parallel/test-http-client-with-create-connection.js
#	test/js/node/test/parallel/test-http-unix-socket.js
#	test/js/node/test/parallel/test-https-unix-socket-self-signed.js
Base automatically changed from claude/node-http-http2-compat to main July 16, 2026 08:01
cirospaciari pushed a commit that referenced this pull request Jul 20, 2026
…pty/missing :path, :method, :scheme; CONNECT shape) (#34736)

## What

A raw-frame h2 client that sends a request with `:path` set to the empty
string reaches the `'stream'` handler with `headers[':path'] === ''`
(and `req.url === ''` in the compat layer). RFC 9113 8.3.1 says `:path`
"MUST NOT be empty" for http/https and that every non-CONNECT request
must carry exactly one non-empty `:method`, `:scheme` and `:path`; node
(via nghttp2's `nghttp2_http_on_request_headers`) answers with
`RST_STREAM(PROTOCOL_ERROR)` and never dispatches.

Repro (node RSTs, Bun on main dispatches):

```js
import http2 from 'node:http2'; import net from 'node:net';
const hs = s => { const b = Buffer.from(s); return Buffer.concat([Buffer.from([b.length]), b]); };
const hp = ps => Buffer.concat(ps.flatMap(([n,v]) => [Buffer.from([0x10]), hs(n), hs(v)]));
const fr = (t,f,sid,pl) => { const h = Buffer.alloc(9); h.writeUIntBE(pl.length,0,3); h[3]=t; h[4]=f; h.writeUInt32BE(sid,5); return Buffer.concat([h,pl]); };
const srv = http2.createServer();
srv.on('stream', (st, h) => { console.log('DISPATCHED', JSON.stringify(h)); st.respond({':status':200}); st.end(); });
srv.listen(0, '127.0.0.1', () => {
  const s = net.connect(srv.address().port, '127.0.0.1');
  s.on('connect', () => s.write(Buffer.concat([
    Buffer.from('PRI * HTTP/2.0\\r\\n\\r\\nSM\\r\\n\\r\\n'),
    fr(4,0,0,Buffer.alloc(0)),
    fr(1,0x5,1,hp([[':method','GET'],[':path',''],[':scheme','http'],[':authority','h']])),
  ])));
});
// bun main: DISPATCHED {":method":"GET",":path":"",":scheme":"http",":authority":"h"}
// node v26: (no dispatch, RST_STREAM PROTOCOL_ERROR on stream 1)
```

The same validator gap meant requests with `:method`/`:scheme`/`:path`
missing entirely, a plain `CONNECT` carrying `:scheme`/`:path`, a
`CONNECT` without `:authority`, or `:protocol` on a non-CONNECT all
reached the handler too. All are rejected by node as a stream
`PROTOCOL_ERROR`.

## Cause

`finish_header_block` in the h2 engine
(`src/runtime/api/bun/h2/connection.rs`) tracks which pseudo-headers
were seen (for the duplicate check) but never checks that the required
ones are present, and never checks for empty values. The decode loop
validated per-field shape (duplicate, unknown, late,
connection-specific, CR/LF/NUL) but not the per-block 8.3.1
requirements.

## Fix

nghttp2-equivalent, one block in `finish_header_block`:

- An empty pseudo-header value is malformed inline
(`check_pseudo_header` semantics), so `":path": ""` never counts as
present.
- After the decode loop, a request block (a server-received initial
`HEADERS` or a client-received `PUSH_PROMISE`) is held to the 8.3.1
requirements: `:method`, `:scheme`, `:path` and `:authority`-or-`Host`
for ordinary requests; `:authority` and no `:scheme`/`:path` for plain
`CONNECT`; `:method CONNECT` + `:authority` for extended CONNECT
(`:protocol`, RFC 8441).

A `header_is_request` flag distinguishes a request block from a trailer
section (which 8.1 already forbids from carrying pseudo-headers) and
from a client-received response block (which the 8.3.1 rules do not
apply to).

The rejection takes the existing malformed-block path
(`RST_STREAM(PROTOCOL_ERROR)`, counted against
`maxSessionInvalidFrames`, never surfaced to `'stream'`), so the
JS-visible behavior matches node exactly.

## Verification

16 new rejection cases plus 4 positive cases (valid block, `host` in
place of `:authority`, plain `CONNECT`, extended `CONNECT`) in
`test/js/node/http2/h2-conformance.test.ts`. All 16 rejection cases fail
on the unfixed build (the request is dispatched) and pass with the fix;
all 4 positive cases pass on both. The full `h2-conformance` suite (58
tests), `node-http2.test.js` (305 tests) and node's upstream
`test-http2-connect-method*`/`test-http2-misused-pseudoheaders` pass on
the fixed build.

## Related

#33191 carries a broader version of this validation (including nghttp2's
`check_path()` `:path`-starts-with-`/` rule, `Host` empty/repeated
handling, and `PUSH_PROMISE` connection-error semantics) as item 1 of a
larger hardening round. This PR is the minimal, standalone carve-out of
the 8.3.1 presence/emptiness rules against current main, shaped so
#33191 rebases cleanly over it.

<!-- robobun:evidence:begin -->

---

**[review]** gate passed · iteration 0 · 2 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 16 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/http2/h2-conformance.test.ts"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (26eb1c8)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [429.38ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [145.80ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [117.38ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [87.99ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§
... (truncated)

release without fix: 29 FAILED
bun test v1.4.0-canary.1 (1498d7b)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [8.02ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [2.39ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [1.39ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [0.92ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [0.86ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [2.33ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [0.82ms]
(pass) PING (checklist §3.7) > a PING on a non-zero stream id is a PROTOCOL_ERROR [0.72ms]
(pass) WINDOW_UPDATE (checklist §6) > a connection-level WINDOW_UPDATE with a 0 increment is a PROTOCOL_ERROR [0.71ms]
(pass) WINDOW_UPDATE
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
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/http2/h2-conformance.test.ts"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (26eb1c8)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [451.77ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [148.56ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [133.99ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [90.24ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 750ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m    Blocking�[0m waiting for file lock on build directory
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/runtime/api/bun/h2/connection.rs      |  89 +++++++++--
 test/js/node/http2/h2-conformance.test.ts | 242 ++++++++++++++++++++++++++++++
 2 files changed, 318 insertions(+), 13 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                       reads  edits  tests
src/runtime/api/bun/h2/connection.rs           6     13      0
test/js/node/http2/h2-conformance.test.ts      5      1      0
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
…2-compat-hardening

# Conflicts:
#	packages/bun-usockets/src/crypto/openssl.c
#	packages/bun-usockets/src/eventing/libuv.c
#	packages/bun-uws/src/HttpContext.h
#	packages/bun-uws/src/HttpParser.h
#	packages/bun-uws/src/HttpResponseData.h
#	src/dotenv/env_loader.rs
#	src/js/internal/tls.ts
#	src/js/node/_http_client.ts
#	src/js/node/_http_incoming.ts
#	src/js/node/_http_server.ts
#	src/js/node/http2.ts
#	src/js/node/net.ts
#	src/js/node/tls.ts
#	src/jsc/bindings/ErrorCode.ts
#	src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp
#	src/jsc/bindings/node/JSNodeHTTPServerSocket.h
#	src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
#	src/jsc/web_worker.rs
#	src/runtime/api/bun/h2/connection.rs
#	src/runtime/api/bun/h2_frame_parser.rs
#	src/runtime/server/NodeHTTPResponse.rs
#	src/runtime/server/ServerWebSocket.rs
#	src/runtime/server/WebSocketServerContext.rs
#	src/runtime/server/mod.rs
#	src/runtime/server/server_body.rs
#	src/uws_sys/App.rs
#	src/uws_sys/Response.rs
#	src/uws_sys/h3.rs
#	src/uws_sys/us_socket_t.rs
#	test/expectations.txt
#	test/js/bun/http/serve.test.ts
#	test/js/bun/net/socket-syscall-fault.test.ts
#	test/js/bun/websocket/websocket-server.test.ts
#	test/js/node/http/node-http-backpressure.test.ts
#	test/js/node/http/node-http-parser.test.ts
#	test/js/node/http/node-http-transfer-encoding.test.ts
#	test/js/node/http/node-http-uaf.test.ts
#	test/js/node/http2/h2-conformance.test.ts
#	test/js/node/http2/node-http2.test.js
#	test/js/node/net/node-net.test.ts
#	test/js/web/fetch/fetch-leak.test.ts
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The PR strengthens HTTP/1 and HTTP/2 validation, normalizes TLS authorization options, protects HTTP/2 stream lifetimes during reentrant execution, centralizes HTTP response failure handling, validates WebSocket upgrades, and adds shutdown cleanup with regression tests.

HTTP/1 validation and connection safety

Layer / File(s) Summary
Transfer-Encoding validation
packages/bun-uws/src/HttpParser.h, test/js/bun/http/request-smuggling.test.ts, test/js/node/http/node-http-transfer-encoding.test.ts
The parser rejects elements after chunked, including repeated, empty, trailing, and subsequent codings. Tests cover malformed encodings and incomplete chunked trailers.
Closed parser sweep handling
src/jsc/bindings/node/http/JSConnectionsList.cpp, test/js/node/http/node-http-parser.test.ts
idle() and expired() skip closed parsers without implementations. A subprocess test verifies safe sweeps and preserved all() results.

TLS authorization normalization

Layer / File(s) Summary
rejectUnauthorized normalization
src/js/internal/tls.ts, src/js/node/tls.ts, src/js/node/net.ts, test/js/node/tls/node-tls-cert.test.ts
TLS treats values other than explicit false as authorization enabled. Client and server tests cover null and 0 values.

HTTP/2 validation and stream safety

Layer / File(s) Summary
HTTP/2 header validation
src/runtime/api/bun/h2/connection.rs, test/js/node/http2/node-http2.test.js, test/js/node/test/common/index.js, test/js/node/test/sequential/test-http2-ping-flood.js
Validation covers request and response pseudo-headers, CONNECT forms, paths, schemes, hosts, trailers, response status, malformed PUSH_PROMISE blocks, HTTP/1 fallback status lines, and ping flooding.
Reentrant stream and payload handling
src/runtime/api/bun/h2_frame_parser.rs, test/js/node/http2/node-http2.test.js
Stream reclamation waits for quiescent dispatch and engine borrows. write_stream pins payloads across reentrant sends. Tests cover buffer integrity and synchronous stream closure.

HTTP response and upgrade lifecycle

Layer / File(s) Summary
Failed response termination
src/runtime/server/NodeHTTPResponse.rs, src/runtime/server/mod.rs, test/js/node/http/node-http.test.ts
Failures before output produce a 500 response. Failures after output force termination without adding framing bytes.
WebSocket upgrade eligibility
src/runtime/server/NodeHTTPResponse.rs, src/runtime/server/server_body.rs, test/js/node/http/node-http-with-ws.test.ts
Upgrade checks require valid response state, server socket state, and WebSocket configuration before and after reentrant option access.

FileSink shutdown cleanup

Layer / File(s) Summary
Finalize shutdown cleanup
src/runtime/webcore/FileSink.rs
FileSink::finalize releases the keep-alive reference and clears pending flush-task bookkeeping during shutdown.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#35295 — Both changes extend HttpParser.h transfer-encoding validation and request-smuggling coverage.
  • oven-sh/bun#36343 — Both changes strengthen HTTP/2 response header and :status validation.
  • oven-sh/bun#36389 — Both changes modify HTTP/2 frame-parser error and stream-cleanup handling.

Suggested reviewers: jarred-sumner, robobun

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main HTTP/2 hardening, failed-handler handling, and parser validation changes.
Description check ✅ Passed The description explains the changes and verification results in detail, despite using headings that differ from the repository template.
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.

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

robobun added 2 commits August 3, 2026 22:26
- patches/mimalloc/strnlen-oob-read.patch no longer applies: the pinned
  mimalloc commit (d078ad06) already has the bounds-first strnlen fix.
  Main dropped this patch in #34335; the merge into this branch
  re-introduced it.
- WebSocketServerContext.rs Handler struct had two 'server' fields
  (E0124) after the merge; keep main's pub(crate) one.

@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: 13

🤖 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/tls.ts`:
- Line 13: Use normalizeRejectUnauthorized as the sole normalization path in the
surrounding TLS options handling: replace the earlier inline conversion with
this shared helper, ensuring null, 0, and empty-string values are handled
consistently, then remove the duplicate normalization block later in the same
flow.

In `@src/runtime/api/bun/h2_frame_parser.rs`:
- Around line 7979-7990: Update the
StringOrBuffer::from_js_with_encoding_maybe_async call in the send_data
conversion path to use explicit argument labels/comments for the fourth is_async
flag and fifth allow_string_object flag, preserving their current true values.
Leave ThreadSafe::adopt unchanged because it already takes ownership of the
existing protection and releases it exactly once.

In `@src/runtime/api/bun/h2/connection.rs`:
- Around line 1180-1185: Restrict the `b"host"` validation arm to `is_request`
only by removing the `self.is_server` disjunct. Preserve the existing
empty-value and duplicate-Host checks for request blocks, while allowing trailer
sections to bypass this validation.

In `@src/runtime/server/NodeHTTPResponse.rs`:
- Around line 511-520: Strengthen NodeHTTPResponse::can_upgrade() to require
raw_response and reject both is_http_status_called() and is_http_write_called()
before permitting an upgrade. In src/runtime/server/server_body.rs lines
1904-1912, rely on this shared predicate before writing the 101 response or
custom headers, with no separate predicate change required there. In
test/js/node/http/node-http-with-ws.test.ts lines 63-98, add a header getter
that calls res.writeHead(200) and assert exactly one valid 200 response with no
upgrade headers.

In `@src/runtime/webcore/FileSink.rs`:
- Around line 933-960: Remove the duplicated shutdown cleanup block in the
relevant finalization method, keeping a single execution of the
vm.is_shutting_down() cleanup that calls clear_keep_alive_ref and conditionally
balances run_pending_later. Preserve the existing flag checks, pointer lifetime
guarantees, and deref behavior; do not retain a second no-op pass.

In `@test/js/bun/http/request-smuggling.test.ts`:
- Around line 99-116: Update the maliciousRequest fixture to include a
Connection: close header, ensuring the unfixed parser closes the socket and the
existing response assertion fails with a non-400 response instead of waiting for
the client close event.

In `@test/js/node/http/node-http-parser.test.ts`:
- Around line 280-283: Update the test around the closed parser fixture to
assert that p remains present in the active-list result after p.close(), before
invoking list.expired(1, 1). Keep the existing idle, expired, and all membership
assertions, ensuring the test exercises expired() scanning activeConnections().

In `@test/js/node/http/node-http-with-ws.test.ts`:
- Around line 63-98: Add a regression test alongside the existing plain-request
upgrade test where the upgrade options’ headers getter invokes
res.writeHead(200) before returning the supplied headers. Assert that
server.upgrade returns false, the response contains exactly one HTTP/1.1 200
response, and the upgrade header is absent, while preserving the existing
fallback response validation and cleanup.

In `@test/js/node/http2/node-http2.test.js`:
- Around line 2937-2974: Extract the duplicated literal helper and the
byte-identical exchange frame-reader logic into module-level shared helpers near
the existing http2utils usage. Replace all five local literal definitions and
both exchange implementations with calls to those shared helpers, preserving
their current framing, completion, error, and socket-cleanup behavior.
- Around line 3520-3580: Strengthen the reentrant-close test by tracking
observable execution of the queued write callback and PING handling. Add flags
in the test around the req.write callback and the client’s PING event, include
both values in the final output, and parse/assert that each is true alongside
the existing success and exit-code checks.
- Around line 3514-3517: Update the child-process result handling around the
JSON.parse call to retain stderr, assert that stdout contains output before
parsing, and only parse after that assertion. Ensure failures expose the drained
stderr alongside the failed child-process result, while preserving the existing
result and exitCode assertions for successful execution.

In `@test/js/node/test/common/index.js`:
- Around line 1433-1464: Remove the duplicate build.module registrations for
internal/http, internal/streams/state, and internal/options in the test setup.
Keep the original registrations and their existing behavior unchanged, deleting
only the redundant second definitions.

In `@test/js/node/tls/node-tls-cert.test.ts`:
- Around line 553-717: Add coverage for rejectUnauthorized set to an empty
string in both TLS test paths: extend the tls.connect verification test
alongside the existing null/0 cases, and extend the requestCert tls.createServer
rejection test alongside the null/0 cases. Assert that empty-string values keep
verification enabled and untrusted clients remain rejected, matching the
existing expected errors and connection outcomes.
🪄 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: 7e0e5695-2586-4249-8567-e671a3f4a05c

📥 Commits

Reviewing files that changed from the base of the PR and between 1f447a7 and 2256eed.

📒 Files selected for processing (20)
  • packages/bun-uws/src/HttpParser.h
  • src/js/internal/tls.ts
  • src/js/node/net.ts
  • src/js/node/tls.ts
  • src/jsc/bindings/node/http/JSConnectionsList.cpp
  • src/runtime/api/bun/h2/connection.rs
  • src/runtime/api/bun/h2_frame_parser.rs
  • src/runtime/server/NodeHTTPResponse.rs
  • src/runtime/server/mod.rs
  • src/runtime/server/server_body.rs
  • src/runtime/webcore/FileSink.rs
  • test/js/bun/http/request-smuggling.test.ts
  • test/js/node/http/node-http-parser.test.ts
  • test/js/node/http/node-http-transfer-encoding.test.ts
  • test/js/node/http/node-http-with-ws.test.ts
  • test/js/node/http/node-http.test.ts
  • test/js/node/http2/node-http2.test.js
  • test/js/node/test/common/index.js
  • test/js/node/test/sequential/test-http2-ping-flood.js
  • test/js/node/tls/node-tls-cert.test.ts

Comment thread src/js/node/tls.ts
tlsStringToProtocolVersion,
secureProtocolToVersionRange,
processPfxOptions,
normalizeRejectUnauthorized,

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use normalizeRejectUnauthorized as the only normalizer.

Line 576 already converts every defined non-boolean value to true. Therefore, Lines 611-614 do not normalize null, 0, or "" in this function.

Replace the earlier conversion with the shared helper. Then remove the later duplicate block.

Proposed refactor
   const rejectUnauthorized = options.rejectUnauthorized;
-  if (rejectUnauthorized !== undefined && typeof rejectUnauthorized !== "boolean") {
-    options = { ...options, rejectUnauthorized: true };
+  if (rejectUnauthorized !== undefined) {
+    options = {
+      ...options,
+      rejectUnauthorized: normalizeRejectUnauthorized(rejectUnauthorized),
+    };
   }
...
-  const rejectUnauthorized = options.rejectUnauthorized;
-  if (rejectUnauthorized !== undefined && typeof rejectUnauthorized !== "boolean") {
-    options = { ...options, rejectUnauthorized: normalizeRejectUnauthorized(rejectUnauthorized) };
-  }

As per coding guidelines, “Prefer the simplest honest shape” and use “named helpers for repeated blocks.”

Also applies to: 608-614

🤖 Prompt for 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.

In `@src/js/node/tls.ts` at line 13, Use normalizeRejectUnauthorized as the sole
normalization path in the surrounding TLS options handling: replace the earlier
inline conversion with this shared helper, ensuring null, 0, and empty-string
values are handled consistently, then remove the duplicate normalization block
later in the same flow.

Source: Coding guidelines

Comment on lines +7979 to +7990
// send_data can re-enter JS mid-payload (batch flushes, prior writes' callbacks): pin
// + protect ArrayBuffer payloads so they can't be detached under the borrowed slice.
// Strings are immutable (zero-copy path); ThreadSafe's Drop releases the pin/protect.
let pin_payload = data_arg.is_cell() && data_arg.js_type().is_array_buffer_like();
let buffer = match StringOrBuffer::from_js_with_encoding_maybe_async(
global_object,
data_arg,
encoding,
pin_payload,
true,
)? {
Some(b) => bun_jsc::ThreadSafe::adopt(b),

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.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Resolve StringOrBuffer::from_js_with_encoding_maybe_async and ThreadSafe::adopt signatures and Drop.
set -euo pipefail

echo "== from_js_with_encoding_maybe_async =="
rg -n -C 20 'fn from_js_with_encoding_maybe_async' src

echo "== ThreadSafe definition, adopt, Drop =="
rg -n -C 10 'pub struct ThreadSafe|impl .*ThreadSafe|fn adopt' src/jsc src/bun_jsc 2>/dev/null || rg -n -C 10 'ThreadSafe' src

echo "== other call sites for comparison =="
rg -n -C 3 'from_js_with_encoding_maybe_async\(' src

Repository: oven-sh/bun

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Definitions =="
rg -n -C 18 --glob '*.rs' 'from_js_with_encoding_maybe_async|pub struct ThreadSafe|impl<.*ThreadSafe|impl ThreadSafe|fn adopt|impl.*Unprotect|fn unprotect' src/runtime src | head -n 1200

echo "== Target call sites =="
rg -n -C 8 --glob '*.rs' 'from_js_with_encoding_maybe_async\(' src/runtime/api/bun/h2_frame_parser.rs src/runtime/node src/runtime/api/BunObject.rs

echo "== ThreadSafe::adopt call sites =="
rg -n -C 8 --glob '*.rs' 'ThreadSafe::adopt\(' src/runtime src | head -n 800

Repository: oven-sh/bun

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Target implementation and call =="
sed -n '7750,8010p' src/runtime/api/bun/h2_frame_parser.rs | cat -n
sed -n '285,325p' src/runtime/node/types.rs | cat -n
sed -n '495,580p' src/runtime/node/types.rs | cat -n

echo "== ThreadSafe definition and impl =="
rg -n --glob '*.rs' --glob '*.zig' --glob '*.ziggy' \
  'pub struct ThreadSafe|struct ThreadSafe|impl[^\\n]*ThreadSafe|pub fn adopt|fn adopt|trait Unprotect' . \
  | rg 'ThreadSafe|Unprotect' | head -n 300

echo "== Relevant ThreadSafe call sites =="
rg -n -C 12 --glob '*.rs' 'bun_jsc::ThreadSafe::adopt\(' src/runtime/api/bun/h2_frame_parser.rs src/runtime/api/JSTranspiler.rs src/runtime/api/BunObject.rs src/runtime/crypto/PBKDF2.rs src/runtime

Repository: oven-sh/bun

Length of output: 35862


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,110p' src/jsc/node_path.rs | cat -n
rg -n -C 8 --glob '*.rs' 'ThreadSafe<' src/jsc src/runtime | head -n 500
rg -n -C 12 --glob '*.rs' 'impl.*Unprotect for StringOrBuffer|from_js_maybe_async_into' src/runtime/node/types.rs

Repository: oven-sh/bun

Length of output: 35568


Label both conversion flags

The fourth argument is is_async; the fifth is allow_string_object. Keep these labels visible at the call site. ThreadSafe::adopt safely takes ownership of the existing protection and releases it once on drop.

🤖 Prompt for 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.

In `@src/runtime/api/bun/h2_frame_parser.rs` around lines 7979 - 7990, Update the
StringOrBuffer::from_js_with_encoding_maybe_async call in the send_data
conversion path to use explicit argument labels/comments for the fourth is_async
flag and fifth allow_string_object flag, preserving their current true values.
Leave ThreadSafe::adopt unchanged because it already takes ownership of the
existing protection and releases it exactly once.

Comment on lines +1180 to +1185
b"host" if self.is_server || is_request => {
if value_b.is_empty() || saw_host {
malformed = true;
}
saw_host = true;
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Restrict the Host validation to request blocks.

The guard is self.is_server || is_request. On a server, is_request is self.is_server && is_new, so it is false for a trailer section. The self.is_server disjunct therefore also applies the :authority-style Host rules to inbound request trailers.

nghttp2 routes a trailer block through http_trailer_on_header, which has no host case. A trailer carrying host: "" (or a repeated host) is delivered by node, but this code marks the block malformed and answers RST_STREAM(PROTOCOL_ERROR).

The comment above the arm states the intent as "in request blocks Host is checked like :authority". is_request already covers both inbound HEADERS request blocks and client-received PUSH_PROMISE blocks, so the extra disjunct is not needed.

🐛 Proposed fix to scope the check to request blocks
-                                b"host" if self.is_server || is_request => {
+                                b"host" if is_request => {
                                     if value_b.is_empty() || saw_host {
                                         malformed = true;
                                     }
                                     saw_host = true;
                                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
b"host" if self.is_server || is_request => {
if value_b.is_empty() || saw_host {
malformed = true;
}
saw_host = true;
}
b"host" if is_request => {
if value_b.is_empty() || saw_host {
malformed = true;
}
saw_host = true;
}
🤖 Prompt for 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.

In `@src/runtime/api/bun/h2/connection.rs` around lines 1180 - 1185, Restrict the
`b"host"` validation arm to `is_request` only by removing the `self.is_server`
disjunct. Preserve the existing empty-value and duplicate-Host checks for
request blocks, while allowing trailer sections to bypass this validation.

Comment on lines +511 to +520
/// Every precondition under which [`Self::upgrade`] refuses. Callers that must not commit
/// the one-shot 101 preamble to the socket for an upgrade that will fail check this first;
/// `upgrade()` itself starts with it, so the two can never drift.
pub(crate) fn can_upgrade(&self) -> bool {
// `AnyServer` is a `Copy` type-erased pointer to the long-lived server, not `*self`.
let mut server = self.server;
!self.upgrade_context.get().context.is_null()
&& server.web_socket_handler().is_some()
&& !self.get_server_socket_value().is_empty()
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject WebSocket upgrades after an HTTP response starts.

NodeHTTPResponse::can_upgrade() does not reject committed status or body output. A getter can call res.writeHead() and leave the upgrade context valid. The later upgrade path can then append upgrade headers to a normal response.

  • src/runtime/server/NodeHTTPResponse.rs#L511-L520: require raw_response and reject is_http_status_called() and is_http_write_called() in can_upgrade().
  • src/runtime/server/server_body.rs#L1904-L1912: rely on the strengthened shared predicate before writing 101 or custom headers.
  • test/js/node/http/node-http-with-ws.test.ts#L63-L98: add a getter that calls res.writeHead(200) before returning headers, then assert one valid 200 response and no upgrade headers.

As per coding guidelines, “Assume any operation that can run user JavaScript can synchronously free state; ... revalidate liveness after callbacks.”

📍 Affects 3 files
  • src/runtime/server/NodeHTTPResponse.rs#L511-L520 (this comment)
  • src/runtime/server/server_body.rs#L1904-L1912
  • test/js/node/http/node-http-with-ws.test.ts#L63-L98
🤖 Prompt for 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.

In `@src/runtime/server/NodeHTTPResponse.rs` around lines 511 - 520, Strengthen
NodeHTTPResponse::can_upgrade() to require raw_response and reject both
is_http_status_called() and is_http_write_called() before permitting an upgrade.
In src/runtime/server/server_body.rs lines 1904-1912, rely on this shared
predicate before writing the 101 response or custom headers, with no separate
predicate change required there. In test/js/node/http/node-http-with-ws.test.ts
lines 63-98, add a header getter that calls res.writeHead(200) and assert
exactly one valid 200 response with no upgrade headers.

Source: Coding guidelines

Comment on lines +933 to +960
// Under `is_shutting_down` the loop stops ticking: onWrite/onClose/EOF and queued
// FlushPendingFileSinkTask never arrive to balance these refs, so release them here
// (else e.g. a piped stdout with a `.pending` write strands its keep-alive forever).
if let Some(vm) = self.js_vm() {
if vm.is_shutting_down() {
let this = std::ptr::from_mut::<Self>(self);
// SAFETY: `this` is the canonical allocation pointer (finalize
// receives the wrapper's `m_ctx`); the wrapper's +1 is still
// held until the trailing `deref` below, so neither release
// can free `this` mid-body. `clear_keep_alive_ref` is
// flag-gated, so a (theoretical) late `onClose` is a no-op.
// SAFETY: `this` is the canonical alloc (finalize's `m_ctx`); the wrapper's +1
// is held until the trailing `deref` so neither release frees `this` mid-body.
// `clear_keep_alive_ref` is flag-gated, so a late `onClose` is a no-op.
unsafe { FileSink::clear_keep_alive_ref(this) };
if self.run_pending_later.has.get() {
self.run_pending_later.has.set(false);
// SAFETY: as above; balances the `ref_()` taken in
// `run_pending_later()` for a task that will never run.
unsafe { FileSink::deref(this) };
}
}
}

// Under `is_shutting_down` the loop stops ticking: onWrite/onClose/EOF and queued
// FlushPendingFileSinkTask never arrive to balance these refs, so release them here
// (else e.g. a piped stdout with a `.pending` write strands its keep-alive forever).
if let Some(vm) = self.js_vm() {
if vm.is_shutting_down() {
let this = std::ptr::from_mut::<Self>(self);
// SAFETY: `this` is the canonical alloc (finalize's `m_ctx`); the wrapper's +1
// is held until the trailing `deref` so neither release frees `this` mid-body.
// `clear_keep_alive_ref` is flag-gated, so a late `onClose` is a no-op.

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the duplicated shutdown cleanup pass.

The cleanup in Lines 933-950 is repeated again from Line 952. The flag checks make the second pass a no-op today, so it does not currently double-release the references. Keep one pass, or extract one helper and call it once. This keeps the refcount cleanup auditable and prevents future edits from diverging.

As per coding guidelines: “Prefer the simplest honest shape ... and named helpers for repeated blocks.”

Proposed fix
-        // Remove the second identical `is_shutting_down` cleanup block.
🤖 Prompt for 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.

In `@src/runtime/webcore/FileSink.rs` around lines 933 - 960, Remove the
duplicated shutdown cleanup block in the relevant finalization method, keeping a
single execution of the vm.is_shutting_down() cleanup that calls
clear_keep_alive_ref and conditionally balances run_pending_later. Preserve the
existing flag checks, pointer lifetime guarantees, and deref behavior; do not
retain a second no-op pass.

Source: Coding guidelines

Comment on lines +2937 to +2974
async function exchange(headerBlock) {
const frames = [];
const { promise: exchanged, resolve: onExchanged, reject: onSocketError } = Promise.withResolvers();
const socket = net.connect(port, "127.0.0.1", () => {
socket.write(http2utils.kClientMagic);
socket.write(new http2utils.SettingsFrame(false).data);
socket.write(new http2utils.HeadersFrame(1, headerBlock, 0, true, true).data);
socket.write(new http2utils.PingFrame(false).data);
});
socket.on("error", onSocketError);
let received = Buffer.alloc(0);
socket.on("data", chunk => {
received = Buffer.concat([received, chunk]);
while (received.length >= 9) {
const length = received.readUIntBE(0, 3);
if (received.length < 9 + length) break;
const frame = {
type: received[3],
flags: received[4],
streamId: received.readUInt32BE(5) & 0x7fffffff,
payload: Buffer.from(received.subarray(9, 9 + length)),
};
received = received.subarray(9 + length);
frames.push(frame);
if ((frame.type === 6 && (frame.flags & 1) !== 0) || frame.type === 7) {
onExchanged();
return;
}
}
});
socket.on("close", () => onExchanged());
try {
await exchanged;
} finally {
socket.destroy();
}
return frames;
}

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared literal and exchange helpers.

literal is now defined four times in this file (Lines 2703, 2897, 3000, 3074, 3148) and the 9-byte frame reader in exchange is duplicated at Lines 2804-2844 and 2937-2974. The two exchange bodies are byte-identical.

Move both into module-level helpers next to the other http2utils usage. A single frame reader keeps every new protocol test on the same parsing behavior.

🤖 Prompt for 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.

In `@test/js/node/http2/node-http2.test.js` around lines 2937 - 2974, Extract the
duplicated literal helper and the byte-identical exchange frame-reader logic
into module-level shared helpers near the existing http2utils usage. Replace all
five local literal definitions and both exchange implementations with calls to
those shared helpers, preserving their current framing, completion, error, and
socket-cleanup behavior.

Comment on lines +3514 to +3517
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const result = JSON.parse(stdout.trim().split("\n").at(-1));
expect(result).toEqual({ transferResult: "threw", dataBytes: 200 * 1024, corrupt: 0 });
expect(exitCode).toBe(0);

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Guard the JSON parse so a child failure reports the child output.

If the child never reaches sawEndStream, stdout is empty and JSON.parse("") throws SyntaxError: Unexpected end of JSON input. The exitCode assertion below never runs, and the drained stderr is discarded, so the real cause stays hidden.

Assert that the child produced output before parsing it, and keep stderr available in the failure.

♻️ Proposed change to surface the child output
-  const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
-  const result = JSON.parse(stdout.trim().split("\n").at(-1));
-  expect(result).toEqual({ transferResult: "threw", dataBytes: 200 * 1024, corrupt: 0 });
-  expect(exitCode).toBe(0);
+  const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+  expect({ stdout: stdout.trim(), stderr }).toMatchObject({ stdout: expect.stringContaining("transferResult") });
+  const result = JSON.parse(stdout.trim().split("\n").at(-1));
+  expect(result).toEqual({ transferResult: "threw", dataBytes: 200 * 1024, corrupt: 0 });
+  expect(exitCode).toBe(0);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const result = JSON.parse(stdout.trim().split("\n").at(-1));
expect(result).toEqual({ transferResult: "threw", dataBytes: 200 * 1024, corrupt: 0 });
expect(exitCode).toBe(0);
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout: stdout.trim(), stderr }).toMatchObject({ stdout: expect.stringContaining("transferResult") });
const result = JSON.parse(stdout.trim().split("\n").at(-1));
expect(result).toEqual({ transferResult: "threw", dataBytes: 200 * 1024, corrupt: 0 });
expect(exitCode).toBe(0);
🤖 Prompt for 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.

In `@test/js/node/http2/node-http2.test.js` around lines 3514 - 3517, Update the
child-process result handling around the JSON.parse call to retain stderr,
assert that stdout contains output before parsing, and only parse after that
assertion. Ensure failures expose the drained stderr alongside the failed
child-process result, while preserving the existing result and exitCode
assertions for successful execution.

Comment on lines +3520 to +3580
it("http2 client survives a synchronous parser read from a closing stream's write callback", async () => {
// stream.close() concludes queued writes' callbacks synchronously while native code still
// holds the stream. A callback that synchronously feeds inbound bytes back into the parser
// must not let the deferred stream free run under that live reference (use-after-free).
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const http2 = require("node:http2");
const { Duplex } = require("node:stream");

function frame(type, flags, streamId, payload = Buffer.alloc(0)) {
const header = Buffer.alloc(9);
header.writeUIntBE(payload.length, 0, 3);
header[3] = type;
header[4] = flags;
header.writeUInt32BE(streamId, 5);
return Buffer.concat([header, payload]);
}

const socket = new Duplex({
writableHighWaterMark: 4 * 1024 * 1024,
read() {},
write(chunk, encoding, callback) {
callback();
},
});

const client = http2.connect("http://localhost", { createConnection: () => socket });
client.on("error", () => {});
client.on("connect", () => {
socket.push(Buffer.concat([frame(4, 0, 0), frame(4, 1, 0)]));
});
client.once("remoteSettings", () => {
const req = client.request({ ":method": "POST", ":path": "/" });
req.on("error", () => {});
// 65535 bytes fit the flow-control window; the remainder is queued with this callback.
req.write(Buffer.alloc(65535 + 32768, "a"), () => {
// Concluded synchronously by the close path below: feed inbound bytes so the parser
// re-enters read() while the closing stream is still referenced natively.
socket.push(frame(6, 0, 0, Buffer.alloc(8)));
});
setImmediate(() => {
req.close(http2.constants.NGHTTP2_CANCEL);
setImmediate(() => {
console.log("REENTRANT_CLOSE_OK");
process.exit(0);
});
});
});
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toContain("REENTRANT_CLOSE_OK");
expect(exitCode).toBe(0);
});

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert that the reentrant path actually ran.

The test only asserts REENTRANT_CLOSE_OK and exit code 0. Both hold even when the intended sequence never happens: if the queued write callback is never concluded by req.close(), socket.push(frame(6, ...)) never runs, the parser is never re-entered from the callback, and the test still passes.

Emit an observable signal from inside the write callback and from the PING handling, then assert both. The test then fails when the reentrant path stops being exercised.

For example, set a flag in the req.write callback and include it in the printed line:

let reentered = false;
req.write(Buffer.alloc(65535 + 32768, "a"), () => {
  reentered = true;
  socket.push(frame(6, 0, 0, Buffer.alloc(8)));
});
client.on("ping", () => {
  pingSeen = true;
});
// ...
console.log(JSON.stringify({ ok: "REENTRANT_CLOSE_OK", reentered, pingSeen }));

Then assert reentered and pingSeen are true.

As per coding guidelines: "Tests must prove they fail for the intended reason and must exercise the actual production guards, constants, environment knobs, and preconditions." and "Every assertion must be able to fail and must assert the strongest meaningful invariant".

🤖 Prompt for 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.

In `@test/js/node/http2/node-http2.test.js` around lines 3520 - 3580, Strengthen
the reentrant-close test by tracking observable execution of the queued write
callback and PING handling. Add flags in the test around the req.write callback
and the client’s PING event, include both values in the final output, and
parse/assert that each is true alongside the existing success and exit-code
checks.

Source: Coding guidelines

Comment on lines +1433 to +1464
// node's internal/http: serve the very same symbols Bun's _http_outgoing
// attaches to OutgoingMessage instances, so tests poke at real state.
build.module("internal/http", () => {
const { kOutHeaders, kHighWaterMark } = require("node:_http_outgoing");
return {
loader: "object",
exports: { kOutHeaders, kHighWaterMark },
};
});
// node's internal/streams/state: getDefaultHighWaterMark is also part of
// the public node:stream API, so reuse that (same function in Bun).
build.module("internal/streams/state", () => ({
loader: "object",
exports: { getDefaultHighWaterMark: require("node:stream").getDefaultHighWaterMark },
}));
// node's internal/options: map the few CLI options vendored http tests ask
// about onto the equivalent runtime values. Unknown options return undefined.
build.module("internal/options", () => ({
loader: "object",
exports: {
getOptionValue(name) {
switch (name) {
case "--max-http-header-size":
return require("node:http").maxHeaderSize;
case "--insecure-http-parser":
return false;
default:
return undefined;
}
},
},
}));

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Confirm duplicate build.module specifiers in the shim and inspect duplicate-registration handling.
set -euo pipefail

file=$(fd -p 'test/js/node/test/common/index.js' | head -n1)
echo "== duplicate specifiers in $file =="
rg -n 'build\.module\(' "$file" | awk -F'"' '{print $2}' | sort | uniq -c | sort -rn

echo "== build.module registration / duplicate handling =="
rg -n -C 6 'build\.module|onLoadPlugins|module\(' --glob '*.ts' --glob '*.zig' --glob '*.rs' --glob '*.cpp' src | rg -n -C 6 -i 'duplicate|already|virtual module|module\(' || true

Repository: oven-sh/bun

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd -p 'test/js/node/test/common/index.js' | head -n1)"
echo "== relevant setup block =="
sed -n '1360,1485p' "$file"

echo "== all build.module calls in the file =="
rg -n -C 2 'build\.module\(' "$file"

echo "== implementation and call sites for installBunExposeInternalsShim =="
rg -n -C 12 'installBunExposeInternalsShim|build\.module' --glob '*.js' --glob '*.ts' --glob '*.tsx' --glob '*.cpp' --glob '*.h' --glob '*.hpp' --glob '*.zig' --glob '*.rs' . \
  | head -n 500

Repository: oven-sh/bun

Length of output: 24900


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== build.module API implementation references =="
rg -n -C 8 'build_module|BuildModule|module_callback|module\(' src packages test --glob '*.rs' --glob '*.cpp' --glob '*.h' --glob '*.hpp' --glob '*.zig' --glob '*.ts' --glob '*.js' \
  | rg -m 300 -C 4 -i 'plugin|module|duplicate|specifier|virtual'

echo "== plugin registration internals =="
rg -n -C 10 'plugin_runner|PluginRunner|virtual module|module namespace|module specifier|register.*module|onResolve|onLoad' src --glob '*.rs' --glob '*.cpp' --glob '*.h' --glob '*.hpp' \
  | head -n 600

Repository: oven-sh/bun

Length of output: 50370


🌐 Web query:

Bun plugin build.module duplicate specifier behavior implementation

💡 Result:

In Bun's plugin API, the build.module(specifier, callback) method is used to register a lazy-loaded virtual module [1][2]. Regarding duplicate specifiers, the implementation is as follows: 1. Restriction on Built-ins: You cannot use build.module() to override built-in modules (e.g., "buffer") [3]. Attempting to do so will result in an error [3]. 2. Virtual Module Tracking: When using build.module(), Bun maintains a map of virtual modules [4]. If you attempt to register a duplicate specifier, the underlying implementation typically relies on this registry, which tracks added, retrieved, and existing virtual modules [4]. 3. Plugin Lifecycle and Overriding: While build.module() is a convenience method for virtual modules, standard resolution is handled via onResolve and onLoad [5][6]. If multiple plugins or multiple calls to build.module() attempt to define the same specifier, the behavior is governed by the order of plugin execution and how the onResolve and onLoad hooks are implemented [6]. If a module is already registered in the virtual module registry, subsequent attempts to override it via build.module() may be rejected or may conflict depending on the specific version of Bun [4]. 4. Known Issues: Users have reported instances where Bun's bundler incorrectly handles module imports, leading to duplicated outputs [7]. However, this is distinct from the behavior of the build.module() specifier registration itself, which is designed to provide a single, unique virtual entry point for a given specifier [1][4]. To ensure predictable behavior, it is recommended to avoid defining the same specifier in multiple plugins unless explicitly intended, and to rely on onResolve to control which plugin ultimately claims the resolution of a specific module [5][8]. If you require complex resolution logic, using onResolve and onLoad provides more control over the resolution process than the simplified build.module() interface [5][6].

Citations:


Remove the duplicate build.module registrations. The second registrations of internal/http, internal/streams/state, and internal/options are redundant and can cause registration conflicts.

🤖 Prompt for 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.

In `@test/js/node/test/common/index.js` around lines 1433 - 1464, Remove the
duplicate build.module registrations for internal/http, internal/streams/state,
and internal/options in the test setup. Keep the original registrations and
their existing behavior unchanged, deleting only the redundant second
definitions.

Comment on lines +553 to +717
it("tls.connect with rejectUnauthorized: null still rejects untrusted certificates", async () => {
// Node normalizes rejectUnauthorized with `!== false`: only an explicit `false` disables
// verification. `null` (a common config-file "use the default") must keep it enforced.
const { promise, resolve, reject } = Promise.withResolvers();
let server: Server | null = null;
let socket: TLSSocket | null = null;

try {
server = tls
.createServer({
key: readFileSync(join(import.meta.dir, "..", "http", "fixtures", "openssl.key")),
cert: readFileSync(join(import.meta.dir, "..", "http", "fixtures", "openssl.crt")),
passphrase: "123123123",
})
.on("error", reject)
.listen(0, () => {
const address = server?.address() as AddressInfo;
socket = tls
.connect({ port: address.port, rejectUnauthorized: null as unknown as boolean }, () => {
reject(new Error("secureConnect must not fire when verification failed"));
})
.on("error", resolve);
});

const err = await promise;
expect(err.code).toBe("UNABLE_TO_VERIFY_LEAF_SIGNATURE");
} finally {
//@ts-ignore
socket?.end();
server?.close();
}
});

it("tls.connect with rejectUnauthorized: 0 keeps verification on like node", async () => {
// Node's normalization is `!== false`, so falsy non-`false` values (0, "") keep
// verification enforced; they must not blow up in the native strict-boolean conversion.
const { promise, resolve, reject } = Promise.withResolvers();
let server: Server | null = null;
let socket: TLSSocket | null = null;

try {
server = tls
.createServer({
key: readFileSync(join(import.meta.dir, "..", "http", "fixtures", "openssl.key")),
cert: readFileSync(join(import.meta.dir, "..", "http", "fixtures", "openssl.crt")),
passphrase: "123123123",
})
.on("error", reject)
.listen(0, () => {
const address = server?.address() as AddressInfo;
socket = tls
.connect({ port: address.port, rejectUnauthorized: 0 as unknown as boolean }, () => {
reject(new Error("secureConnect must not fire when verification failed"));
})
.on("error", resolve);
});

const err = await promise;
expect(err.code).toBe("UNABLE_TO_VERIFY_LEAF_SIGNATURE");
} finally {
//@ts-ignore
socket?.end();
server?.close();
}
});

it("tls.createServer with rejectUnauthorized: 0 still rejects a client with an untrusted certificate", async () => {
// The server-side gate runs in JS after the handshake (the native verify callback always
// defers to JS), so the raw option value must be normalized like Node (`!== false`): a
// falsy non-`false` value must not admit a client whose certificate failed verification.
let server: Server | null = null;
let socket: TLSSocket | null = null;
const secureConnections: string[] = [];
const clientErrors = Promise.withResolvers<Error>();
const clientClosed = Promise.withResolvers<string>();
const serverTLS = {
key: readFileSync(join(import.meta.dir, "..", "http", "fixtures", "openssl.key")),
cert: readFileSync(join(import.meta.dir, "..", "http", "fixtures", "openssl.crt")),
passphrase: "123123123",
};

try {
server = tls
.createServer(
{
...serverTLS,
requestCert: true,
rejectUnauthorized: 0 as unknown as boolean,
},
s => {
secureConnections.push(s.authorizationError as string);
s.write("admitted");
},
)
.on("tlsClientError", clientErrors.resolve)
.listen(0, () => {
const address = server?.address() as AddressInfo;
// The client presents a certificate the server cannot verify (self-signed, no CA).
socket = tls.connect({ port: address.port, rejectUnauthorized: false, ...serverTLS });
let received = "";
socket.on("data", chunk => (received += chunk));
socket.on("error", () => {});
socket.on("close", () => clientClosed.resolve(received));
});

const [, received] = await Promise.all([clientErrors.promise, clientClosed.promise]);
// The unverified client is rejected: no 'secureConnection', no bytes served.
expect({ secureConnections, received }).toEqual({ secureConnections: [], received: "" });
} finally {
//@ts-ignore
socket?.end();
server?.close();
}
});

it("tls.createServer with rejectUnauthorized: null still rejects unauthorized clients", async () => {
// Server side of the same normalization: with requestCert, a client that fails
// verification must be destroyed unless rejectUnauthorized is explicitly `false`
// (Node's `!== false`), so `null` must never silently admit an unverified peer.
let server: Server | null = null;
let socket: TLSSocket | null = null;
const secureConnections: string[] = [];
const clientErrors = Promise.withResolvers<Error>();
const clientClosed = Promise.withResolvers<string>();

try {
server = tls
.createServer(
{
key: readFileSync(join(import.meta.dir, "..", "http", "fixtures", "openssl.key")),
cert: readFileSync(join(import.meta.dir, "..", "http", "fixtures", "openssl.crt")),
passphrase: "123123123",
requestCert: true,
rejectUnauthorized: null as unknown as boolean,
},
s => {
secureConnections.push(s.authorizationError as string);
s.write("admitted");
},
)
.on("tlsClientError", clientErrors.resolve)
.listen(0, () => {
const address = server?.address() as AddressInfo;
// The client presents no certificate, so the server's verification fails.
socket = tls.connect({ port: address.port, rejectUnauthorized: false });
let received = "";
socket.on("data", chunk => (received += chunk));
socket.on("error", () => {});
socket.on("close", () => clientClosed.resolve(received));
});

const [err, received] = await Promise.all([clientErrors.promise, clientClosed.promise]);
// The unauthorized client is rejected: no 'secureConnection', no bytes served.
expect({ secureConnections, received, code: (err as NodeJS.ErrnoException).code }).toEqual({
secureConnections: [],
received: "",
code: "ERR_SSL_PEER_DID_NOT_RETURN_A_CERTIFICATE",
});
} finally {
//@ts-ignore
socket?.end();
server?.close();
}
});

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.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline test/js/node/tls/node-tls-cert.test.ts --items all
rg -n -C 5 'rejectUnauthorized:\s*(""|'\'''\''|0|null|undefined|false)' \
  test/js/node/tls/node-tls-cert.test.ts

Repository: oven-sh/bun

Length of output: 7500


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- rejectUnauthorized implementation references ---'
rg -n -C 6 'rejectUnauthorized' src/js/node/tls.ts src test/js/node/tls/node-tls-cert.test.ts 2>/dev/null | head -n 240

printf '%s\n' '--- surrounding existing TLS certificate tests ---'
sed -n '240,370p' test/js/node/tls/node-tls-cert.test.ts

Repository: oven-sh/bun

Length of output: 19842


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path("test/js/node/tls/node-tls-cert.test.ts")
text = p.read_text()
for i, line in enumerate(text.splitlines(), 1):
    if "rejectUnauthorized" in line:
        print(f"{i}:{line}")
PY

printf '%s\n' '--- empty-string rejectUnauthorized uses across tracked tests ---'
rg -n -S 'rejectUnauthorized\s*:\s*([\"'\"']{2})' --glob 'test/**' . || true

Repository: oven-sh/bun

Length of output: 1716


Add empty-string coverage for rejectUnauthorized on both TLS paths.

The implementation treats rejectUnauthorized: "" as verification enabled, but this file does not test it for tls.connect or a certificate-requesting tls.createServer.

🤖 Prompt for 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.

In `@test/js/node/tls/node-tls-cert.test.ts` around lines 553 - 717, Add coverage
for rejectUnauthorized set to an empty string in both TLS test paths: extend the
tls.connect verification test alongside the existing null/0 cases, and extend
the requestCert tls.createServer rejection test alongside the null/0 cases.
Assert that empty-string values keep verification enabled and untrusted clients
remain rejected, matching the existing expected errors and connection outcomes.

Source: Coding guidelines

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/runtime/server/server_body.rs (1)

1904-1912: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Run the post-getter guard for every option shape.

opts.fast_get(...) can re-enter JavaScript. The guard at Line 1907 runs only when headers_value is present and non-null.

If a getter ends or destroys the response and headers is absent, undefined, or null, the break 'getter path skips the guard. The function then calls NodeHTTPResponse::upgrade at Line 1928. In src/runtime/server/NodeHTTPResponse.rs Lines 514-622, upgrade() rechecks can_upgrade(), but can_upgrade() does not reject ENDED or SOCKET_CLOSED.

Add the same state and eligibility check after the complete 'getter block, before Line 1928. Keep the existing check before writing 101 Switching Protocols.

As per coding guidelines, revalidate state after every callback that can run JavaScript.

Proposed fix
                 if global.has_exception() {
                     return Err(JsError::Thrown);
                 }
             }
+            if node_http_response.flags.get().intersects(
+                NodeHTTPResponseFlags::ENDED | NodeHTTPResponseFlags::SOCKET_CLOSED,
+            ) || !node_http_response.can_upgrade()
+            {
+                return Ok(JSValue::FALSE);
+            }
             return Ok(JSValue::from(node_http_response.upgrade(
🤖 Prompt for 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.

In `@src/runtime/server/server_body.rs` around lines 1904 - 1912, Extend the
post-getter guard in the upgrade flow so it runs after the complete 'getter
block, covering absent, undefined, and null headers as well as present headers.
Add the same ENDED/SOCKET_CLOSED and can_upgrade() check immediately before
NodeHTTPResponse::upgrade, while preserving the existing guard before committing
the 101 response.

Source: Coding guidelines

src/runtime/webcore/FileSink.rs (1)

1581-1589: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Propagate inline termination failures. FileSink::end returns sys::Result<()>, but both EndedInline paths discard termination errors before returning JSValue::UNDEFINED. Map Err through the existing JS error path and preserve EndedInline(Some(err)) errors.

🤖 Prompt for 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.

In `@src/runtime/webcore/FileSink.rs` around lines 1581 - 1589, Update the
NativeWireResult::EndedInline handling to propagate failures from both
self.end_from_stream(Some(err)) and self.end(None) through the existing JS error
path instead of discarding their Results. Preserve the original Some(err) stream
error while returning JSValue::UNDEFINED only after successful termination.

Source: Coding guidelines

test/js/bun/http/request-smuggling.test.ts (1)

565-565: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject socket errors from rawPost.

Line 565 resolves the helper when client emits error. A peer can send a partial HTTP/1.1 400 response and then reset the socket. The test can then pass while hiding the transport failure. Destructure reject and attach it to the error event.

As per coding guidelines, “Tests must await observable conditions, wire every failure event to rejection.”

Proposed fix
-    const { promise, resolve } = Promise.withResolvers<{ status: string; raw: string }>();
+    const { promise, resolve, reject } = Promise.withResolvers<{ status: string; raw: string }>();
...
-    client.on("error", done);
+    client.on("error", reject);
🤖 Prompt for 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.

In `@test/js/bun/http/request-smuggling.test.ts` at line 565, Update the rawPost
helper’s Promise handling around the client error listener so it destructures
reject and routes client socket errors to rejection instead of resolving via
done. Preserve successful response resolution while ensuring every client error
event fails the awaited test.

Source: Coding guidelines

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

Outside diff comments:
In `@src/runtime/server/server_body.rs`:
- Around line 1904-1912: Extend the post-getter guard in the upgrade flow so it
runs after the complete 'getter block, covering absent, undefined, and null
headers as well as present headers. Add the same ENDED/SOCKET_CLOSED and
can_upgrade() check immediately before NodeHTTPResponse::upgrade, while
preserving the existing guard before committing the 101 response.

In `@src/runtime/webcore/FileSink.rs`:
- Around line 1581-1589: Update the NativeWireResult::EndedInline handling to
propagate failures from both self.end_from_stream(Some(err)) and self.end(None)
through the existing JS error path instead of discarding their Results. Preserve
the original Some(err) stream error while returning JSValue::UNDEFINED only
after successful termination.

In `@test/js/bun/http/request-smuggling.test.ts`:
- Line 565: Update the rawPost helper’s Promise handling around the client error
listener so it destructures reject and routes client socket errors to rejection
instead of resolving via done. Preserve successful response resolution while
ensuring every client error event fails the awaited test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fc0d31e6-af07-43e1-ac8d-95d44ab6a1e8

📥 Commits

Reviewing files that changed from the base of the PR and between 2256eed and 1057479.

📒 Files selected for processing (5)
  • packages/bun-uws/src/HttpParser.h
  • src/runtime/server/mod.rs
  • src/runtime/server/server_body.rs
  • src/runtime/webcore/FileSink.rs
  • test/js/bun/http/request-smuggling.test.ts

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants