node:https: fail closed when createServer has no key or cert - #33539
node:https: fail closed when createServer has no key or cert#33539robobun wants to merge 1 commit into
Conversation
|
Warning Review limit reached
Next review available in: 2 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 (3)
WalkthroughChangesHTTPS TLS server behavior
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 4:52 AM PT - Aug 4th, 2026
✅ @robobun, your commit 3c70124f4acc4e3bc29cc8662162c0065d9dbd0c passed in 🧪 To try this PR locally: bunx bun-pr 33539That installs a local version of the PR into your bun-33539 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Heads up on the duplicate-detection flag: #32435 already contains this exact fail-closed change. Its #31095 creates an This PR (#33539) is the minimal, security-only version: it isolates just the fail-closed fix (a distinct |
There was a problem hiding this comment.
I didn't find any correctness issues, but this changes fail-open → fail-closed TLS behavior and makes https.Server a distinct class, so a maintainer should sign off (and pick between this and #32435).
Extended reasoning...
Overview
This PR stops https.createServer({}) from silently serving plaintext HTTP when no key/cert are provided. It does so by giving node:https its own Server class that pre-sets isTlsSymbol before delegating to http.Server, and hoisting the TLS-option parse in _http_server.ts out of the else branch so the pre-set flag is honored even when options is omitted or is a function. A new test file exercises three call shapes, verifies cleartext requests get nothing back, verifies the TLS handshake fails with an alert (not WRONG_VERSION_NUMBER), and includes a control that http.createServer({}) still serves plaintext.
Security risks
The change itself is a security fix (fail-closed instead of fail-open on cert misconfiguration), and I don't see it introducing new exposure — http.createServer keeps its plaintext default, and the hoisted option reads are all no-ops on the {} fallback. But it is squarely in TLS/HTTPS listener-setup code, which is exactly the kind of path the approval guidelines say to leave for a human.
Level of scrutiny
Medium-high. The logic is small and the mechanism is clear (isTlsSymbol → tlsSymbol → Bun.serve({ tls })), but it changes public API identity (https.Server !== http.Server, instanceof semantics) and flips the default behavior of a misconfigured-but-previously-working call pattern. That's an intentional Node-compat correction, but it's the kind of behavioral shift a maintainer should be aware of.
Other factors
Two open PRs (#31095, #32435) overlap with this one, and #32435 contains the same fail-closed fix bundled with SNI/addContext work — robobun already flagged that a maintainer needs to choose which to land. Tests look solid (event-driven, no sleeps, control case included). No CODEOWNERS coverage on these files.
|
Status: rebased onto main at 3c70124. Folded main's ALPN-defaulting Locally: |
ecdb63a to
4aaae86
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 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/_http_server.ts`:
- Around line 324-325: Shorten the explanatory comment blocks near the PKCS#12
handling in the HTTPS server implementation to one concise line each, including
the blocks around the visible comment and the referenced locations. Retain only
durable, non-obvious implementation context and remove workaround rationale or
Node parity explanations.
In `@test/js/node/http/node-https-server.test.ts`:
- Around line 107-110: Extend the https.Server tests to cover direct
construction with new https.Server(options, requestListener), including listener
behavior. Add ALPN cases verifying the default ALPNProtocols value ["http/1.1"],
preservation of an explicit ALPNProtocols value, and storage of an ALPNCallback
on the created instance.
- Around line 42-47: The comment above the HTTPS test is overly detailed;
replace it with one concise line stating the invariant that missing TLS
key/certificate material must not create a cleartext HTTP listener. Remove the
historical behavior and failure-context explanation while preserving the test
logic.
🪄 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: 57997737-a94b-4bcc-8a34-4176a99731cb
📒 Files selected for processing (3)
src/js/node/_http_server.tssrc/js/node/https.tstest/js/node/http/node-https-server.test.ts
4aaae86 to
56d7f1d
Compare
https.createServer delegated to http.Server, which only enables TLS when
it sees key/cert/ca/pfx in the options. With none of those present the
options were handed to Bun.serve({tls}) where a cert-less tls object is
treated as no TLS, starting a plaintext HTTP listener on what the user
intended as an https endpoint. A typo'd option name, an undefined from a
failed file read, or a conditionally-assembled config would silently turn
an https server into a working cleartext http server.
Node's https.Server extends tls.Server, so it is always a TLS listener:
with no cert every handshake fails and no byte is ever served in the clear.
Give node:https its own Server class that forces the TLS path on (and carries
the ALPN-defaulting that was previously only on createServer, so it also
applies to `new https.Server`). Hoist http.Server's TLS-option parsing out
of the options-object branch so the forced flag is honored regardless of how
options was passed. http.createServer keeps its cleartext default.
56d7f1d to
3c70124
Compare
There was a problem hiding this comment.
I didn't find any bugs — the fail-closed mechanism (pre-setting isTlsSymbol before delegating to http.Server, with the TLS-option parse hoisted so the flag is honored on all argument shapes) checks out, and the test matrix is thorough. Deferring to a human because this is security-sensitive TLS server code and there's a merge-order decision to make vs. #32435, which bundles the same fix with SNI/addContext support.
What was reviewed:
- Verified the
_http_server.tsdedent is behavior-preserving for plainhttp.Server:this[tlsSymbol] = nullat line 312 makes the removedelseredundant, andoptions = {}on the function/null paths means the hoisted TLS reads are all no-ops. - Confirmed
http.Servernever resetsisTlsSymbol, so the value pre-set byhttps.Serversurvives into thenormalizeServerTlsbranch. - Checked the double
validateObject/spread betweenhttps.Serverandhttp.Serveris harmless.
Extended reasoning...
Overview
Three files: src/js/node/https.ts gains a real Server class (previously aliased to http.Server) that forces this[isTlsSymbol] = true before delegating, folds in the ALPN-defaulting logic that used to live only in createServer, and wires createServer to it. src/js/node/_http_server.ts hoists the TLS-option parsing block out of the else branch so the pre-set isTlsSymbol is honored even when options is a function or omitted; the diff is almost entirely a two-column dedent plus one new one-line comment. test/js/node/http/node-https-server.test.ts is new and covers the fail-closed invariant across four constructor shapes, a positive control (valid cert still serves over TLS), a negative control (http.createServer({}) stays plaintext), instanceof identity, and ALPN defaults for both createServer() and new Server().
Security risks
The change is in the security-hardening direction — it converts a fail-open (misconfigured https.createServer serves plaintext) into a fail-closed (cert-less TLS listener whose handshakes all fail), matching Node. I traced the mechanism end to end: https.Server sets isTlsSymbol → http.Server.$call runs, line 312 resets tlsSymbol (not isTlsSymbol) → the hoisted parse leaves key/cert undefined → if (this[isTlsSymbol]) at line 373 is true → tlsSymbol becomes a normalizeServerTls({key: undefined, cert: undefined, requestCert: false, rejectUnauthorized: false, ...}) object → listen() reads it as truthy tls and hands it to the native layer, which starts a TLS listener with no cert. The residual risk is the usual one for TLS-path edits: an unforeseen interaction with the native Bun.serve({ tls: {...} }) path when key/cert are undefined. The test's _ALERT_ assertion and the reported 59/59 pass on test-https-*.js node tests give reasonable confidence, but a human familiar with the uSockets TLS layer should confirm.
Level of scrutiny
High — this is the node:https server constructor and it changes when a listener is TLS vs. plaintext. That said, the actual code delta is small (~30 net new lines in https.ts, a dedent in _http_server.ts) and the direction is strictly safer than before.
Other factors
All prior review threads (comment-cop, coderabbit, my own comment-style note) are resolved in 3c70124. The duplicate-PR bot flagged #32435 as carrying the same fix bundled with addContext/SNI support and native uWS changes; the author acknowledged this and offered to close in its favor. That's a maintainer call — landing this focused security fix now vs. waiting for the larger PR — which is another reason to defer rather than approve.
What
https.createServer({})with nokeyand nocertserves plaintext HTTP on the "https" listener. Node fails closed: it still builds a TLS listener, every handshake fails, and nothing is ever served in the clear.This matters because the misconfiguration class "key/cert didn't make it into the options object" (a typo'd option name, an
undefinedfrom a failed file read, a conditionally-assembled config) turns an intended https endpoint into a working cleartext http endpoint. It passes a naive "server is up and answers" health check, so nothing catches it.Cause
node:httpsaliasedServer/createServerstraight tonode:http:http.Serveronly marks the server as TLS when it sees truthykey/cert/cain the options. With none present,this[tlsSymbol]staysnull, andBun.serve({ tls: null })starts a plain HTTP listener. Becausehttps.createServerandhttp.createServerwere the same function, the https layer could not tell "user wanted HTTPS" from "user wanted HTTP".Fix
Node's
https.Serverextendstls.Server, so it is always a TLS listener regardless of cert material. This PR givesnode:httpsits ownServerclass that forces the TLS path on before delegating tohttp.Server. With no cert the server becomes a certificate-less TLS listener whose handshakes all fail, matching Node, instead of a cleartext HTTP listener.http.Server's TLS-option parsing is hoisted out of theelsebranch so the forced flag is honored even when the options argument is omitted or is a function (new https.Server(),https.createServer(requestListener)).http.createServerkeeps its cleartext default for callers that pass no cert material.Verification
test/js/node/http/node-https-server.test.tscovers it: a cleartext client gets nothing back, the TLS handshake fails, andhttp.createServer({})still serves cleartext as a control. Fails on the released binary, passes with this change.Out of scope
The report also notes that a garbage
key/certPEM is not rejected synchronously atcreateServertime (Node throwsERR_OSSL_PEM_NO_START_LINE; Bun surfaces it as a deferred'error'atlisten()). That is a separate timing/compat issue that also affectstls.createServer, which builds its native SSL context lazily at listen. It is not a fail-open security problem (the handshake still fails with bad PEM), so it is left for a focused follow-up.