Repository navigation
Conversation
The `any` presence detector in SSLConfig.from_generated treats tuning
fields (requestCert, rejectUnauthorized, lowMemoryMode, ciphers, min/max
version, etc.) as peers of key/cert when deciding whether a TLS listener
is wanted, and never requires key/cert at all. So:
- `Bun.serve({tls: {}})` parsed to None and quietly listened as plain
http:// even though a tls object was given (fail-open).
- `Bun.serve({tls: {requestCert: true}})` and similar tuning-only
objects parsed to Some with no key/cert and listened as https:// with
a context that fails every handshake with an internal alert.
- The v0.2.1 legacy top-level reader ran SSLConfig.from_js on the whole
serve options object, so a stray `lowMemoryMode: true` at top level
armed a cert-less https:// listener.
- `https.createServer({})` (which routes through the same Server
constructor) also listened as plain http://, where Node listens as a
TLS server whose every handshake fails with handshake_failure.
Make identity material the only thing that arms TLS:
- SSLConfig.has_identity_material() reports (key && cert) ||
(keyFile && certFile).
- Bun.serve({tls: <object>}) now throws a synchronous TypeError when the
object carries no identity material; same for each entry of a tls
array. The node:http wrapper (onNodeHTTPRequest) is exempt so
node:https can keep Node's fail-closed semantics.
- The legacy top-level reader only keeps the parsed config when it has
identity material; a lone tuning field is inert and never arms or
throws.
- https.createServer stamps an internal is-TLS marker on the options it
hands to the shared Server constructor so an https server is always
TLS: with no key/cert the listener now fails every handshake instead
of serving plaintext.
|
Warning Review limit reached
Next review available in: 5 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 |
|
Updated 8:10 PM PT - Jul 25th, 2026
❌ @robobun, your commit 73b4fc2 has some failures in 🧪 To try this PR locally: bunx bun-pr 35810That installs a local version of the PR into your bun-35810 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Re #35795: there is real overlap (same identity helper on
It also updates the two existing tests whose fixtures used |
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/runtime/server/ServerConfig.rs:1406-1412— The array path still fails open:Bun.serve({ tls: [{}], fetch })serves plaintexthttp:with no error.SSLConfig::from_js({})returnsNone(no fields →any=false), theNonearm hitscontinuewithout callingrequire_identity,args.ssl_configstaysNone, andsaw_tls_key=trueblocks the legacy reader. Callrequire_identity(global, None)?in theNonearm (or after the loop when the array was non-empty andssl_configis stillNone) so the array branch matches the object branch.Extended reasoning...
What the bug is
The PR's stated invariant is that a
tlsobject passed toBun.serve"can never quietly downgrade to plaintext". The non-array branch enforces this —tls: {}now throwsTypeError: tls option requires both "key" and "cert". But the sibling array branch does not:Bun.serve({ tls: [{}], fetch })still listens as plaintexthttp:with no error, exactly the fail-open this PR set out to close.Code path
Step through
Bun.serve({ port: 0, tls: [{}], fetch: () => new Response("ok") })inServerConfig::from_js:arg.get_truthy(global, "tls")returnsSome([{}]);saw_tls_key = true.tls.js_type().is_array()is true,value_iter.len == 1, so the loop runs once withitem = {}.SSLConfig::from_js(vm, global, {})→from_generatedcomputesany = false(no passphrase, no dhParams, no serverName,reject_unauthorized.is_none(),request_cert == false, no secureOptions, no key/cert/ca/crl, …) and returnsOk(None)(src/runtime/socket/SSLConfig.rs:265).- The
Nonearm at ServerConfig.rs:1401-1407 hitscontinue—require_identityis never called. The comment there still reads "Backwards-compatibility; we ignored empty tls objects", which is precisely the stance this PR overrides for the object case. - Loop ends with
args.ssl_config == None. saw_tls_key == true, so the legacy top-level reader at line 1446 is skipped.args.ssl_configremainsNone→ the server listens as plaintexthttp:.
Compare the non-array branch at line 1433:
require_identity(global, parsed.as_ref())?runs withparsed == None,is_node_http == false,cfg.is_some_and(...) == false→ throws. The array branch's new guard at line 1409 (require_identity(global, Some(&ssl_config))?) only runs on theSomearm, so an entry that parses toNonenever reaches it.Why existing code doesn't prevent it
The
Nonearm was deliberately a silent skip pre-PR ("Backwards-compatibility; we ignored empty tls objects"). This PR removed that back-compat fortls: {}but left the same comment and the samecontinuein the parallel array arm. The new test matrix's only array case is[{ key: tlsCert.key, rejectUnauthorized: false }], which returnsSome(becausereject_unauthorized.is_some()setsany = true) and so reaches theSome-arm guard —[{}]is not covered.Impact
Same security fail-open the PR description leads with, reachable via a documented input shape (
tlsaccepts an array for SNI). A user who writestls: [{}]— ortls: [tlsOpts]wheretlsOptsis accidentally{}— gets a plaintext server instead of an error. Per REVIEW.md "Security checks fail closed and cover every path to the protected effect … enumerate every route to the effect … and enforce through one shared predicate" and "Fix the whole class in the same PR … parallel switch arms", the array branch is a sibling entry point that must carry the same guard.Fix
In the
Nonearm, replacecontinuewithrequire_identity(global, None)?; continue;(which throws for directBun.servecallers and letsnode:httpthrough, matching the object branch). Alternatively, after the loop, if the array was non-empty andargs.ssl_configis stillNone, callrequire_identity(global, None)?. Either way, add["array with empty entry", [{}]]to thewithoutIdentitytest matrix inbun-serve-ssl.test.tsand drop or update the now-stale "Backwards-compatibility; we ignored empty tls objects" comment.
…constructor
- has_identity_material() now accepts any key-source paired with any
cert-source ({key, certFile} etc.), matching what usockets loads.
- A non-empty tls array whose entries all parse to None now throws
instead of listening as plaintext.
- https.Server is its own constructor that stamps the is-TLS marker and
defaults ALPN before delegating to http.Server, so both
https.createServer and new https.Server fail closed without key/cert.
createServer now just returns new Server(...), matching Node.
There was a problem hiding this comment.
The three earlier findings (mixed keyFile/cert sources, https.Server constructor path, tls: [{}] array gap) are all addressed and I didn't find anything new. Deferring to a human because this is a user-facing breaking change to TLS semantics — Bun.serve({ tls: {} }) now throws, top-level tuning fields go inert, and https.Server becomes its own class — plus #35795 covers overlapping ground and someone needs to pick which lands.
What was reviewed:
has_identity_materialpredicate now accepts any key-source × any cert-source; legacy top-level fallback drops non-identity configs without leaking (SSLConfig::Drop runs).require_identityclosure:is_node_httpexemption reaches the single-object arm and arms cert-less TLS viaSSLConfig::zero; array arm's post-loopNonecheck closes the[{}]gap.https.Serverwrapper:isTlsSymbolsurvives the{ ...options }spread in_http_server.ts; call-without-newand function-arg forms handled; existinginstanceof https.Serverassertions intest-https-set-timeout-server.jsstill hold sincecreateServernow constructs the subclass.
Extended reasoning...
Overview
The PR touches src/http/ssl_config.rs (new has_identity_material() predicate), src/runtime/server/ServerConfig.rs (new require_identity closure gating the three tls-parsing arms plus a saw_tls_key flag on the legacy fallback), src/js/node/https.ts (Server becomes a real constructor that stamps isTlsSymbol and delegates to http.Server; createServer is now a thin wrapper), src/js/node/_http_server.ts (reads the stamped symbol so an https server is TLS even with no key/cert), and three test files with a ~16-case matrix.
Security risks
The change closes a fail-open: previously Bun.serve({ tls: {} }) silently served plaintext and tls: { requestCert: true } stood up a cert-less https listener. Now direct Bun.serve throws synchronously and node:https arms a fail-closed TLS listener (Node parity). I checked that the is_node_http exemption is keyed on on_node_http_request being set, which only _http_server.ts's listen() supplies — a user calling Bun.serve directly can't reach the exempt path without also supplying a callable onNodeHTTPRequest. The legacy top-level reader now only keeps a config carrying identity material, so a stray lowMemoryMode: true at the top level no longer arms a broken https listener; the discarded SSLConfig is dropped and its C strings freed by Drop.
Level of scrutiny
High — this is TLS listener setup, and the PR changes three public behaviors: (1) Bun.serve now throws on tls objects without key+cert, (2) top-level tuning fields no longer arm TLS, (3) require('node:https').Server is now a distinct subclass rather than === http.Server. All three are defensible fixes but are breaking changes a maintainer should sign off on. There is also an overlapping PR (#35795) that a human needs to reconcile.
Other factors
All three of my earlier inline findings were fixed in 75583b7 and I re-verified each in the current diff. I spot-checked existing Node parallel tests that call https.Server(opts, ...) without new and that assert instanceof https.Server — the new constructor's if (!(this instanceof Server)) guard and $toClass(Server, "Server", http.Server) prototype chain keep those passing. get_truthy("tls") filters falsy values, so tls: false/tls: null leave saw_tls_key = false and the legacy fallback still runs (unchanged from before). The test matrix covers the throw cases, the [{}] array gap, both createServer and new Server entry points, and the instanceof relationship.
A top-level key without cert (or vice versa) previously armed a
fail-closed https listener; the earlier change in this PR made it inert
(plaintext). Since key/cert/keyFile/certFile at top level are
unambiguously TLS intent, throw the same TypeError the nested tls:{}
arm throws for the same input. Tuning-only top-level fields stay inert.
tls: [] expresses no TLS intent (zero SNI configs, #21792), so leave saw_tls_key false for it and drop the dead is_falsey() arm. Combining tls: [] with legacy top-level key/cert keeps arming HTTPS like before.
get_truthy only filters undefined/null/empty-string, so the is_falsey() arm was load-bearing for tls:false, tls:0 and tls:NaN. Restore it as a no-TLS carve-out (saw_tls_key stays false, legacy reader still runs) and add tls:false/0/null/undefined plain-HTTP control tests.
There was a problem hiding this comment.
No further findings after 73b4fc2 — the is_falsey() guard is back and covered by the new tls: false/0/null/undefined control cases. Deferring to a human for the API/compat decisions here (new synchronous throw on tls: {}, the intentional tls: [] carve-out, https.Server becoming its own constructor) and to reconcile with the overlapping #35795.
What was reviewed:
require_identityreaches everytls:arm (object, array entry, post-loop empty-array-of-objects) and the legacy top-level partial-identity path;is_node_httpexemption plumbs through toSSLConfig::zerosonode:httpsstill arms a fail-closed listener.has_identity_materialaccepts mixedkey/keyFile×cert/certFilesources;tls: []andtls: falseleavesaw_tls_key = falseso top-levelkey/certstill arm HTTPS._http_server.tsreadsoptions[isTlsSymbol]on the object branch, setsthis[tlsSymbol]vianormalizeServerTls, andkRealListenpasses that object astls:alongsideonNodeHTTPRequest, sois_node_httpis true when the identity check runs.
Extended reasoning...
Overview
The PR changes when Bun.serve decides to arm a TLS listener. It adds SSLConfig::has_identity_material() / has_any_key() / has_any_cert(), wires a require_identity closure into every tls: arm of ServerConfig::from_js (single object, each array entry, post-loop for arrays whose entries all parsed to None, and the legacy v0.2.1 top-level reader for partial identity), and exempts the node:https path via is_node_http. On the JS side, https.Server becomes its own constructor that stamps an internal isTlsSymbol marker on the options so _http_server.ts builds a tls object even when no key/cert is present, which kRealListen then hands to Bun.serve alongside onNodeHTTPRequest. Tests cover the throw matrix, falsey/empty carve-outs, legacy top-level paths, and node:https fail-closed behavior for both createServer and new Server.
Security risks
This is squarely in TLS-arming territory. The direction of the change is fail-safe (turn a silent plaintext downgrade into a synchronous throw, and turn a plaintext node:https listener into a fail-closed TLS one), and I traced each arm to confirm the identity check is not bypassable via a truthy-but-identity-less shape after the latest commits. The remaining plaintext carve-outs (tls: [], tls: false/0/NaN/null/undefined) are the pre-existing behavior and are now covered by regression tests plus the #21792 test.
Level of scrutiny
High. Security-sensitive TLS gating with several sibling arms (object / array / legacy top-level / node:http), a five-round review history where each round found a real gap, an intentional API tightening that will throw on inputs that previously produced a running (if broken) server, and a change to the https.Server prototype chain. That combination warrants a maintainer signing off on the API surface even though the mechanics now look correct.
Other factors
Test coverage is thorough for the new behavior. Two things a human should weigh: the deliberate tls: [] = plain-HTTP carve-out (author documented the rationale and the #21792 regression test that pins it), and reconciliation with the overlapping PR #35795. The https.Server !== http.Server change matches Node but is a user-observable identity change that isn't called out in the PR body.
|
CI status for 73b4fc2 (build #81897, finished): the diff itself is green on every lane that built.
All darwin and Windows lanes built and ran: |
|
Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-26, 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. |
What
Bun.serve's TLS-presence detection treats tuning fields (requestCert,rejectUnauthorized,lowMemoryMode,ciphers,minVersion/maxVersion,sessionTimeout, ...) as peers ofkey/certwhen deciding whether to stand up a TLS listener, and never requireskey/certat all. Both directions of that are wrong:Root cause
SSLConfig::from_generatedcomputesany = passphrase || dhParams || ... || rejectUnauthorized.is_some() || requestCert || secureOptions || ...and returnsSomewhenever any tuning field is set.ServerConfigthen arms TLS onSome(cert-lesshttps:) and silently drops to plaintext onNone(tls:{}). The legacy top-level reader applies the same check to the whole serve-options object.Fix
Identity material is the only thing that arms TLS.
SSLConfig::has_identity_material()reports(key || keyFile) && (cert || certFile).ca/crlare trust, not identity.Bun.serve({ tls: <object> })now throws a synchronousTypeError: tls option requires both "key" and "cert" (or "keyFile" and "certFile")when the object (or anytls: [...]entry) lacks identity material. Thenode:httpwrapper path (onNodeHTTPRequest) is exempt sonode:httpskeeps Node's fail-closed behaviour.tls: false/0/null/undefinedandtls: []stay plain HTTP and leave the legacy top-level reader active (unchanged; covered by the [v1.2.20] Binding multiple certs for SNI via Bun.serve crashes #21792 regression test).keywithoutcert(or vice versa) now throws the same TypeError.https.Serveris now its own constructor that stamps the internal is-TLS marker and delegates tohttp.Server, andhttps.createServerjust returnsnew Server(...)(matching Node's structure). Anhttpsserver is always a TLS listener: with nokey/certevery handshake now fails (like Node v26) instead of serving plaintext. Observable change:require("node:https").Server !== require("node:http").Server(it was previously the same constructor);https.Serverstill extendshttp.Serversoinstanceofholds.Verification
test/js/bun/http/bun-serve-ssl.test.tsgains atls must carry key+certmatrix (10 throw cases + legacy/falsey controls) and anode:https without key/cert is TLS fail-closedmatrix (plaintext fetch rejected, TLS handshake errors,createServerandnew Serverboth covered, key+cert control,instanceofcheck). Roughly 20 of the new tests fail onmainand all pass with this change.Fixture adjustments:
serve-types.test.ts'stls: {}case now asserts the constructor error, andserve.test.ts's "SNI missing serverName" case carriescertalongsidekeyso it still reaches the serverName check.