Skip to content

node:https: add addContext and other tls.Server methods to https.Server#32435

Open
robobun wants to merge 4 commits into
mainfrom
farm/88a7a915/https-server-addcontext
Open

node:https: add addContext and other tls.Server methods to https.Server#32435
robobun wants to merge 4 commits into
mainfrom
farm/88a7a915/https-server-addcontext

Conversation

@robobun

@robobun robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes #12157.
Fixes #24474.

https.Server was a direct alias for http.Server, so none of the tls.Server methods (addContext, setSecureContext, getTicketKeys, setTicketKeys) were available on servers created via https.createServer(). In Node.js, https.Server extends tls.Server, which provides these.

Reproduction

import https from "https";

const server = https.createServer({ ca: "", cert: "", key: "" });
server.addContext("my-domain.com", { ca: "", cert: "", key: "" });
TypeError: server.addContext is not a function. (In 'server.addContext("my-domain.com", {...})', 'server.addContext' is undefined)

Fix

  • https.Server is now its own class extending http.Server (so httpsServer instanceof http.Server is still true, and http.Server does not gain these methods).
  • addContext(hostname, context) validates hostname and buffers the context. When listen() is called, the buffered SNI contexts are passed to Bun.serve via the tls array so each serverName is matched via SNI. When called after listen(), a new native binding (httpServerAddServerName) registers the SNI context on the running uWS SSL app via uws_add_server_name_with_options and reinstalls routes for that domain.
  • setSecureContext(options) updates the default TLS config used at listen() and applies the same requestCert/rejectUnauthorized normalization as normalizeServerTls so that providing ca does not inadvertently require a client certificate.
  • getTicketKeys() / setTicketKeys() are stubbed the same way as on tls.Server. Also fixed the existing "implented" typo in the tls.Server stubs.

Verification

New tests at test/js/node/http/node-https-server-context.test.ts:

  • https.Server exposes all four methods and is an http.Server subclass
  • addContext before listen(): connecting with SNI "a.example.com" and "b.example.com" returns the respective per-hostname certs (CN agent1 / agent3); an unknown SNI falls through to the default cert (CN agent2)
  • addContext after listen(): same SNI behavior on a running server, plus an HTTPS request still reaches the request handler
  • setSecureContext before listen() replaces the default cert
  • setSecureContext({key, cert, ca}) on a server created with no initial TLS options does not require a client certificate

All tests fail on main (TypeError: server.addContext is not a function) and pass with this change. Existing test/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 the test-https-* Node parallel tests still pass.

Related: #31095 implements the same https.Server subclass split with method stubs; this PR additionally wires addContext to the underlying SNI mechanism (both at listen time and on a running server) so the contexts actually select per-hostname certificates.

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds SNI (Server Name Indication) context support to Bun's HTTPS server by introducing a kSNIContexts symbol, a dedicated https.Server constructor with addContext and setSecureContext methods, TLS array assembly at listen time, and native binding/runtime support for dynamic SNI context registration after the server is listening.

Changes

HTTPS Server SNI Context Support

Layer / File(s) Summary
kSNIContexts symbol declaration
src/js/internal/http.ts
Declares and exports the kSNIContexts symbol for storing per-server SNI context entries.
https.Server constructor with SNI methods
src/js/node/https.ts, src/js/node/tls.ts
Introduces a new https.Server constructor that marks instances as TLS servers, normalizes TLS options via tlsOptionsFromContext, and adds addContext, setSecureContext, and stubbed ticket-key prototype methods. Updates createServer and the exported https object to use the new constructor. Corrects spelling in tls.Server ticket-key error messages for consistency.
SNI TLS array assembly in kRealListen
src/js/node/_http_server.ts
Imports kSNIContexts and extends kRealListen to detect non-empty SNI contexts on the server instance, prepend a base/default TLS entry, and append each SNI context into a TLS array for Bun.serve.
Native binding and runtime SNI support
src/runtime/node/node_http_binding.rs, src/runtime/server/server_body.rs
Adds http_server_add_server_name JS binding to dispatch SNI context registration to the appropriate server type. Implements add_sni_context method on NewServer to register per-host TLS contexts at runtime: validates SSL/listening state, parses TLS options, installs server names on uWS and H3 apps, and reinstalls routes.
Integration tests for https.Server SNI
test/js/node/http/node-https-server-context.test.ts
Adds test suite with helpers to load PEM fixtures and extract peer certificate subject CN via tls.connect. Asserts method exposure (addContext, setSecureContext, getTicketKeys, setTicketKeys), http.Server instanceof hierarchy, per-host certificate selection via addContext before/after listen, and default certificate replacement via setSecureContext.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately summarizes the main change: adding tls.Server methods to https.Server, which is the primary objective of the pull request.
Description check ✅ Passed The PR description includes both required template sections with comprehensive details: what the PR does (fixes, motivation, implementation), and how it was verified (new tests plus existing test compatibility).
Linked Issues check ✅ Passed The pull request fully addresses the linked issues. Issue #12157 requests addContext support on https.Server, and issue #24474 requests https.Server inherit tls.Server methods. The implementation adds all four methods (addContext, setSecureContext, getTicketKeys, setTicketKeys) with working SNI functionality.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing https.Server methods and SNI support. The typo fix in tls.Server is a minor, closely related improvement. No extraneous or unrelated code modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@robobun

robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:23 AM PT - Jun 18th, 2026

@robobun, your commit 462eff681c950d0fbe7d1de74025cc2fb111a0d5 passed in Build #63327! 🎉


🧪   To try this PR locally:

bunx bun-pr 32435

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

bun-32435 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. [node compatibility] https.Server should inherit from tls.Server #24474 - PR makes https.Server a proper subclass with tls.Server methods (addContext, setSecureContext, etc.), directly fixing the reported problem that https.createServer().addContext is undefined

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #24474

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:https: expose tls.Server API on https.Server #31095 - Same goal: makes https.Server its own class extending http.Server and exposes addContext, setSecureContext, getTicketKeys/setTicketKeys on it

🤖 Generated with Claude Code

@coderabbitai coderabbitai 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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 91ce3b4 and 0da5f6d.

📒 Files selected for processing (4)
  • src/js/internal/http.ts
  • src/js/node/_http_server.ts
  • src/js/node/https.ts
  • test/js/node/http/node-https-server-context.test.ts

Comment thread src/js/node/https.ts
Comment thread src/js/node/https.ts
Comment on lines +82 to +100
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,
};
}

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.

🔴 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.context

and 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);
  1. ctx is an InternalSecureContext with own props { context: <native>, servername: undefined }.
  2. addContext calls tlsOptionsFromContext(ctx) → destructures { key, cert, ca, ... } = ctx → all undefined.
  3. if (cert) / if (key) validation is skipped (falsy), no error thrown.
  4. Returns { key: undefined, cert: undefined, ca: undefined, passphrase: undefined, secureOptions: undefined, requestCert: false, rejectUnauthorized: false }.
  5. entry.serverName = 'a.example.com' is set and the entry is pushed to kSNIContexts.
  6. At listen(), this empty config is passed to Bun.serve as an SNI entry with no certificate — the handshake for a.example.com will 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/js/node/https.ts
Comment thread src/js/node/https.ts Outdated
Comment thread src/js/node/https.ts
Comment on lines +118 to +119
Server.prototype.setSecureContext = function (options) {
if (options == null) return;

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.

🔴 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
  1. Constructor sets this[tlsSymbol] = normalizeServerTls({ key: oldKey, cert: oldCert, ... }).
  2. listen() reads this[tlsSymbol] once and calls Bun.serve({ tls: {key: oldKey, cert: oldCert, ...} }). this[serverSymbol] is now set.
  3. setSecureContext({key: newKey, cert: newCert}) runs: const tls = this[tlsSymbol] || {...} → mutates tls.key = newKey, tls.cert = newCertthis[tlsSymbol] = tls. No check for this[serverSymbol], no native call. Returns undefined.
  4. 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@robobun robobun force-pushed the farm/88a7a915/https-server-addcontext branch from 7b35335 to a028e90 Compare June 17, 2026 16:30

@coderabbitai coderabbitai 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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 0da5f6d and a028e90.

📒 Files selected for processing (7)
  • src/js/internal/http.ts
  • src/js/node/_http_server.ts
  • src/js/node/https.ts
  • src/js/node/tls.ts
  • src/runtime/node/node_http_binding.rs
  • src/runtime/server/server_body.rs
  • test/js/node/http/node-https-server-context.test.ts

@coderabbitai coderabbitai 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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 0da5f6d and a028e90.

📒 Files selected for processing (7)
  • src/js/internal/http.ts
  • src/js/node/_http_server.ts
  • src/js/node/https.ts
  • src/js/node/tls.ts
  • src/runtime/node/node_http_binding.rs
  • src/runtime/server/server_body.rs
  • test/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 win

Falsy values bypass TLS option type validation.

Line 89 and Lines 140-153 use truthiness checks (if (value && typeof value !== ...)), so invalid falsy values like 0, 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.ts

Repository: 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 -A2

Repository: 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.ts

Repository: 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 -60

Repository: 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 -5

Repository: oven-sh/bun

Length of output: 242


🏁 Script executed:

# Find test files specifically for node:tls
fd -e test.ts -e test.js | rg tls

Repository: 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.ts

Repository: 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 -80

Repository: 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 -100

Repository: 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 -80

Repository: 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 -100

Repository: 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 -60

Repository: 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 -80

Repository: 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 -B5

Repository: 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 -A3

Repository: 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 -A5

Repository: 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 -60

Repository: 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 -A15

Repository: 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 || true

Repository: 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 -40

Repository: 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.ts

Repository: 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 -A2

Repository: 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.ts

Repository: oven-sh/bun

Length of output: 1059


🏁 Script executed:

# Find VALID_TLS_VERSIONS definition
rg 'VALID_TLS_VERSIONS' src/js/node/tls.ts -B5 -A5

Repository: 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.ts

Repository: 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.ts

Repository: 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 from DEFAULT_MIN_VERSION / DEFAULT_MAX_VERSION), it returns 0. The current code in newNativeSecureContext() (lines 743–747) converts these strings to numeric protocol versions but never validates the result. If a developer sets tls.DEFAULT_MIN_VERSION to an invalid string (e.g., tls.DEFAULT_MIN_VERSION = "invalid"), and createSecureContext() is called without explicit minVersion, the invalid string silently converts to 0 and disables the version bound instead of throwing.

Node.js rejects this with ERR_TLS_INVALID_PROTOCOL_VERSION at 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 win

Propagate H3 SNI registration failures instead of falling through.

When the H3 add_server_name_with_options call fails while global.has_exception() is already true, this condition is false and the method continues to set_routes() and returns success with a pending exception. Mirror the H1 error path and return Err(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 win

Exercise the SNI-selected route, not only the Host header.

fetch("https://127.0.0.1:...") does not use a.example.com as TLS SNI; the Host header is sent after TLS context selection. This assertion can pass through the default route even if the new SNI domain never gets routes installed. Reuse tls.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

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Re the four CodeRabbit findings that failed to post inline (review 4517716418), addressed in 34b011c:

  • server_body.rs H3 SNI error propagation: fixed; the H3 path now mirrors the H1 path and returns Err(JsError::Thrown) on any failure instead of falling through when an exception is already pending.
  • Test: verify SNI route, not just cert: fixed; the post-listen test now sends an HTTP request over a tls.connect({ servername }) socket and asserts both the served cert CN and the response body, so it fails if routes are not installed for the new SNI domain.

Declined with reasons:

  • https.ts truthiness checks on cert/key/ca/passphrase: these match the existing validation in _http_server.ts (lines 234-264) and tls.ts exactly; changing only https.ts would diverge from the established convention for the same options.
  • tls.ts tlsStringToProtocolVersion validation: this PR's only change to tls.ts is the "implented" typo fix in the ticket-key stubs. The flagged code (newNativeSecureContext, TLSSocket path) is pre-existing and unrelated to https.Server#addContext.

Comment thread src/js/node/https.ts
Comment thread src/js/node/https.ts Outdated
Comment thread src/js/node/https.ts
@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

CI build 63164: 284 passed, 2 failed. Neither failure touches this diff:

  • test/js/bun/http/serve-body-leak.test.ts on debian-13-x64-asan: end_memory 541 MB vs ≤512 MB threshold (~5% over) in the "buffering a JSON body" case; this is a Bun.serve request-body memory threshold test under ASAN and does not exercise node:https.
  • test/js/bun/terminal/terminal.test.ts on darwin-14-x64: "creates subprocess with terminal attached" timed out after 90s; this is Bun.spawn PTY handling.

The new test/js/node/http/node-https-server-context.test.ts suite and the other node:https/node:tls suites pass on every platform. Previous runs on this branch (63153, 63160) failed only on similarly unrelated lanes (autobahn docker platform mismatch, puppeteer download, Windows hot-reload flakes).

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
@robobun robobun force-pushed the farm/88a7a915/https-server-addcontext branch from baa7f22 to 23ef95d Compare June 18, 2026 12:44
Comment thread src/runtime/server/server_body.rs
Comment thread src/runtime/server/server_body.rs
Comment thread src/runtime/server/server_body.rs
robobun added 2 commits June 18, 2026 13:15
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.
Comment thread test/js/node/http/node-https-server-context.test.ts
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.
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.

[node compatibility] https.Server should inherit from tls.Server Node's https server doesn't have addContext function

1 participant