Skip to content

Bun.serve: reject tls objects that have no certificate - #35795

Closed
robobun wants to merge 7 commits into
mainfrom
farm/12db1f7a/serve-tls-requires-identity
Closed

robobun wants to merge 7 commits into
mainfrom
farm/12db1f7a/serve-tls-requires-identity

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

An explicit tls object that only carries tuning knobs (lowMemoryMode, requestCert, rejectUnauthorized, secureOptions, passphrase, serverName, ca) or only one half of the cert/key pair previously started an HTTPS listener with no usable server identity: server.url reported https:, plain HTTP clients got ECONNRESET, and TLS clients hung after ClientHello because no ServerHello could be sent. The same keys at the top level (the legacy v0.2 flattened shape) triggered the same dead listener via the back-compat read of the serve options object.

Repro

const s = Bun.serve({ port: 0, lowMemoryMode: true, fetch: () => new Response("hi") });
console.log(s.url.protocol);                              // https:
await fetch(`http://127.0.0.1:${s.port}/`);               // ECONNRESET

Cause

SSLConfig::from_generated sets its "any TLS option present" flag for each of these knobs, so the parser returned Some(SSLConfig) with cert/key still empty and ServerConfig created the listener in TLS mode. The v0.2.1 back-compat block in ServerConfig::from_js runs the same parser over the top-level serve options object when tls is absent, so a top-level lowMemoryMode: true took the same path.

Fix

SSLConfig::has_server_identity() reports whether the config carries both a certificate and a private key (inline PEM or file path). ServerConfig::from_js checks it after parsing:

  • tls: {...} / tls: [{...}] without both a cert and a key throws at construction time with tls object must specify both "cert" and "key" to start a TLS server.
  • top-level keys without both are ignored so the server stays plain HTTP, preserving the v0.2 back-compat only for the { cert, key } shape it existed for.

Bun.listen shares the underlying parser but is intentionally left out of this change: its SocketConfig path is shared with Bun.connect (where cert-less TLS is valid) and already accepts tls: true for both modes, so wiring the guard there needs a mode-aware placement plus a decision on what tls: true should mean for a listener. That belongs in a follow-up rather than bundled here.

Verification

test/js/bun/http/bun-serve-ssl.test.ts gains a tls option without a server identity block covering 9 no-identity shapes (7 tuning-only plus {cert}-only and {key}-only) across tls: {...}, tls: [{...}], and top-level placement, plus two back-compat guards (tls: {} stays HTTP, top-level {cert, key} still enables TLS). On the released binary 27 of the 29 new tests fail; with the fix all 45 tests in the file pass on both debug+ASAN and release builds.


no test proof · iteration 5 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/http/serve.test.ts

An explicit tls object that only carries tuning knobs (lowMemoryMode,
requestCert, rejectUnauthorized, secureOptions, passphrase, serverName,
ca) but no cert/key previously started an HTTPS listener with no server
identity: server.url reported https:, plain HTTP clients got ECONNRESET,
and TLS clients hung after ClientHello because no ServerHello could be
sent. The same keys at the top level (the legacy v0.2 flattened shape)
triggered the same broken listener via the back-compat read of the serve
options object.

SSLConfig::from_generated sets its "any TLS option present" flag for
each of these knobs, so the parser returned Some(SSLConfig) with
cert/key still empty and the listener was created in TLS mode.

Bun.serve now checks SSLConfig::has_server_identity() after parsing:

* tls: {...} / tls: [{...}] without a cert or key throws at construction
  time, since the user explicitly asked for TLS but the server could
  never complete a handshake.
* top-level keys without a cert or key are ignored so the server stays
  plain HTTP, preserving the v0.2 back-compat only for the {cert, key}
  shape it existed for.
@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with Bun.serve({ port: 0, lowMemoryMode: true, fetch }) on 1.4.0-canary: server.url.protocol is https: and fetch("http://127.0.0.1:PORT/") fails with ECONNRESET. Fix is in ServerConfig::from_js; 33/35 new tests in bun-serve-ssl.test.ts fail on the released binary and pass with the patch (verified on debug+ASAN and release locally).

CI build #82108 finished 194/196 green; bun-serve-ssl.test.ts and serve.test.ts pass on every lane. The two red jobs are unrelated:

  • binary-size compares against stale baseline #79916 and reports +~540 KB on every target; the same check is red on every other recent PR build (82109, 82106, 82105, 82103, 82101). This diff adds ~20 lines of Rust.
  • darwin 26 aarch64: tcp-server.test.ts harness crashed with uv_os_get_passwd returned ENOENT (box infra, reported separately).

Flaky-tagged warnings (webview-chrome.test.ts animation timing, 20144.test.ts SIGINT vs SIGKILL) are unrelated. Diff is ready for review.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 8 minutes

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

How can I continue?

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

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b32f3e6f-94f7-4bfd-983f-5aa9d91aea41

📥 Commits

Reviewing files that changed from the base of the PR and between 063a7fb and a861719.

📒 Files selected for processing (3)
  • docs/runtime/http/tls.mdx
  • test/js/bun/http/bun-serve-ssl.test.ts
  • test/js/bun/http/serve.test.ts

Walkthrough

Changes

Added server-identity detection to SSLConfig, enforced complete certificate and key pairs for explicit TLS configurations, preserved valid legacy TLS behavior, and added SSL validation tests.

TLS identity validation

Layer / File(s) Summary
Server identity detection
src/http/ssl_config.rs
SSLConfig::has_server_identity() reports whether both certificate and private key data are available inline or through file paths.
TLS option validation and coverage
src/runtime/server/ServerConfig.rs, test/js/bun/http/bun-serve-ssl.test.ts
TLS arrays and objects reject incomplete identities, incomplete legacy values remain HTTP, valid top-level credentials still enable HTTPS, and empty TLS options remain HTTP.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title matches the main change: rejecting TLS configs without a server certificate.
Description check ✅ Passed The description explains the change and verification, though it uses custom headings instead of the template ones.

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

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:16 PM PT - Jul 25th, 2026

❌ @robobun, your commit a861719 has 1 failures in Build #82108 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    ❌ bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    ❌ bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    ❌ bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    ❌ bun-linux-x6472.47 MB71.95 MB+528.0 KB
    ❌ bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    ❌ bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    ❌ bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    ❌ bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    ❌ bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    ❌ bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    ❌ bun-windows-x6480.26 MB79.70 MB+571.5 KB
    ❌ bun-windows-aarch6470.86 MB70.34 MB+533.5 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35795

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

bun-35795 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Beyond the two inline nits, I also checked: the explicit drop(ssl_config) before each new throw mirrors the existing SNI-error path and is leak-free (SSLConfig::Drop runs deinit); the top-level back-compat branch correctly drops the parsed config via scope exit when has_server_identity() is false; and tls: [{cert, key}, {serverName: "x"}] (SNI entry with only serverName) now throws at the new guard rather than reaching the later serverName check — still correct since a cert-less SNI entry is the same dead-handshake class.

Extended reasoning...

Two nits are posted inline covering the OR-vs-AND semantics of has_server_identity() and the unaddressed Bun.listen sibling. Both are pre-existing behavior, not regressions. This note records the additional paths examined so a later pass need not re-derive them.

Comment thread src/http/ssl_config.rs
Comment thread src/runtime/server/ServerConfig.rs
has_server_identity() now requires (cert || certFile) && (key || keyFile)
so a tls object with only one half of the pair is rejected too, and the
error message is rephrased to read correctly whether zero or one of the
two is present. Covers {cert}-only and {key}-only in the test block.
Comment thread src/http/ssl_config.rs Outdated
Comment thread src/runtime/server/ServerConfig.rs Outdated
Comment thread src/http/ssl_config.rs
Comment thread src/runtime/server/ServerConfig.rs

@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 `@test/js/bun/http/bun-serve-ssl.test.ts`:
- Around line 6-73: Expand the TLS test matrix around noIdentity to include
partial certFile/keyFile combinations, asserting that explicit tls objects and
arrays reject incomplete identities with the existing error. Add successful
Bun.serve cases for complete file-backed certFile/keyFile credentials and valid
mixed inline/file credentials, preferably using test.each while preserving the
existing top-level legacy and tls:{} coverage.
🪄 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: 9419d461-34a3-45bd-94a6-9f11b3f76830

📥 Commits

Reviewing files that changed from the base of the PR and between 04bb5c4 and 063a7fb.

📒 Files selected for processing (3)
  • src/http/ssl_config.rs
  • src/runtime/server/ServerConfig.rs
  • test/js/bun/http/bun-serve-ssl.test.ts

Comment thread test/js/bun/http/bun-serve-ssl.test.ts
Comment thread src/runtime/server/ServerConfig.rs
The "multiple missing server name" test used key-only entries, which now
throw the new "must specify both cert and key" error before the SNI
serverName check it is meant to exercise. Give each entry the harness
tls.cert/tls.key so both pass has_server_identity() and the second entry
reaches the serverName validation.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All three earlier findings are addressed (the ||→&& in has_server_identity, the Bun.listen exclusion now stated in the description, and the serve.test.ts SNI fixture). Deferring for a maintainer to sign off on the user-facing behavior change — an explicit tls: object without both cert and key now throws where it previously started a (dead) listener.

What was reviewed:

  • has_server_identity() requires (cert || certFile) && (key || keyFile); the three ServerConfig::from_js call sites (array entry, single object, legacy top-level) and their throw-vs-ignore split.
  • drop(ssl_config) before each return Err matches the existing SNI-error pattern just below; SSLConfig: Drop handles the C-string frees.
  • Test matrix: 11 no-identity shapes × 3 placements, tls: {} stays HTTP, top-level {cert, key} still enables HTTPS, and the updated serve.test.ts fixture reaches the SNI check via the harness tls import.
  • server.reload() reuses the same ServerConfig::from_js, so the new validation applies there too — consistent with the intent.
Extended reasoning...

Overview

Adds SSLConfig::has_server_identity() (a 2-line predicate over cert/cert_file_name and key/key_file_name) and wires it into the three TLS-parsing sites in ServerConfig::from_js: array entries and single tls: objects throw tls object must specify both "cert" and "key" to start a TLS server; the legacy v0.2 top-level scan silently drops the parsed config so the server stays plain HTTP. ~75 lines of new tests in bun-serve-ssl.test.ts cover 11 no-identity shapes across all three placements plus back-compat guards, and one existing serve.test.ts fixture is updated to carry a full cert+key so it still reaches the SNI-serverName check.

Security risks

None introduced. The change is a fail-closed validation: it rejects configurations that previously produced an HTTPS listener incapable of completing a handshake. It does not touch BoringSSL context creation, certificate verification, or any handshake path — only the JS-side option parser.

Level of scrutiny

Medium. The code change is small and mechanical (one predicate + three guards following the local drop(ssl_config); return Err(...) idiom). What warrants human sign-off is the API contract change: Bun.serve({ tls: { requestCert: true }, ... }) and every other tuning-only or half-identity tls object now throws at construction time. That is unambiguously better than the previous dead-listener behavior, but it is a deliberate tightening on a widely-used public API and per repo convention a maintainer should confirm the throw (vs. warn / silently downgrade to HTTP) is the desired UX.

Other factors

  • All three of my earlier inline findings were addressed in follow-up commits (6f17404, 063a7fb, e18e604, be735f4) and the threads are resolved. The Bun.listen sibling is intentionally excluded with rationale in the PR description.
  • The updated serve.test.ts fixture uses tls.key/tls.cert from the existing harness import (line 14), so no new import is needed.
  • server.reload() shares ServerConfig::from_js (server_body.rs:2322), so the guard applies uniformly there — expected and consistent.
  • The bug-hunting system found nothing on this revision.

Comment thread src/runtime/server/ServerConfig.rs
These two snippets showed tls:{...} without a cert or key, which now
throws at construction. The adjacent Passphrase and CA examples on the
same page already include key/cert alongside the highlighted field.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No issues found. All prior review feedback (has_server_identity() AND vs OR, the broken serve.test.ts SNI fixture, the tls.mdx doc snippets, and the Bun.listen scope note) has been addressed. Deferring to a human because this tightens user-facing Bun.serve validation — configs that previously started a (dead) HTTPS listener now throw at construction — and the top-level-ignore vs explicit-tls:-throw split is a small API decision worth a maintainer sign-off.

Checked this run: traced certFile/keyFile through from_generated — both set requires_custom_request_ctx → any = true, so from_js returns Some and the guard fires; handle_path's access() probe passes for the jsonwebtoken PEM fixtures used in the new tests. Grepped the test suite for other Bun.serve({ tls: {tuning-only} }) shapes that would newly throw — none beyond the already-fixed serve.test.ts SNI fixture; the many tls: { rejectUnauthorized: false } hits are all client-side (fetch/WebSocket).

Extended reasoning...

Overview

Adds SSLConfig::has_server_identity() (4-line predicate: (cert || certFile) && (key || keyFile)) and wires it into three sites in ServerConfig::from_js: the tls: [...] array loop, the single tls: {...} object branch, and the v0.2 top-level back-compat block. The first two throw tls object must specify both "cert" and "key" to start a TLS server; the third silently ignores so a top-level lowMemoryMode: true no longer arms a certificate-less HTTPS listener. Updates one existing serve.test.ts fixture so it still reaches its SNI-serverName assertion, adds ~35 tests in bun-serve-ssl.test.ts covering 11 no-identity shapes × 3 placements plus two back-compat guards, and adds key/cert to two tls.mdx doc snippets that would otherwise now throw.

Security risks

None introduced. This is defensive validation in the TLS-server construction path — it fails closed where the previous behavior started a listener that could never complete a handshake. No new parsing of untrusted input; no widening of accepted config shapes.

Level of scrutiny

Moderate. The Rust change is ~20 lines and mechanically simple, but it's a user-facing behavioral change to Bun.serve in a TLS-adjacent path: configs that previously "started" (uselessly) now throw. The top-level path deliberately ignores rather than throws — a reasonable call given accidental key-name collisions with unrelated serve options, and explained in the inline comment — but it's an API-shape decision. Bun.listen is intentionally left out (shared SocketConfig path with Bun.connect, needs mode-aware placement); the PR description documents this per the "say so if excluded" rule.

Other factors

This PR has been through four prior automated review rounds, each producing a concrete fix: (1) has_server_identity() changed from OR to AND with {cert}-only / {key}-only test coverage; (2) Bun.listen exclusion documented; (3) the serve.test.ts "multiple missing server name" fixture given a full cert+key pair so it reaches its named assertion; (4) two tls.mdx snippets given key/cert. All threads are resolved. The bug-hunting pass this run found nothing new. Test coverage is thorough (matrix over shapes × placements, back-compat guards, file-path variants). CI on the previous run had a fleet stall (zero test lanes ran); build #82108 is the re-run to watch.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

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

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants