Skip to content

http: fix use-after-free in onHandshake when checkServerIdentity rejects (u9sf0l)#29829

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/96b6f3ed/https-checkserveridentity-uaf
Apr 28, 2026
Merged

http: fix use-after-free in onHandshake when checkServerIdentity rejects (u9sf0l)#29829
Jarred-Sumner merged 4 commits into
mainfrom
farm/96b6f3ed/https-checkserveridentity-uaf

Conversation

@robobun

@robobun robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

What

Fixes an ASAN use-after-poison in HTTPContext.onHandshake and ships the related node:https compat pieces that the grouped tests exercised once the crash was out of the way.

Repro

Any HTTPS request via node:https (or fetch()) with rejectUnauthorized: true, a trusted CA, and a hostname that does not match the certificate's identity:

const https = require("https");
// server uses agent1-cert.pem (CN=agent1, no SAN)
https.request({ port, ca, servername: "not-agent1" }, ...).end();

On ASAN builds this crashes with use-after-poison at src/http/HTTPContext.zig:420.

Cause

checkServerIdentity() returning false means it already called closeAndFail()terminateSocket() + fail()unregisterAbortTracker() + result callback → onAsyncHTTPCallback()threadlocal_http.deinit() destroys the AsyncHTTP that embeds the HTTPClient.

onHandshake then wrote client.flags.did_have_handshaking_error = true and called client.unregisterAbortTracker() on the freed client. Both operations were redundant (already done inside closeAndFail / fail).

ASAN poison-history trace:

Memory was manually poisoned by thread T12:
    ... mi_free ...
    http.AsyncHTTP.onAsyncHTTPCallback  src/http/AsyncHTTP.zig:402
    http.HTTPClientResult.Callback.run  src/http.zig:2296
    http.fail                           src/http.zig:2059
    http.closeAndFail                   src/http.zig:1712
    http.checkServerIdentity            src/http.zig:132
    http.HTTPContext...onHandshake      src/http/HTTPContext.zig:419

Fix

Use-after-free

In HTTPContext.zig and ProxyTunnel.zig, when checkServerIdentity returns false, return immediately without touching client/this — the socket is already terminated, the abort tracker already unregistered, and the result callback may have freed the client.

CN fallback in checkX509ServerIdentity

Once the crash was gone, all seven tests still hung: they use Node's fixture certs (agent1, agent3, rsa_cert) which carry only a Subject CN with no SAN extension. Bun's native checkX509ServerIdentity checked SAN only and rejected these. Node's tls.checkServerIdentity (and undici's fetch) fall back to the Subject CN when the certificate has no DNS/IP/URI SAN entries. Added that fallback for non-IP hostnames.

checkServerIdentity forwarding in https.request

_http_client.ts copied ca/cert/key/etc. from the merged agent/request options into the native TLS config but dropped checkServerIdentity. A user-supplied callback was never invoked; the native check ran instead. Now forwarded and validated.

requestCert/rejectUnauthorized forwarding in https.Server

_http_server.ts passed ca to Bun.serve({ tls }) but not requestCert/rejectUnauthorized. The uSockets SSL context treats ca with the (defaulted) rejectUnauthorized: true as SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, so https.createServer({ key, cert, ca }) rejected every client that didn't present a client cert. Node only requests a client cert when requestCert: true. Mirror the existing tls.Server workaround: default requestCert to false and, when not requesting, force rejectUnauthorized to false so the CA is loaded without requiring a client cert.

Verification

New test/js/node/http/node-https-checkServerIdentity.test.ts exercises all four fixes. Without this change it crashes with ASAN use-after-poison / times out; with it, all four cases pass.

The seven originally-grouped Node tests no longer crash but still need separate feature work to pass end-to-end (req.socket.authorized, agent.sockets/freeSockets tracking, res.socket.getSession(), server.setSecureContext()), so they are not checked in here.

When the native checkServerIdentity() rejects the peer certificate it
calls closeAndFail() -> fail() -> result callback, which can destroy the
AsyncHTTP (and its embedded HTTPClient). onHandshake then wrote to
client.flags and called client.unregisterAbortTracker() on freed memory.
Both operations were already performed inside closeAndFail/fail, so the
fix is to simply return after a false result. Applied to both
HTTPContext.zig and ProxyTunnel.zig.

While here, add the related Node.js-compat pieces the grouped tests
exercised once the crash was out of the way:

- checkX509ServerIdentity now falls back to the Subject CN when the
  certificate carries no DNS/IP/URI subjectAltName entries, matching
  Node's tls.checkServerIdentity.
- https.request forwards a user-supplied checkServerIdentity to the
  native fetch layer.
- https.Server forwards requestCert/rejectUnauthorized so a server with
  ca but no requestCert: true no longer rejects clients that don't
  present a client certificate.
@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 03e93085-6e8a-4cb2-bd19-def9d0d40317

📥 Commits

Reviewing files that changed from the base of the PR and between bcf213a and 6463c7c.

📒 Files selected for processing (2)
  • src/boringssl.zig
  • src/js/node/_http_server.ts

Walkthrough

Refactors TLS hostname verification to centralize DNS/wildcard matching and CN fallback behavior, caches IP-literal checks, adjusts handshake failure control flow to rely on checkServerIdentity side effects, propagates TLS checkServerIdentity and normalizes server client-cert options, and adds Node-compatible HTTPS tests.

Changes

Cohort / File(s) Summary
Crypto/TLS core
src/boringssl.zig
Introduces matchDnsName for RFC-aligned wildcard and case-insensitive DNS matching; tracks presence of SAN identifiers (has_identifier_san); uses host_is_ip cache; routes DNS SAN matching through helper; falls back to Subject CN only when no DNS/IP/URI SANs and host is not an IP literal.
HTTP handshake flow
src/http/HTTPContext.zig, src/http/ProxyTunnel.zig
Removes manual client-state mutation, abort-tracker unregistration, and explicit socket termination from post-handshake failure paths; relies on checkServerIdentity to call closeAndFail()/fail() and trigger the result callback instead.
Node HTTP client/server TLS options
src/js/node/_http_client.ts, src/js/node/_http_server.ts
ClientRequest now validates and stores a user checkServerIdentity callback in TLS options. Server TLS setup normalizes requestCert as boolean and forces rejectUnauthorized to false when client certs are not requested while still allowing ca to populate trust store.
Tests
test/js/node/http/node-https-checkServerIdentity.test.ts
Adds regression tests exercising Node-compatible https.request/https.Server behavior: SAN mismatch rejection, CN fallback when no SANs, user checkServerIdentity override, and server ca without requestCert allowing client connections.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing a use-after-free bug in onHandshake when checkServerIdentity rejects a certificate.
Description check ✅ Passed The description is comprehensive and well-structured, covering both required sections (What does this PR do, How did you verify) with detailed explanations of the root cause, fixes, and verification steps.
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.

✏️ 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 Apr 28, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:23 PM PT - Apr 28th, 2026

@Jarred-Sumner, your commit 6463c7c has 2 failures in Build #48596 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29829

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

bun-29829 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. http: fix ASAN use-after-poison in onHandshake after checkServerIdentity reject u9sf0l #29819 - Fixes the same ASAN use-after-poison in onHandshake when checkServerIdentity rejects (same u9sf0l tag); http: fix use-after-free in onHandshake when checkServerIdentity rejects (u9sf0l) #29829 is a superset that also adds CN fallback and requestCert forwarding

🤖 Generated with Claude Code

@robobun

robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • ✅ Reproduced ASAN use-after-poison at src/http/HTTPContext.zig:420 in onHandshake
  • ✅ Root cause: checkServerIdentity() returning false already ran closeAndFail()fail() → result callback → ThreadlocalAsyncHTTP.deinit(), freeing the HTTPClient. Caller then touched client.flags.
  • ✅ Fix applied to both HTTPContext.zig and ProxyTunnel.zig
  • ✅ Plus related node:https compat fixes needed by the same grouped tests (CN fallback, checkServerIdentity forwarding, requestCert/rejectUnauthorized forwarding)
  • ✅ Regression test in test/js/node/http/node-https-checkServerIdentity.test.ts — crashes/hangs without fix, 4/4 pass with it
  • ✅ Closed duplicate http: fix ASAN use-after-poison in onHandshake after checkServerIdentity reject u9sf0l #29819 (UAF-only subset)
  • ✅ Addressed CodeRabbit review (8dbbe45): strings.containsChar instead of std.mem.indexOfScalar; normalizeServerTls() applied to both constructor and listen({ tls }) paths

CI (build #48524)

node-https-checkServerIdentity.test.ts passed on every lane. The remaining failures are unrelated to this change and reproduce on other open PRs:

Failure Also on
scripts/build/ci.ts build failed (darwin-x64-build-cpp, win-x64-baseline-build-zig) #29831, #29830, #29828, #29825
test/js/bun/http/serve-http3.test.ts on Windows #29831, #29828
test/js/bun/test/parallel/test-integration-rspack.ts segfault on win11-aarch64 #29828

None of these touch files modified by this PR.

@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: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/boringssl.zig`:
- Around line 131-137: The code uses std.mem.indexOfScalar for wildcard label
checks; replace those calls with the bun.strings equivalent to comply with the
repo policy. Specifically, update the two usages inside the hostname/suffix
check (the call on suffix and the call on hostname[0..dot_index]) to the
bun.strings string-search API (matching the same semantics and return-value
checks) so the logic around dot_index, the dot comparison, and the single-label
wildcard constraint remains identical but uses bun.strings instead of std.mem.

In `@src/js/node/_http_server.ts`:
- Around line 253-271: The TLS normalization done in the constructor (using
isTlsSymbol/tlsSymbol and computing requestCert and rejectUnauthorized from
options.requestCert/options.rejectUnauthorized) must also be applied when
Server.prototype.listen receives a tls option; update the listen path that
replaces tls with arguments[0].tls to run the same normalization logic (compute
requestCert = !!tls.requestCert and rejectUnauthorized = requestCert ?
tls.rejectUnauthorized !== false : false) and then assign the normalized object
to this[tlsSymbol] or forward that normalized object into Bun.serve(), so
callers using server.listen({ tls: { key, cert, ca } }) do not inadvertently
require client certificates.
🪄 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: 4b892ead-c40e-425d-bdf5-250d019f1214

📥 Commits

Reviewing files that changed from the base of the PR and between 4d615e8 and bcf213a.

📒 Files selected for processing (6)
  • src/boringssl.zig
  • src/http/HTTPContext.zig
  • src/http/ProxyTunnel.zig
  • src/js/node/_http_client.ts
  • src/js/node/_http_server.ts
  • test/js/node/http/node-https-checkServerIdentity.test.ts

Comment thread src/boringssl.zig Outdated
Comment thread src/js/node/_http_server.ts Outdated

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

The UAF fix and option-forwarding look correct, but the CN-fallback in checkX509ServerIdentity and the normalizeServerTls change to rejectUnauthorized defaults are security-sensitive enough that a human should sanity-check them.

Extended reasoning...

Overview

This PR bundles four related fixes: (1) a UAF fix in HTTPContext.onHandshake / ProxyTunnel.onHandshake that removes writes to a freed HTTPClient after checkServerIdentity rejects, (2) a Subject-CN fallback in checkX509ServerIdentity (src/boringssl.zig) when the certificate carries no DNS/IP/URI SAN entries, (3) forwarding of the user-supplied checkServerIdentity callback in _http_client.ts, and (4) a new normalizeServerTls() in _http_server.ts that defaults requestCert to false and forces rejectUnauthorized: false when not requesting a client cert.

Security risks

Two of the four changes directly alter TLS trust decisions. The CN fallback widens the set of certificates accepted by the native hostname check — it is gated on !has_identifier_san (only GEN_DNS/GEN_IPADD/GEN_URI count) and !host_is_ip, which mirrors Node's tls.checkServerIdentity, but a subtle miss in the SAN-type accounting or the extracted matchDnsName wildcard logic would weaken hostname verification for every fetch()/https.request in Bun. The normalizeServerTls change flips an HTTPS server with { ca } from rejecting cert-less clients to accepting them; that is the Node-compatible behaviour, but it is still a deliberate relaxation of server-side TLS auth that deserves a second pair of eyes.

Level of scrutiny

High. checkX509ServerIdentity is on the trust boundary for all outbound TLS in the runtime, and _http_server.ts TLS defaults affect inbound auth. The UAF fix and checkServerIdentity forwarding are mechanically simple and well-explained, but they are bundled with the security-relevant pieces.

Other factors

CodeRabbit's two findings were addressed in 8dbbe45. CI passed the new test on every lane and the remaining failures are unrelated infra. The only bug-hunter finding is a test-style nit (describe.concurrent). Given the security surface, I'm deferring rather than approving.

ca: readFileSync(join(keys, "ca1-cert.pem"), "utf8"),
};

describe("https.request checkServerIdentity", () => {

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.

🟡 nit: per test/CLAUDE.md, tests that each spawn an independent subprocess should use describe.concurrent. All four tests here spawn isolated bunExe() children with port: 0 and no shared state, so this block can be describe.concurrent("https.request checkServerIdentity", …) to run them in parallel.

Extended reasoning...

What

test/CLAUDE.md (line 22) states:

Prefer concurrent tests over sequential tests: When multiple tests in the same file spawn processes or write files, make them concurrent with test.concurrent or describe.concurrent unless it's very difficult to make them concurrent.

This file declares its suite with plain describe("https.request checkServerIdentity", …) containing four test(...) cases, so they execute serially.

Why it applies here

Each of the four tests is fully self-contained:

  1. "hostname mismatch emits error without crashing"Bun.spawn([bunExe(), "-e", …]), child creates its own https.createServer on port: 0.
  2. "falls back to Subject CN when no SAN is present" — same pattern, own subprocess, own ephemeral port.
  3. "custom checkServerIdentity overrides the native check" — same pattern.
  4. "https.Server with ca but no requestCert accepts clients without a cert" — same pattern.

There is no shared mutable state at the test-file level (the only module-level values are the agent1 PEM strings, which are read-only), no beforeAll/afterAll, no shared temp directory, and no fixed port. Each subprocess listens on port: 0 and connects to server.address().port, so concurrent execution cannot collide. This is exactly the case the guideline targets.

Why nothing prevents it

There is no ordering dependency between the tests — none of them reads state produced by another. The await using proc + await Promise.all([stdout, stderr, exited]) pattern is already self-contained per test. Nothing in the file requires sequential execution; it's just the default describe.

Impact

Convention-only nit. Functionally the tests are correct as written. Running four TLS-handshaking subprocesses serially adds avoidable wall-clock time to the suite (especially on debug/ASAN builds where each child startup is slow), and diverges from the documented repo convention for subprocess-spawning test files.

Fix

One-token change on line 27:

-describe("https.request checkServerIdentity", () => {
+describe.concurrent("https.request checkServerIdentity", () => {

Step-by-step proof

  1. Line 27: describe("https.request checkServerIdentity", () => { — sequential block.
  2. Lines ~31, ~73, ~120, ~169: four test(...) bodies, each beginning with await using proc = Bun.spawn({ cmd: [bunExe(), "-e", …], env: bunEnv, … }).
  3. Inside each inline -e script: server.listen(0, …) then https.request({ port: server.address().port, … }) — no fixed port, no cross-test resource.
  4. No beforeAll/beforeEach/afterAll hooks; the only shared module-scope binding is the immutable agent1 cert/key/ca strings.
  5. test/CLAUDE.md line 22 says such files should use describe.concurrent. This file does not → convention violation.
  6. Changing describedescribe.concurrent lets all four subprocesses run in parallel with no possibility of interference, satisfying the guideline.

@Jarred-Sumner Jarred-Sumner merged commit fad191d into main Apr 28, 2026
75 of 77 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/96b6f3ed/https-checkserveridentity-uaf branch April 28, 2026 21:20
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…cts (u9sf0l) (oven-sh#29829)

## What

Fixes an ASAN use-after-poison in `HTTPContext.onHandshake` and ships
the related `node:https` compat pieces that the grouped tests exercised
once the crash was out of the way.

## Repro

Any HTTPS request via `node:https` (or `fetch()`) with
`rejectUnauthorized: true`, a trusted CA, and a hostname that does
**not** match the certificate's identity:

```js
const https = require("https");
// server uses agent1-cert.pem (CN=agent1, no SAN)
https.request({ port, ca, servername: "not-agent1" }, ...).end();
```

On ASAN builds this crashes with `use-after-poison` at
`src/http/HTTPContext.zig:420`.

## Cause

`checkServerIdentity()` returning `false` means it already called
`closeAndFail()` → `terminateSocket()` + `fail()` →
`unregisterAbortTracker()` + result callback → `onAsyncHTTPCallback()` →
**`threadlocal_http.deinit()` destroys the `AsyncHTTP` that embeds the
`HTTPClient`**.

`onHandshake` then wrote `client.flags.did_have_handshaking_error =
true` and called `client.unregisterAbortTracker()` on the freed
`client`. Both operations were redundant (already done inside
`closeAndFail` / `fail`).

ASAN poison-history trace:
```
Memory was manually poisoned by thread T12:
    ... mi_free ...
    http.AsyncHTTP.onAsyncHTTPCallback  src/http/AsyncHTTP.zig:402
    http.HTTPClientResult.Callback.run  src/http.zig:2296
    http.fail                           src/http.zig:2059
    http.closeAndFail                   src/http.zig:1712
    http.checkServerIdentity            src/http.zig:132
    http.HTTPContext...onHandshake      src/http/HTTPContext.zig:419
```

## Fix

### Use-after-free

In `HTTPContext.zig` and `ProxyTunnel.zig`, when `checkServerIdentity`
returns `false`, return immediately without touching `client`/`this` —
the socket is already terminated, the abort tracker already
unregistered, and the result callback may have freed the client.

### CN fallback in `checkX509ServerIdentity`

Once the crash was gone, all seven tests still hung: they use Node's
fixture certs (`agent1`, `agent3`, `rsa_cert`) which carry **only** a
Subject CN with no SAN extension. Bun's native `checkX509ServerIdentity`
checked SAN only and rejected these. Node's `tls.checkServerIdentity`
(and undici's `fetch`) fall back to the Subject CN when the certificate
has no DNS/IP/URI SAN entries. Added that fallback for non-IP hostnames.

### `checkServerIdentity` forwarding in `https.request`

`_http_client.ts` copied `ca`/`cert`/`key`/etc. from the merged
agent/request options into the native TLS config but dropped
`checkServerIdentity`. A user-supplied callback was never invoked; the
native check ran instead. Now forwarded and validated.

### `requestCert`/`rejectUnauthorized` forwarding in `https.Server`

`_http_server.ts` passed `ca` to `Bun.serve({ tls })` but not
`requestCert`/`rejectUnauthorized`. The uSockets SSL context treats `ca`
with the (defaulted) `rejectUnauthorized: true` as `SSL_VERIFY_PEER |
SSL_VERIFY_FAIL_IF_NO_PEER_CERT`, so `https.createServer({ key, cert, ca
})` rejected every client that didn't present a client cert. Node only
requests a client cert when `requestCert: true`. Mirror the existing
`tls.Server` workaround: default `requestCert` to `false` and, when not
requesting, force `rejectUnauthorized` to `false` so the CA is loaded
without requiring a client cert.

## Verification

New `test/js/node/http/node-https-checkServerIdentity.test.ts` exercises
all four fixes. Without this change it crashes with ASAN
use-after-poison / times out; with it, all four cases pass.

The seven originally-grouped Node tests no longer crash but still need
separate feature work to pass end-to-end (`req.socket.authorized`,
`agent.sockets`/`freeSockets` tracking, `res.socket.getSession()`,
`server.setSecureContext()`), so they are not checked in here.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
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