Skip to content

node:https: enforce server handshakeTimeout - #33541

Closed
robobun wants to merge 3 commits into
mainfrom
farm/d5652adc/https-handshake-timeout
Closed

robobun wants to merge 3 commits into
mainfrom
farm/d5652adc/https-handshake-timeout

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Problem

https.createServer({ handshakeTimeout }) never enforced the timeout. A peer that opened a TCP connection and never began the TLS handshake was kept open indefinitely, holding a socket and BoringSSL state bounded only by the fd limit (a slowloris against the handshake). Bun's own tls.createServer enforces the same option correctly, which localizes the bug: https.Server is backed by Bun.serve (idleTimeout: 0), not by node:tls's Server, so the option was dropped on the floor.

import https from "node:https";
import net from "node:net";
// ...cert setup...
const server = https.createServer({ key, cert, handshakeTimeout: 150 }, () => {});
server.on("tlsClientError", e => console.log("tlsClientError", e.code));
server.listen(0, "127.0.0.1", () => {
  const c = net.connect(server.address().port, "127.0.0.1"); // connects, never sends a byte
  c.on("error", () => {});
});

// node v26:  tlsClientError ERR_TLS_HANDSHAKE_TIMEOUT, peer closed ~160ms
// bun (before): peer stays open forever

Cause

node:tls's Server arms a per-socket timer in JS for every accepted connection (src/js/node/net.ts). Bun.serve never surfaces a pre-handshake socket to JS, so there is nothing to hang a JS timer on, and uSockets' only per-socket timer is the 4 second sweep wheel, too coarse to express a millisecond handshakeTimeout.

Fix

Add a TLS handshake watchdog to the uWS HttpContext. Every accepted SSL socket queues on an intrusive FIFO, and a single one-shot us_timer_t is armed at the head's deadline. Arrival order keeps the deadlines non-decreasing, so the head is always the next to expire and one timer covers the whole list. When it fires, the socket is reported through node's tlsClientError with ERR_TLS_HANDSHAKE_TIMEOUT and closed. The entry is removed when the handshake settles (either way) or the socket closes first; the timer outlives the socket group so the drain on shutdown is safe.

node:http's server forwards handshakeTimeout (default 120s, matching node) into the server and validates it the same way tls.Server does. It is only read for TLS servers; a plain http.Server has no handshake and ignores it, like node.

Verification

test/js/node/http/node-https-handshake-timeout.test.ts (driven by a single fixture process, since debug+ASAN TLS accept is slow) covers:

  • a silent peer is dropped via tlsClientError: ERR_TLS_HANDSHAKE_TIMEOUT
  • a real request completing the handshake in time is not killed
  • a non-numeric handshakeTimeout throws ERR_INVALID_ARG_TYPE

Fails on the unfixed build (peer stays open, no event, option unvalidated); passes with the fix.

https.createServer is backed by Bun.serve (idleTimeout: 0) rather than
node:tls's Server, so the handshakeTimeout option was never applied: a
peer that connected over TCP and never began the TLS handshake was kept
open indefinitely, bounded only by the fd limit.

Add a per-HttpContext TLS handshake watchdog in uWS. Accepted SSL
sockets queue on an intrusive FIFO and a single one-shot timer is armed
at the head's deadline; the deadlines are non-decreasing, so the head is
always the next to expire. When it fires, the socket is reported through
node's 'tlsClientError' with ERR_TLS_HANDSHAKE_TIMEOUT and closed. The
entry is removed once the handshake settles or the socket closes.

node:http forwards handshakeTimeout (default 120s, like node) into the
server and validates it the same way tls.Server does.
@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bc9dfcda-2621-4821-83d2-b5ecaa8e8ba2

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and 6ce3396.

📒 Files selected for processing (13)
  • packages/bun-uws/src/App.h
  • packages/bun-uws/src/HttpContext.h
  • packages/bun-uws/src/HttpContextData.h
  • packages/bun-uws/src/HttpResponseData.h
  • src/js/internal/http.ts
  • src/js/node/_http_server.ts
  • src/jsc/bindings/NodeHTTP.cpp
  • src/runtime/server/mod.rs
  • src/runtime/server/server_body.rs
  • src/uws_sys/App.rs
  • src/uws_sys/libuwsockets.cpp
  • test/js/node/http/node-https-handshake-timeout-fixture.mjs
  • test/js/node/http/node-https-handshake-timeout.test.ts

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

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:05 PM PT - Jul 6th, 2026

❌ @robobun, your commit 6ce3396 has some failures in Build #69415 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33541

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

bun-33541 --bun

Comment thread test/js/node/http/node-https-handshake-timeout-fixture.mjs Outdated
Comment thread test/js/node/http/node-https-handshake-timeout-fixture.mjs
Raise the live-request handshakeTimeout to 10s (a completed handshake is
dequeued immediately, so this only avoids the watchdog racing a slow
debug+ASAN loopback handshake) and clear the silent-peer guard timer so
it cannot fire during later scenarios and emit a stray RESULT line.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both test-fixture nits from the earlier pass are addressed and I didn't find further issues, but this adds a new intrusive-list + us_timer_t watchdog directly in the uWS TLS accept path with manual lifecycle management across C++/Rust/JS — worth a human look at the memory/timer ordering.

Extended reasoning...

Overview

This PR wires handshakeTimeout from https.createServer down into uWebSockets by adding a per-HttpContext TLS handshake watchdog: an intrusive doubly-linked FIFO of pending-handshake sockets (links stored in HttpResponseData), a single lazily-created us_timer_t armed at the head's deadline, and a callback that unlinks expired sockets, reports them to JS via a new on_handshake_timeout StrongOptional, and closes them. Plumbing spans HttpContext.h/HttpContextData.h/HttpResponseData.h, the C shim in libuwsockets.cpp, Rust FFI in uws_sys/App.rs and runtime/server/{mod,server_body}.rs, the NodeHTTP.cpp binding, and _http_server.ts. 13 files changed.

Security risks

The change sits directly on the TLS accept path. It hardens against a slowloris-style handshake DoS, which is good, but it also introduces new state that is mutated from onOpen/onHandshake/onClose and from a timer callback that re-enters JS. The design looks careful (unlink-before-report, handshakeDeadline == 0 as the idempotence sentinel, timer freed after us_socket_group_deinit so onClose-driven re-arms don't UAF), but a mistake here would be a UAF or list corruption on every HTTPS connection.

Level of scrutiny

High. This is not a mechanical change: it adds a hand-rolled intrusive linked list with raw us_socket_t* pointers, a timer whose ext stores a raw HttpContext*, and a JS re-entry point mid-iteration. The free() ordering, the armedHandshakeDeadline short-circuit, the if constexpr (SSL) gating, and the interaction with socket adoption/upgrade all deserve a maintainer's eye. The Rust side also adds a new StrongOptional GC root and a *mut Self stashed as user_data.

Other factors

The two flakiness nits I raised on the fixture (tight 200ms handshake budget in liveRequest, uncleared 8s guard timer in silentPeer) were both fixed in c46e0b9 and the threads are resolved. The current bug-hunting pass found nothing. Test coverage exercises the timeout firing, a successful handshake not being killed, and option validation — reasonable but doesn't stress concurrent handshakes or shutdown-during-pending. Given the breadth (uWS internals + 4-language FFI chain + TLS path), I'm deferring rather than approving.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both test-fixture nits from the earlier pass are addressed and I found nothing further, but this adds a native timer + intrusive socket queue to the uWS TLS accept path with lifetime spanning C++/Rust/JS — worth a human look.

Extended reasoning...

Overview

This PR implements handshakeTimeout for https.createServer by adding a TLS handshake watchdog to uWS's HttpContext: an intrusive FIFO of pending-handshake sockets driven by a single us_timer_t, plumbed through the C ABI shim (libuwsockets.cpp), Rust FFI (uws_sys/App.rs, server_body.rs), C++ bindings (NodeHTTP.cpp), and the node:http JS layer (_http_server.ts). It touches 13 files across four languages.

Prior review

On the previous revision I left two inline nits about test-fixture flakiness (tight 200ms handshake budget in liveRequest(); an uncleared guard timer in silentPeer()). Both were addressed in c46e0b9 and the threads are resolved. The current bug-hunting pass found no issues.

Security risks

The change is itself a security hardening (slowloris against the TLS handshake). It sits directly in the TLS accept path: onOpen enqueues raw us_socket_t* pointers, a loop-owned timer fires into JS (which can synchronously close arbitrary sockets), and onClose/onHandshake dequeue. The intrusive-list and timer-lifetime handling look correct to me (unlink-before-callback, head re-read each iteration, timer freed after group deinit in free()), but raw-pointer queues that survive JS reentrancy in a security-sensitive path are exactly where a maintainer should sign off.

Level of scrutiny

High. This is not a mechanical change: it introduces new native state (timer, linked list, deadline bookkeeping) with non-trivial lifetime and reentrancy invariants, a new FFI surface, a StrongOptional GC root on the server, and a design decision (single one-shot timer over a monotone FIFO rather than per-socket timers) that a maintainer should agree with.

Other factors

Test coverage exercises the three headline behaviors (silent peer dropped, live request unaffected, invalid option throws) and the earlier flakiness concerns are fixed. CI build #69415 is in progress. Given the scope and the layers touched, I'm deferring rather than approving.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green on every lane it affects. The red lanes across the last three runs have all been unrelated flakes on paths this PR does not touch:

  • build #69313: napi.test.ts (napi_wrap GC-lifetime timing on Windows x64-baseline) and node-http-uaf.test.ts (flaky warning, retried; plain-HTTP path, while this change's watchdog is TLS-only via if constexpr (SSL))
  • build #69415: bun-install-lifecycle-scripts.test.ts (flaky warning on aarch64, retried) and postgres-binary-numeric.test.ts (PostgresError: the database system is starting up, 57P03 — Postgres container not ready, infra)

Different flaky tests each run, none in node:http/https, TLS, or the uWS server path. I verified locally that the handshake-timeout test passes and the plain-HTTP UAF "drain after onWritable undefined" test does not regress with this diff. Ready for a maintainer to merge.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-06, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant