Conversation
node:http creates its Bun.serve listener with idleTimeout: 0, leaving headersTimeout/requestTimeout enforcement to the checkConnections sweep. But HttpResponseData::idleTimeout is compiled in as 10 and onOpen calls resetTimeout() with that value before any request bytes arrive; the configured idleTimeout is only pushed once a request head is parsed (on_node_http_request / prepare_js_request_context). A connection that sends a partial head, or nothing at all, is reaped by the uWS 10s default (~12s wall clock given the 4s timer granularity) regardless of headersTimeout, so any headersTimeout above ~13s is silently truncated and the close is a bare FIN instead of the 408 the sweep would send. Store the configured idleTimeout on HttpContextData and seed each accepted socket from it in onOpen, so the same value governs the pre-request window that governs an in-flight request. node:http's 0 disables the uWS window entirely and the checkConnections sweep owns the deadline; Bun.serve's explicit idleTimeout now also applies from accept. The default (10) is unchanged, so Bun.serve without an idleTimeout option still reaps a stalled pre-request connection at ~10s.
|
Warning Review limit reached
Next review available in: 16 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (7)
Comment |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
Beyond the inline findings, I also checked: plain node:http (non-TLS) is still covered — onOpen fires the filter for !SSL, so the socket enters kTrackedConnections at accept and checkConnections reaps it; and Bun.serve without an idleTimeout option is unchanged (HttpContextData::idleTimeout defaults to 10, same as the old compiled-in per-socket default). The regression is scoped to the node:https pre-handshake window.
Extended reasoning...
The HTTPS pre-handshake finding is security-relevant (slowloris regression on the default node:https configuration), so this needs a human look. I confirmed the mechanism: onOpen<true> skips the filter for SSL (HttpContext.h if(!SSL)), the filter runs only in onHandshake, and checkConnections (_http_server.ts:437-447) iterates only kTrackedConnections, which is populated from the filter path. Plain HTTP and default Bun.serve were checked and are not affected.
| httpResponseData->idleTimeout = httpContextData->idleTimeout; | ||
| ((HttpResponse<SSL> *) s)->resetTimeout(); |
There was a problem hiding this comment.
🔴 For node:https, seeding idleTimeout=0 at accept removes the only timer that covered the TLS pre-handshake window. The filter that enrolls a socket in kTrackedConnections fires in onHandshake for SSL (not onOpen), so a client that TCP-connects and stalls the TLS handshake is never swept by checkConnections, has no uWS idle timer, and stays open forever — a slowloris regression vs. the previous ~12s reap. Consider firing the filter (or at least stamping lastMessageStartMs and tracking the socket) at TCP accept for SSL, or arming a native handshake timeout when the context's idleTimeout is 0.
Extended reasoning...
What the bug is
This PR seeds each accepted socket's HttpResponseData::idleTimeout from HttpContextData::idleTimeout in onOpen, and set_routes() populates the context value from config.idle_timeout. node:http/node:https (src/js/node/_http_server.ts:675) pass idleTimeout: 0 to Bun.serve, so after this PR the context value is 0 and onOpen calls resetTimeout() → us_socket_timeout(s, 0) — the uWS idle timer is disabled from TCP accept.
The PR's stated invariant is that node:http's own checkConnections sweep (headersTimeout/requestTimeout) covers the pre-request window instead. That holds for plain HTTP, but not for HTTPS: for SSL sockets the filter that enrolls the connection in the sweep fires only after the TLS handshake completes, so a stalled-handshake client is never swept and never times out.
Code path
https.createServer()→http.Serverwith atlsconfig →Bun.serve({ idleTimeout: 0, tls, ... })(_http_server.ts:675).set_routes()(mod.rs:2101-2108) callsapp.set_idle_timeout(0)→HttpContextData::idleTimeout = 0.- On TCP accept,
us_internal_ssl_on_opendispatchesHttpContext::onOpen<true>before the TLS handshake starts.onOpennow setshttpResponseData->idleTimeout = 0and callsresetTimeout()→us_socket_timeout(s, 0), which setss->timeout = 255(disabled). - For SSL,
onOpenskips the filter (if constexpr (!SSL)at HttpContext.h:224); the filter runs only inonHandshake(HttpContext.h:177-180), i.e. after the handshake completes. - The filter is what invokes
on_connection_callback(server_body.rs:3768-3770 explicitly notes "for TLS, when its handshake completes"), which is what constructsNodeHTTPServerSocketand adds it tokTrackedConnections(_http_server.ts:1580). checkConnections()(_http_server.ts:437-454) iterates onlythis[kTrackedConnections]; a pre-handshake socket is never in it.- There is no separate handshake timeout on this path:
_http_server.tsusesBun.servedirectly and does not wiretls.Server's_handshakeTimeout(that lives innet.ts'sinitAcceptedTLSSocket, a different code path).
Why existing code doesn't prevent it
Before this PR, onOpen armed the compiled-in HttpResponseData::idleTimeout = 10, so a stalled TLS handshake was reaped by uWS at ~12s wall-clock regardless of what the JS layer did. This PR replaces that 10 with the context's 0 for node:https, and the replacement mechanism (checkConnections) cannot see the socket until the handshake completes. TCP_DEFER_ACCEPT doesn't help: sending a single byte (a partial ClientHello) defeats it on Linux, and it doesn't exist on macOS/Windows. onOpen does stamp lastMessageStartMs on the native ext block, but nothing reads it — the sweep runs in JS over kTrackedConnections, which the socket never enters.
Step-by-step proof
- Start
https.createServer((req, res) => res.end()).listen(0)with default timeouts. net.connect(port)(raw TCP, no TLS) and write one byte, e.g."\x16", then go idle.onOpen<true>fires:idleTimeout ← 0,us_socket_timeout(s, 0)disables the uWS timer;if(!SSL)skips the filter.- usockets feeds the byte to OpenSSL;
SSL_do_handshakereturnsWANT_READ;onHandshakenever fires. checkConnectionsruns every 30s, iterateskTrackedConnections— this socket isn't in it.- No timer, no sweep, no handshake timeout → the fd stays open indefinitely. Repeat with N sockets for a slowloris fd exhaustion.
Before this PR, step 3 armed a 10s timer and the socket closed at ~12s.
Impact
Security-relevant DoS regression on the default node:https server configuration introduced by this PR. Users have no knob to opt back in — idleTimeout: 0 is hardcoded internally for node:http compat, and server.headersTimeout/requestTimeout cannot reach pre-handshake sockets. Per REVIEW.md's security guidance ("security checks fail closed and cover every path to the protected effect"), removing the reap without a replacement covering the SSL pre-handshake path should block.
Fix
Either (a) fire the filter (+1) for SSL in onOpen too so NodeHTTPServerSocket is created and tracked from TCP accept (Node itself emits 'connection' and starts the parser timing at accept, before 'secureConnection'), or (b) when httpContextData->idleTimeout == 0 and SSL, arm a nonzero native timeout in onOpen that onHandshake clears — a native equivalent of Node's tls.Server handshakeTimeout (default 120s). Option (a) also fixes checkConnections semantics for HTTPS more generally.
| test("headersTimeout above the old ~12s cap is honored", async () => { | ||
| const server = http.createServer({ connectionsCheckingInterval: 200 }, (req, res) => res.end("ok")); | ||
| server.headersTimeout = 15000; |
There was a problem hiding this comment.
🟡 This test necessarily runs ≥15s serial (it must survive past the old ~12s reap and then hit the 15s headersTimeout), which pushes the file well over the ~10s default-lane budget REVIEW.md scrutinizes, and it passes a per-test 30000 timeout that test/CLAUDE.md:120 says not to set. The workload genuinely can't be shrunk here (that's the whole point of the fix), so consider marking it test.concurrent — it's fully isolated on its own port: 0 server and client socket — and dropping the third test(..., 30000) argument in favor of the harness default.
Extended reasoning...
What this is
The new test "headersTimeout above the old ~12s cap is honored" is a legitimate outlier: to prove the fix works it must keep a partial-head connection alive past the old ~12s uWS reap window (10s compiled-in idleTimeout + usockets' 4s sweep granularity) and then wait for the configured headersTimeout = 15000 sweep to fire. That's ≥15.2s of wall-clock per run, and the test also passes 30000 as a per-test timeout.
Why it gets flagged
Two repo rules apply directly:
- test/CLAUDE.md:120 — "CRITICAL: Do not set a timeout on tests. Bun already has timeouts." None of the other tests in this file set a per-test timeout; this one does at line 199 (
}, 30000);). - REVIEW.md (Tests reviewers reject → harness conventions) — "Keep tests fast (~1s per test; debug+ASAN runs 10-100x slower); a new file over ~10s on the default lane gets scrutinized for
test.concurrentand staying serial needs a stated reason. A correct but slow test still gets changes-requested." and "Don't raise per-test timeouts to make a slow test pass; shrink the workload."
The other five tests in this file each finish in well under a second; this one addition dominates the file's serial runtime by more than an order of magnitude.
Step-by-step
server.headersTimeout = 15000,connectionsCheckingInterval: 200→ the sweep reaps at ~15.0–15.2s.setTimeout(() => markStillOpen(true), 13000)is the fail-before witness — the test cannot resolvestillOpenAt13sbefore 13s of wall clock have elapsed.expect(await stillOpenAt13s).toBe(true)blocks until (2) fires.await closedthen blocks until the sweep at ≥15s.- Total serial wall-clock: ≥15.2s. With the
test(...)form (nottest.concurrent), the entire file waits on it.
Why the workload can't just be shrunk
REVIEW.md's usual answer is "shrink the workload," but here the bug being fixed is specifically that the compiled-in 10s default (reaped at ~12s wall clock by usockets' 4s-granularity sweep) preempted any headersTimeout above ~13s. A test with headersTimeout < 12s would pass on the unfixed build. So this is a genuine outlier, not a lazily-slow test.
Suggested change
- Switch to
test.concurrent("headersTimeout above the old ~12s cap is honored", async () => { ... }). The test is fully isolated: ownhttp.createServeronport: 0, ownnet.connectclient, no shared globals, no ordering dependency on the other cases in thedescribe. Running it concurrently overlaps its 15s wait with the rest of the file (and with other concurrent tests in the lane) instead of adding it serially. - Drop the
, 30000third argument. The harness default timeout already covers a ~15s wall-clock wait (this is timer-driven idle time, not CPU work, so the debug/ASAN 10–100× multiplier does not apply). If it turns out the default is too tight on a specific CI lane, that's the point to add a per-test override with a comment — root CLAUDE.md permits "a per-test timeout only for the rare outlier," but test/CLAUDE.md's stricter rule is what reviewers will cite first.
Minor: the setTimeout(..., 13000) marker is a >50ms literal sleep, which REVIEW.md says needs a comment naming why no observable signal exists. The block comment above the test already explains it as the fail-before witness, so this is likely fine as-is; if you want to be explicit, a one-line // no observable signal for 'still open at 13s' — the old bug closed with a bare FIN next to the setTimeout would satisfy the letter of the rule.
|
Updated 4:53 AM PT - Jul 25th, 2026
⏳ @robobun, your commit b4fe629 is still building in
|
What
node:httpServer: anyheadersTimeoutabove ~13s was silently capped. A connection that sent a partial request head (or nothing at all) was closed with a bare FIN at ~12s regardless ofheadersTimeout; values below the cap worked, and keep-alive idle between requests was not affected.Repro
Before:
closed after 12008 ms(bare FIN). Node v26.3.0 holds the connection to ~40s then answers 408.Cause
HttpResponseData::idleTimeoutis compiled in as10andonOpencallsresetTimeout()with that value before any request bytes arrive. The server's configuredidleTimeout(0for node:http) is only pushed per request, once a head is parsed (on_node_http_request/prepare_js_request_context). So the uWS 10s default governs the pre-request window (usockets' 4s sweep granularity puts the reap at ~12s wall clock), preempting thecheckConnectionssweep that enforcesheadersTimeout.Fix
Store the configured
idleTimeoutonHttpContextDataand seed each accepted socket from it inonOpen, so the same value governs the pre-request window that governs an in-flight request. node:http'sidleTimeout: 0disables the uWS window and thecheckConnectionssweep owns the deadline (408 +'clientError'withERR_HTTP_REQUEST_TIMEOUT). ABun.serveidleTimeoutnow also applies from accept; the default (10) is unchanged, soBun.servewithout the option still reaps a stalled pre-request connection at ~10s.Verification
New case in
test/js/node/http/node-http-server-timeouts.test.ts: partial-head connection withheadersTimeout = 15000survives past 13s and is then reaped by the sweep withERR_HTTP_REQUEST_TIMEOUT. Fails on the previous build at ~12s with a bare FIN.Note: #33061 is a larger reimplementation of headers/request timeout enforcement inside uWS itself; this change is the minimal fix on top of the existing
checkConnectionssweep on main.