node:https: add addContext and other tls.Server methods to https.Server#32435
node:https: add addContext and other tls.Server methods to https.Server#32435robobun wants to merge 4 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds SNI (Server Name Indication) context support to Bun's HTTPS server by introducing a ChangesHTTPS Server SNI Context Support
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
Updated 7:23 AM PT - Jun 18th, 2026
✅ @robobun, your commit 462eff681c950d0fbe7d1de74025cc2fb111a0d5 passed in 🧪 To try this PR locally: bunx bun-pr 32435That installs a local version of the PR into your bun-32435 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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/https.ts`:
- Around line 151-157: The error messages in both the getTicketKeys and
setTicketKeys methods contain a typo where "implented" should be spelled as
"implemented". Update the Error messages in both Server.prototype.getTicketKeys
and Server.prototype.setTicketKeys to correct the spelling from "Not implented
in Bun yet" to "Not implemented in Bun yet".
🪄 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: a1bf7bd6-4259-40b8-af3d-7a43ec5bed54
📒 Files selected for processing (4)
src/js/internal/http.tssrc/js/node/_http_server.tssrc/js/node/https.tstest/js/node/http/node-https-server-context.test.ts
| function tlsOptionsFromContext(context) { | ||
| const { key, cert, ca, passphrase, secureOptions, requestCert, rejectUnauthorized } = context || {}; | ||
| if (cert) throwOnInvalidTLSArray("options.cert", cert); | ||
| if (key) throwOnInvalidTLSArray("options.key", key); | ||
| if (ca) throwOnInvalidTLSArray("options.ca", ca); | ||
| if (passphrase && typeof passphrase !== "string") { | ||
| throw $ERR_INVALID_ARG_TYPE("options.passphrase", "string", passphrase); | ||
| } | ||
| const request = !!requestCert; | ||
| return { | ||
| key, | ||
| cert, | ||
| ca, | ||
| passphrase, | ||
| secureOptions, | ||
| requestCert: request, | ||
| rejectUnauthorized: request ? rejectUnauthorized !== false : false, | ||
| }; | ||
| } |
There was a problem hiding this comment.
🔴 addContext and setSecureContext don't accept a tls.SecureContext as the context argument, only a plain options object. Node.js documents the second arg of addContext as <Object> | <tls.SecureContext>, and Bun's own tls.Server versions handle this (tls.ts:736-738, 750-752) — but tlsOptionsFromContext destructures {key, cert, ...} directly, and Bun's SecureContext only exposes .context/.servername, so server.addContext('host', tls.createSecureContext({key, cert})) silently registers an SNI entry with no certificate.
Extended reasoning...
What the bug is
Node.js's server.addContext(hostname, context) accepts either a plain options object or a tls.SecureContext returned by tls.createSecureContext() — the docs explicitly type the second argument as <Object> | <tls.SecureContext>. The same is true of setSecureContext. The new https.Server implementations only handle the plain-options form; passing a SecureContext silently produces an empty SNI entry.
The code path
tlsOptionsFromContext (https.ts:82-100) does:
const { key, cert, ca, passphrase, secureOptions, requestCert, rejectUnauthorized } = context || {};But Bun's InternalSecureContext (tls.ts:422-460) only defines two own properties — .context (the native SSL_CTX wrapper) and .servername:
var InternalSecureContext = class SecureContext {
context;
servername;
constructor(options) {
...
this.context = newNativeSecureContext(options);
this.servername = options?.servername;
}
};It does not re-expose key/cert/ca/passphrase as own properties. So destructuring a SecureContext instance yields key = undefined, cert = undefined, etc., and the entry pushed into this[kSNIContexts] is { key: undefined, cert: undefined, ca: undefined, ..., serverName: hostname }.
setSecureContext (https.ts:112-149) has the same issue: it destructures {cert, key, ca, ...} directly from options, and since each branch is gated on if (cert) / if (key) / etc., passing a SecureContext is a complete no-op.
Why existing code doesn't prevent it
Bun's tls.Server already handles this case correctly. In tls.ts:736-738:
if (!(context instanceof InternalSecureContext)) {
context = createSecureContext(context);
}
// ...then uses context.contextand in tls.ts:750-752:
if (options instanceof InternalSecureContext) {
options = options.context;
}The new https.Server methods don't share that code path and lack the equivalent unwrap.
Step-by-step proof
import https from 'node:https';
import tls from 'node:tls';
const server = https.createServer({ key: defaultKey, cert: defaultCert });
const ctx = tls.createSecureContext({ key: agent1Key, cert: agent1Cert });
server.addContext('a.example.com', ctx);ctxis anInternalSecureContextwith own props{ context: <native>, servername: undefined }.addContextcallstlsOptionsFromContext(ctx)→ destructures{ key, cert, ca, ... } = ctx→ allundefined.if (cert)/if (key)validation is skipped (falsy), no error thrown.- Returns
{ key: undefined, cert: undefined, ca: undefined, passphrase: undefined, secureOptions: undefined, requestCert: false, rejectUnauthorized: false }. entry.serverName = 'a.example.com'is set and the entry is pushed tokSNIContexts.- At
listen(), this empty config is passed toBun.serveas an SNI entry with no certificate — the handshake fora.example.comwill either fail or fall through to the default cert, with no indication to the user that their context was dropped.
In Node.js the same code works and serves agent1Cert for SNI a.example.com.
Impact
Silent failure of a documented Node.js API input type. Code that follows the Node.js docs (or that pre-builds and shares SecureContext objects across servers, which is a real pattern for cert caching) will appear to succeed but serve the wrong certificate or fail handshakes.
Suggested fix
Mirror what tls.Server does — detect a SecureContext and unwrap it before destructuring. Since InternalSecureContext isn't exported from node:tls, the simplest check is duck-typing on .context, or import the class via an internal binding. For example, at the top of tlsOptionsFromContext:
function tlsOptionsFromContext(context) {
if (context && typeof context === 'object' && context.context && !context.key && !context.cert) {
context = context.context; // unwrap SecureContext → native options
}
...
}(or, more robustly, expose InternalSecureContext from internal/tls and do an instanceof check like tls.ts does), and apply the same unwrap at the top of setSecureContext.
There was a problem hiding this comment.
You're right that tls.createSecureContext() returns an InternalSecureContext whose only own properties are .context (a native SSL_CTX* wrapper) and .servername; the raw key/cert/ca bytes are consumed into the SSL_CTX and are not recoverable from it. Unwrapping to .context therefore does not help here since Bun.serve's tls array is parsed by SSLConfig::from_js, which reads key/cert/ca properties and does not accept a pre-built SSL_CTX*.
Properly supporting a SecureContext argument would mean teaching SSLConfig / the uWS SNI path to accept a native SecureContext handle and SSL_CTX_up_ref it instead of rebuilding from PEM bytes. That is a worthwhile follow-up but touches the native SSL context cache and the uWS SNI registration path, which is beyond the scope of restoring the missing method here.
For this PR, plain option objects (the reported case in #12157 and the common ACME pattern) work end to end, both before and after listen(). I will leave this thread open for a maintainer to weigh in on whether SecureContext input should block this PR or be tracked separately.
| Server.prototype.setSecureContext = function (options) { | ||
| if (options == null) return; |
There was a problem hiding this comment.
🔴 Commit 7b35335 wired addContext() through to the running server (via httpServerAddServerName, with a post-listen test), but setSecureContext() was not given the same treatment — it still only writes this[tlsSymbol], which is read exactly once in kRealListen. So server.listen(); server.setSecureContext({key, cert}) silently keeps serving the old default cert, even though hot cert rotation on a live server is the primary use case for this method in Node.js. It should either call into the running server to swap the default SSL context, or throw when this[serverSymbol] is set so the rotation doesn't fail silently.
Extended reasoning...
What the bug is
The earlier review comment (#3424483805) flagged that both addContext() and setSecureContext() were silent no-ops when called after listen(). Commit 7b35335 fixed addContext() — it now checks this[serverSymbol] and calls httpServerAddServerName(bunServer, hostname, entry) into the running uWS app, and a new test addContext registers a SNI context after listen covers it. However, setSecureContext() (src/js/node/https.ts:118-161) was not updated: it still only mutates this[tlsSymbol] and returns, with no check for this[serverSymbol] and no native call.
The code path
this[tlsSymbol] is consumed in exactly one place — Server.prototype.listen() in _http_server.ts:
let tls = this[tlsSymbol];
// ...
server[kRealListen](tls, port, host, socketPath, ...);which passes it to Bun.serve({ tls, ... }). After Bun.serve() has been called, nothing ever re-reads this[tlsSymbol], and setSecureContext() makes no call into the running server. Contrast with addContext() after the fix:
const bunServer = this[serverSymbol];
if (bunServer) {
httpServerAddServerName(bunServer, hostname, entry);
}setSecureContext() has no equivalent branch.
Step-by-step proof
const server = https.createServer({ key: oldKey, cert: oldCert }, handler);
server.listen(0);
await once(server, 'listening');
server.setSecureContext({ key: newKey, cert: newCert }); // e.g. Let's Encrypt renewal- Constructor sets
this[tlsSymbol] = normalizeServerTls({ key: oldKey, cert: oldCert, ... }). listen()readsthis[tlsSymbol]once and callsBun.serve({ tls: {key: oldKey, cert: oldCert, ...} }).this[serverSymbol]is now set.setSecureContext({key: newKey, cert: newCert})runs:const tls = this[tlsSymbol] || {...}→ mutatestls.key = newKey,tls.cert = newCert→this[tlsSymbol] = tls. No check forthis[serverSymbol], no native call. Returnsundefined.- A client connects. The uWS SSL app was configured in step 2 and never told about the new cert, so it serves
oldCert.
The user gets no error, no warning — just the old certificate. In Node.js, tls.Server.prototype.setSecureContext() on a live server replaces the default context immediately.
Why existing code doesn't prevent it
There is no guard. The new test file only covers the pre-listen case (setSecureContext replaces the default context before listen and setSecureContext on a server with no initial TLS options...), so the gap is acknowledged but unhandled. The asymmetry with addContext() — which now has both a post-listen native path and a post-listen test — makes this a half-applied fix to the original review comment.
Impact
Hot cert rotation is the canonical real-world use of setSecureContext() (Let's Encrypt renewal every ~60 days, without restarting the server). With this PR, that flow goes from a loud TypeError: server.setSecureContext is not a function (which at least tells the user it's unsupported) to silently continuing to serve the expiring cert. That's a security-relevant regression in observability — users will only discover it when clients start seeing certificate-expired errors in production.
How to fix
Ideally, mirror the addContext() fix: when this[serverSymbol] is set, call into the running server to swap the default SSL context (a native reload/set_default_context analogue to add_sni_context). If that's out of scope for this PR, at minimum detect the post-listen case and throw so the failure is loud:
Server.prototype.setSecureContext = function (options) {
if (options == null) return;
if (this[serverSymbol]) {
throw new Error('https.Server.setSecureContext() after listen() is not yet supported in Bun');
}
...
};A loud error is strictly better than a silent no-op for a security-relevant configuration call.
🔬 also observed by claude
There was a problem hiding this comment.
This matches the existing behavior of Bun's tls.Server#setSecureContext (tls.ts:749-817), which also only updates instance fields and does not call into the live handle; post-listen setSecureContext is a silent no-op there today too, so the https.Server implementation is at parity with it.
Unlike SNI contexts (where uWS exposes uws_add_server_name_with_options), there is no uWS primitive for swapping the default SSL_CTX on a running app, so wiring post-listen setSecureContext through properly needs new native plumbing on both the uWS C++ side and the Rust server. I did not add a throw because that would diverge from both Node.js (which applies the change) and Bun's own tls.Server (which silently ignores it), and would break code that calls setSecureContext on every renewal regardless of listen state.
Happy to follow up with the native default-context swap in a separate PR (it would fix tls.Server as well); leaving this open for a maintainer to decide whether it should block here.
7b35335 to
a028e90
Compare
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 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/https.ts`:
- Around line 84-91: The validation checks in the tlsOptionsFromContext function
and related TLS option validations use truthiness checks (if cert, if key, if
ca, if passphrase) that allow invalid falsy values like 0, false, and empty
strings to bypass type validation. Replace these truthiness checks with explicit
null and undefined checks instead. For example, change "if (passphrase && typeof
passphrase !== 'string')" to "if (passphrase != null && typeof passphrase !==
'string')" for the passphrase validation on line 89, and apply the same pattern
to all other TLS option validations including cert, key, and ca. Also apply this
same fix pattern to the similar validation logic mentioned at lines 140-153.
In `@src/js/node/tls.ts`:
- Around line 622-635: The function tlsStringToProtocolVersion() returns 0 for
invalid version strings, but this invalid result is not validated after the
conversion. In the newNativeSecureContext() function around lines 743-747 and in
the TLSSocket path around lines 1790-1801, add validation checks after each call
to tlsStringToProtocolVersion() that verify the returned value is not 0. If the
result is 0 (indicating an invalid protocol version string was passed), throw an
ERR_TLS_INVALID_PROTOCOL_VERSION error instead of silently using the invalid
value, to match Node.js behavior and prevent invalid strings like "invalid" from
being treated as valid protocol versions.
In `@src/runtime/server/server_body.rs`:
- Around line 2553-2564: The H3 SNI registration error handling in the
`Self::HAS_H3` block is incomplete because it only returns an error when
`add_server_name_with_options` fails AND `global.has_exception()` is false. This
causes the function to silently continue when an exception is already pending.
Remove the `&& !global.has_exception()` condition from the if statement so that
any failure from `add_server_name_with_options().is_err()` always returns an
error. Additionally, add a separate check after the if block to return
`Err(JsError::Thrown)` if `global.has_exception()` is true at that point,
mirroring the H1 error path pattern and ensuring no failures are swallowed.
In `@test/js/node/http/node-https-server-context.test.ts`:
- Around line 23-30: The peerCN function establishes an SNI connection but only
checks the peer certificate, not whether the correct route was actually selected
by SNI. Enhance the function to send an actual HTTP GET request over the TLS
socket after the secureConnect event completes, read and return the response
body instead of just the certificate CN, and ensure the caller asserts this
response matches the expected body for the a.example.com SNI-selected route.
This will prove the test fails when SNI routing is not working correctly.
🪄 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: 3e0185b7-de54-473b-b108-47eea20c3ae6
📒 Files selected for processing (7)
src/js/internal/http.tssrc/js/node/_http_server.tssrc/js/node/https.tssrc/js/node/tls.tssrc/runtime/node/node_http_binding.rssrc/runtime/server/server_body.rstest/js/node/http/node-https-server-context.test.ts
There was a problem hiding this comment.
Caution
Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.
Actionable comments posted: 4
🤖 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/https.ts`:
- Around line 84-91: The validation checks in the tlsOptionsFromContext function
and related TLS option validations use truthiness checks (if cert, if key, if
ca, if passphrase) that allow invalid falsy values like 0, false, and empty
strings to bypass type validation. Replace these truthiness checks with explicit
null and undefined checks instead. For example, change "if (passphrase && typeof
passphrase !== 'string')" to "if (passphrase != null && typeof passphrase !==
'string')" for the passphrase validation on line 89, and apply the same pattern
to all other TLS option validations including cert, key, and ca. Also apply this
same fix pattern to the similar validation logic mentioned at lines 140-153.
In `@src/js/node/tls.ts`:
- Around line 622-635: The function tlsStringToProtocolVersion() returns 0 for
invalid version strings, but this invalid result is not validated after the
conversion. In the newNativeSecureContext() function around lines 743-747 and in
the TLSSocket path around lines 1790-1801, add validation checks after each call
to tlsStringToProtocolVersion() that verify the returned value is not 0. If the
result is 0 (indicating an invalid protocol version string was passed), throw an
ERR_TLS_INVALID_PROTOCOL_VERSION error instead of silently using the invalid
value, to match Node.js behavior and prevent invalid strings like "invalid" from
being treated as valid protocol versions.
In `@src/runtime/server/server_body.rs`:
- Around line 2553-2564: The H3 SNI registration error handling in the
`Self::HAS_H3` block is incomplete because it only returns an error when
`add_server_name_with_options` fails AND `global.has_exception()` is false. This
causes the function to silently continue when an exception is already pending.
Remove the `&& !global.has_exception()` condition from the if statement so that
any failure from `add_server_name_with_options().is_err()` always returns an
error. Additionally, add a separate check after the if block to return
`Err(JsError::Thrown)` if `global.has_exception()` is true at that point,
mirroring the H1 error path pattern and ensuring no failures are swallowed.
In `@test/js/node/http/node-https-server-context.test.ts`:
- Around line 23-30: The peerCN function establishes an SNI connection but only
checks the peer certificate, not whether the correct route was actually selected
by SNI. Enhance the function to send an actual HTTP GET request over the TLS
socket after the secureConnect event completes, read and return the response
body instead of just the certificate CN, and ensure the caller asserts this
response matches the expected body for the a.example.com SNI-selected route.
This will prove the test fails when SNI routing is not working correctly.
🪄 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: 3e0185b7-de54-473b-b108-47eea20c3ae6
📒 Files selected for processing (7)
src/js/internal/http.tssrc/js/node/_http_server.tssrc/js/node/https.tssrc/js/node/tls.tssrc/runtime/node/node_http_binding.rssrc/runtime/server/server_body.rstest/js/node/http/node-https-server-context.test.ts
🛑 Comments failed to post (4)
src/js/node/https.ts (1)
84-91:
⚠️ Potential issue | 🟠 Major | ⚡ Quick winFalsy values bypass TLS option type validation.
Line 89 and Lines 140-153 use truthiness checks (
if (value && typeof value !== ...)), so invalid falsy values like0,false, and""pass validation and get written into TLS config.Suggested fix
- if (passphrase && typeof passphrase !== "string") { + if (passphrase !== undefined && passphrase !== null && typeof passphrase !== "string") { throw $ERR_INVALID_ARG_TYPE("options.passphrase", "string", passphrase); } @@ - if (passphrase && typeof passphrase !== "string") { + if (passphrase !== undefined && passphrase !== null && typeof passphrase !== "string") { throw $ERR_INVALID_ARG_TYPE("options.passphrase", "string", passphrase); } @@ - if (servername && typeof servername !== "string") { + if (servername !== undefined && servername !== null && typeof servername !== "string") { throw $ERR_INVALID_ARG_TYPE("options.servername", "string", servername); } @@ - if (secureOptions && typeof secureOptions !== "number") { + if (secureOptions !== undefined && secureOptions !== null && typeof secureOptions !== "number") { throw $ERR_INVALID_ARG_TYPE("options.secureOptions", "number", secureOptions); }Also applies to: 139-154
🤖 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/https.ts` around lines 84 - 91, The validation checks in the tlsOptionsFromContext function and related TLS option validations use truthiness checks (if cert, if key, if ca, if passphrase) that allow invalid falsy values like 0, false, and empty strings to bypass type validation. Replace these truthiness checks with explicit null and undefined checks instead. For example, change "if (passphrase && typeof passphrase !== 'string')" to "if (passphrase != null && typeof passphrase !== 'string')" for the passphrase validation on line 89, and apply the same pattern to all other TLS option validations including cert, key, and ca. Also apply this same fix pattern to the similar validation logic mentioned at lines 140-153.src/js/node/tls.ts (1)
622-635:
⚠️ Potential issue | 🟠 Major🧩 Analysis chain
🏁 Script executed:
# Check the actual state of src/js/node/tls.ts around the flagged lines sed -n '620,650p' src/js/node/tls.ts sed -n '740,750p' src/js/node/tls.ts sed -n '1785,1805p' src/js/node/tls.tsRepository: oven-sh/bun
Length of output: 2129
🏁 Script executed:
# Find where DEFAULT_MIN_VERSION and DEFAULT_MAX_VERSION are defined and exported rg 'DEFAULT_MIN_VERSION|DEFAULT_MAX_VERSION' src/js/node/tls.ts -B2 -A2Repository: oven-sh/bun
Length of output: 2635
🏁 Script executed:
# Check if these constants are exported and writable rg 'export.*DEFAULT_MIN_VERSION|export.*DEFAULT_MAX_VERSION' src/js/node/tls.tsRepository: oven-sh/bun
Length of output: 37
🏁 Script executed:
# Look at the Node.js TLS spec behavior for invalid protocol versions # Check what the native layer expects rg 'tlsStringToProtocolVersion|TLS1_VERSION' src/js/node/tls.ts -B2 -A2 | head -60Repository: oven-sh/bun
Length of output: 1193
🏁 Script executed:
# Check if there are tests for invalid TLS versions find test -name "*.test.ts" -o -name "*.test.js" | xargs rg -l "tls|TLS" | head -5Repository: oven-sh/bun
Length of output: 242
🏁 Script executed:
# Find test files specifically for node:tls fd -e test.ts -e test.js | rg tlsRepository: oven-sh/bun
Length of output: 1663
🏁 Script executed:
# Look at the node-tls-create-secure-context-args test head -100 test/js/node/tls/node-tls-create-secure-context-args.test.tsRepository: oven-sh/bun
Length of output: 3074
🏁 Script executed:
# Search for tests that explicitly test invalid version strings rg 'TLSv|minVersion|maxVersion' test/js/node/tls/node-tls-create-secure-context-args.test.ts -A3 -B1 | head -80Repository: oven-sh/bun
Length of output: 37
🏁 Script executed:
# Check if there's validation in the native layer by searching for error handling rg 'minVersion|maxVersion' src/runtime -A2 -B2 | head -100Repository: oven-sh/bun
Length of output: 1497
🏁 Script executed:
# Look for what happens when 0 is passed as version rg 'set_min_proto_version|set_max_proto_version|minVersion.*0' src/ -r -A2 -B2 | head -80Repository: oven-sh/bun
Length of output: 1165
🏁 Script executed:
# Search for how the native layer handles minVersion/maxVersion, especially 0 rg 'ssl_min_version|ssl_max_version' src/ -A3 -B3 | head -100Repository: oven-sh/bun
Length of output: 5931
🏁 Script executed:
# Check if there's any validation when a version is 0 rg 'minVersion.*0|maxVersion.*0' src/runtime -B2 -A2 | head -60Repository: oven-sh/bun
Length of output: 37
🏁 Script executed:
# Look for the actual native code that sets protocol versions rg 'SSL_CTX_set_min_proto_version|SSL_CTX_set_max_proto_version' src/ -B3 -A3 | head -80Repository: oven-sh/bun
Length of output: 915
🏁 Script executed:
# Search for what happens with the 0 return value from tlsStringToProtocolVersion rg 'tlsStringToProtocolVersion' src/js/node/tls.ts -A5 -B5Repository: oven-sh/bun
Length of output: 1370
🏁 Script executed:
# Check Node.js behavior: does it validate when you set tls.DEFAULT_MIN_VERSION? # Look at existing tests for setting these defaults rg 'DEFAULT_MIN_VERSION|DEFAULT_MAX_VERSION' test/js/node/tls/ -B3 -A3Repository: oven-sh/bun
Length of output: 1178
🏁 Script executed:
# Check if the native layer actually has defaults for when 0 is passed rg 'ssl_min_version.*0|ssl_max_version.*0' src/http/ -B5 -A5Repository: oven-sh/bun
Length of output: 681
🏁 Script executed:
# Search for where BoringSSL gets called with these versions rg 'SSL_CTX_set_min_proto_version|SSL_CTX_set_max_proto_version' src/ --type cpp --type rs -B2 -A2 | head -60Repository: oven-sh/bun
Length of output: 82
🏁 Script executed:
# Check what Node.js actually does - look at the github link in the comment rg 'secureProtocolToVersionRange|Node seeds the protocol' src/js/node/tls.ts -B5 -A15Repository: oven-sh/bun
Length of output: 3637
🏁 Script executed:
# Check if there are any tests setting invalid version strings to DEFAULT_MIN/MAX_VERSION rg 'DEFAULT_MIN_VERSION|DEFAULT_MAX_VERSION' test/js/node/tls -A10 | grep -A10 'invalid\|throw\|error'Repository: oven-sh/bun
Length of output: 37
🏁 Script executed:
# Look at Node.js source to understand what happens with invalid strings # Check if Node validates the version string when DEFAULT_MIN_VERSION is set # First, let's see what the actual behavior is supposed to be node -e "const tls = require('tls'); tls.DEFAULT_MIN_VERSION = 'invalid'; console.log(tls.DEFAULT_MIN_VERSION); const ctx = tls.createSecureContext(); console.log('created context');" 2>&1 || trueRepository: oven-sh/bun
Length of output: 825
🏁 Script executed:
# Since we can't run Node.js in this environment, check what the review expects # Let's see if there's any validation elsewhere in the code rg 'ERR_TLS_INVALID_PROTOCOL_VERSION' src/Repository: oven-sh/bun
Length of output: 1096
🏁 Script executed:
# Look at what happens when 0 is actually passed to the native layer # Check the C++ bindings that consume these values fd -e cpp -e h | xargs rg 'ssl_min_version|ssl_max_version' 2>/dev/null | head -40Repository: oven-sh/bun
Length of output: 305
🏁 Script executed:
# Check the exact lines referenced in the review to see current state sed -n '622,635p' src/js/node/tls.tsRepository: oven-sh/bun
Length of output: 338
🏁 Script executed:
# Check if validation is already present in the file rg '\$ERR_TLS_INVALID_PROTOCOL_VERSION' src/js/node/tls.ts -B5 -A2Repository: oven-sh/bun
Length of output: 700
🏁 Script executed:
# Get the full context of where validation happens sed -n '740,765p' src/js/node/tls.tsRepository: oven-sh/bun
Length of output: 1059
🏁 Script executed:
# Find VALID_TLS_VERSIONS definition rg 'VALID_TLS_VERSIONS' src/js/node/tls.ts -B5 -A5Repository: oven-sh/bun
Length of output: 1344
🏁 Script executed:
# Check the full context of the newNativeSecureContext function to see where validation happens sed -n '700,780p' src/js/node/tls.tsRepository: oven-sh/bun
Length of output: 3847
🏁 Script executed:
# Check the third location mentioned in "Also applies to: 743-747, 1790-1801" sed -n '1785,1810p' src/js/node/tls.tsRepository: oven-sh/bun
Length of output: 737
Validate translated TLS protocol versions; invalid DEFAULT_MIN/MAX_VERSION strings silently degrade bounds.
When
tlsStringToProtocolVersion()receives an invalid version string (including fromDEFAULT_MIN_VERSION/DEFAULT_MAX_VERSION), it returns0. The current code innewNativeSecureContext()(lines 743–747) converts these strings to numeric protocol versions but never validates the result. If a developer setstls.DEFAULT_MIN_VERSIONto an invalid string (e.g.,tls.DEFAULT_MIN_VERSION = "invalid"), andcreateSecureContext()is called without explicitminVersion, the invalid string silently converts to0and disables the version bound instead of throwing.Node.js rejects this with
ERR_TLS_INVALID_PROTOCOL_VERSIONat context creation time. Add validation after each string-to-number conversion to match that behavior:Suggested fix
} else { minVersion = tlsStringToProtocolVersion(optMinVersion ?? DEFAULT_MIN_VERSION); maxVersion = tlsStringToProtocolVersion(optMaxVersion ?? DEFAULT_MAX_VERSION); + if (minVersion === 0) throw $ERR_TLS_INVALID_PROTOCOL_VERSION(String(optMinVersion ?? DEFAULT_MIN_VERSION), "minimum"); + if (maxVersion === 0) throw $ERR_TLS_INVALID_PROTOCOL_VERSION(String(optMaxVersion ?? DEFAULT_MAX_VERSION), "maximum"); }Also applies to lines 1790–1801 in the TLSSocket path.
🤖 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` around lines 622 - 635, The function tlsStringToProtocolVersion() returns 0 for invalid version strings, but this invalid result is not validated after the conversion. In the newNativeSecureContext() function around lines 743-747 and in the TLSSocket path around lines 1790-1801, add validation checks after each call to tlsStringToProtocolVersion() that verify the returned value is not 0. If the result is 0 (indicating an invalid protocol version string was passed), throw an ERR_TLS_INVALID_PROTOCOL_VERSION error instead of silently using the invalid value, to match Node.js behavior and prevent invalid strings like "invalid" from being treated as valid protocol versions.src/runtime/server/server_body.rs (1)
2553-2564:
⚠️ Potential issue | 🟠 Major | ⚡ Quick winPropagate H3 SNI registration failures instead of falling through.
When the H3
add_server_name_with_optionscall fails whileglobal.has_exception()is already true, this condition is false and the method continues toset_routes()and returns success with a pending exception. Mirror the H1 error path and returnErr(JsError::Thrown)whenever the H3 registration fails or leaves an SSL/JS error pending. As per coding guidelines, “Never swallow a failure or signal success on one.”🐛 Proposed fix
if Self::HAS_H3 { if let Some(h3_app) = self.h3_app { - if bun_opaque::opaque_deref_mut(h3_app) + if bun_opaque::opaque_deref_mut(h3_app) .add_server_name_with_options(z, &ssl_opts) .is_err() - && !global.has_exception() { + if !global.has_exception() && !super::throw_ssl_error_if_necessary(global) { + return Err(global.throw(format_args!( + "Failed to add serverName \"{}\" for HTTP/3", + bstr::BStr::new(server_name.to_bytes()) + ))); + } + return Err(JsError::Thrown); + } + if super::throw_ssl_error_if_necessary(global) { + return Err(JsError::Thrown); + } - return Err(global.throw(format_args!( - "Failed to add serverName \"{}\" for HTTP/3", - bstr::BStr::new(server_name.to_bytes()) - ))); - } } }🤖 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 2553 - 2564, The H3 SNI registration error handling in the `Self::HAS_H3` block is incomplete because it only returns an error when `add_server_name_with_options` fails AND `global.has_exception()` is false. This causes the function to silently continue when an exception is already pending. Remove the `&& !global.has_exception()` condition from the if statement so that any failure from `add_server_name_with_options().is_err()` always returns an error. Additionally, add a separate check after the if block to return `Err(JsError::Thrown)` if `global.has_exception()` is true at that point, mirroring the H1 error path pattern and ensuring no failures are swallowed.Source: Coding guidelines
test/js/node/http/node-https-server-context.test.ts (1)
23-30:
⚠️ Potential issue | 🟡 Minor | ⚡ Quick winExercise the SNI-selected route, not only the
Hostheader.
fetch("https://127.0.0.1:...")does not usea.example.comas TLS SNI; theHostheader is sent after TLS context selection. This assertion can pass through the default route even if the new SNI domain never gets routes installed. Reusetls.connect({ host: "127.0.0.1", port, servername: "a.example.com" }), send an HTTP request over that socket, and assert the response body. As per coding guidelines, “Prove the test fails for the RIGHT reason” and “Every assertion must be able to fail, and assert the strongest invariant.”🧪 Proposed test helper shape
async function peerCN(port: number, servername?: string) { const socket = tls.connect({ host: "127.0.0.1", port, servername, rejectUnauthorized: false }); const errored = once(socket, "error"); await Promise.race([once(socket, "secureConnect"), errored.then(([e]) => Promise.reject(e))]); const cert = socket.getPeerCertificate(); socket.destroy(); return cert.subject?.CN; } + +async function httpsBodyViaSNI(port: number, servername: string) { + const socket = tls.connect({ host: "127.0.0.1", port, servername, rejectUnauthorized: false }); + const errored = once(socket, "error").then(([e]) => Promise.reject(e)); + try { + await Promise.race([once(socket, "secureConnect"), errored]); + socket.write(`GET / HTTP/1.1\r\nHost: ${servername}\r\nConnection: close\r\n\r\n`); + + const chunks: Buffer[] = []; + socket.on("data", chunk => chunks.push(chunk)); + await Promise.race([once(socket, "end"), once(socket, "close"), errored]); + + const response = Buffer.concat(chunks).toString("utf8"); + return response.slice(response.indexOf("\r\n\r\n") + 4); + } finally { + socket.destroy(); + } +}- const res = await fetch(`https://127.0.0.1:${port}/`, { - tls: { rejectUnauthorized: false, checkServerIdentity: () => undefined }, - headers: { Host: "a.example.com" }, - }); - expect(await res.text()).toBe("ok"); + expect(await httpsBodyViaSNI(port, "a.example.com")).toBe("ok");Also applies to: 95-99
🤖 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/http/node-https-server-context.test.ts` around lines 23 - 30, The peerCN function establishes an SNI connection but only checks the peer certificate, not whether the correct route was actually selected by SNI. Enhance the function to send an actual HTTP GET request over the TLS socket after the secureConnect event completes, read and return the response body instead of just the certificate CN, and ensure the caller asserts this response matches the expected body for the a.example.com SNI-selected route. This will prove the test fails when SNI routing is not working correctly.Source: Coding guidelines
|
Re the four CodeRabbit findings that failed to post inline (review 4517716418), addressed in 34b011c:
Declined with reasons:
|
|
CI build 63164: 284 passed, 2 failed. Neither failure touches this diff:
The new Ready for review. |
https.Server was a direct alias for http.Server, so none of the tls.Server prototype methods (addContext, setSecureContext, getTicketKeys, setTicketKeys) were available on servers created via https.createServer(). In Node.js https.Server extends tls.Server which provides these. Make https.Server its own class extending http.Server and implement addContext by buffering SNI contexts and passing them to Bun.serve as the tls array on listen; when called after listen, a new native binding (httpServerAddServerName) registers the SNI context on the running uWS SSL app. Repeated addContext for the same hostname replaces the previous context (last wins) instead of throwing. setSecureContext updates the default TLS config before listen and applies the same requestCert/rejectUnauthorized normalization as normalizeServerTls. getTicketKeys/setTicketKeys are stubbed the same way they are on tls.Server. Fixes #12157 Fixes #24474
baa7f22 to
23ef95d
Compare
removeServerName deletes the per-domain HttpRouter, but any keep-alive TLS connection accepted under that SNI still holds a ref on the per-domain SSL_CTX (via SSL_set_SSL_CTX in sni_cb) whose ex_data slot still points at the freed router. The next HTTP request on that connection would read it via us_socket_server_name_userdata() and dereference freed memory in HttpContext.h. Clear the us_sni_ex_idx ex_data slot on the SSL_CTX before deleting the router so existing connections fall back to the default router. Add us_internal_ssl_ctx_clear_sni_userdata helper in openssl.c since us_sni_ex_idx is file-static. Also defer the kSNIContexts splice in addContext until after the native call succeeds, so a failed add (malformed PEM) throws before the previous JS-side entry is dropped. New test exercises a keep-alive connection spanning an addContext re-add for the same hostname.
uWS's addServerName builds the SSL_CTX internally, so a malformed cert/key would fail after remove_server_name() had already stripped the previous working SNI entry, leaving the hostname to fall through to the default cert. Build a probe SSL_CTX via create_ssl_context first and throw before touching the existing entry if it fails, matching the Listener::add_server_name ordering. The probe is freed immediately; uWS still builds its own inside addServerName. Test verifies a re-add with a truncated PEM throws and the previous cert is still served.
once(socket, 'close') resolves to [boolean], so the previous '!got || got.length === 0' check never fired and a regression would surface as a confusing Buffer.concat TypeError. Have the close branch reject directly so the diagnostic is the intended message.
What does this PR do?
Fixes #12157.
Fixes #24474.
https.Serverwas a direct alias forhttp.Server, so none of thetls.Servermethods (addContext,setSecureContext,getTicketKeys,setTicketKeys) were available on servers created viahttps.createServer(). In Node.js,https.Serverextendstls.Server, which provides these.Reproduction
Fix
https.Serveris now its own class extendinghttp.Server(sohttpsServer instanceof http.Serveris stilltrue, andhttp.Serverdoes not gain these methods).addContext(hostname, context)validateshostnameand buffers the context. Whenlisten()is called, the buffered SNI contexts are passed toBun.servevia thetlsarray so eachserverNameis matched via SNI. When called afterlisten(), a new native binding (httpServerAddServerName) registers the SNI context on the running uWS SSL app viauws_add_server_name_with_optionsand reinstalls routes for that domain.setSecureContext(options)updates the default TLS config used atlisten()and applies the samerequestCert/rejectUnauthorizednormalization asnormalizeServerTlsso that providingcadoes not inadvertently require a client certificate.getTicketKeys()/setTicketKeys()are stubbed the same way as ontls.Server. Also fixed the existing"implented"typo in thetls.Serverstubs.Verification
New tests at
test/js/node/http/node-https-server-context.test.ts:https.Serverexposes all four methods and is anhttp.ServersubclassaddContextbeforelisten(): connecting with SNI"a.example.com"and"b.example.com"returns the respective per-hostname certs (CNagent1/agent3); an unknown SNI falls through to the default cert (CNagent2)addContextafterlisten(): same SNI behavior on a running server, plus an HTTPS request still reaches the request handlersetSecureContextbeforelisten()replaces the default certsetSecureContext({key, cert, ca})on a server created with no initial TLS options does not require a client certificateAll tests fail on
main(TypeError: server.addContext is not a function) and pass with this change. Existingtest/js/node/tls/node-tls-context.test.ts,test/js/node/tls/node-tls-server.test.ts,test/js/node/http/node-https-checkServerIdentity.test.ts, and thetest-https-*Node parallel tests still pass.Related: #31095 implements the same
https.Serversubclass split with method stubs; this PR additionally wiresaddContextto the underlying SNI mechanism (both at listen time and on a running server) so the contexts actually select per-hostname certificates.