Skip to content

tls: implement OCSP stapling (OCSPRequest / OCSPResponse) - #33297

Open
robobun wants to merge 2 commits into
mainfrom
farm/785ccecc/tls-ocsp-stapling
Open

tls: implement OCSP stapling (OCSPRequest / OCSPResponse)#33297
robobun wants to merge 2 commits into
mainfrom
farm/785ccecc/tls-ocsp-stapling

Conversation

@robobun

@robobun robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

node:tls accepted requestOCSP and documented the 'OCSPRequest' / 'OCSPResponse' events, then ignored all three. A server that served staples from an OCSPRequest listener never saw the event, and a client that set requestOCSP: true never heard back, so code that believes it is stapling silently isn't.

Repro

const server = tls.createServer({ key, cert }, c => c.end());
server.on("OCSPRequest", (certificate, issuer, callback) => {
  seen.push(["OCSPRequest", Buffer.isBuffer(certificate), Buffer.isBuffer(issuer)]);
  setTimeout(() => callback(null, Buffer.from("hello ocsp")), 10);
});
server.listen(0, () => {
  const client = tls.connect({ port: server.address().port, requestOCSP: true, rejectUnauthorized: false });
  client.on("OCSPResponse", resp => seen.push(["OCSPResponse", resp?.toString() ?? null]));
  client.on("close", () => server.close(() => console.log(JSON.stringify(seen))));
});
node: [["OCSPRequest",true,false],["OCSPResponse","hello ocsp"]]
bun:  []

Cause

Nothing in the TLS layer was wired to BoringSSL's OCSP hooks. requestOCSP was dropped on the floor, no server hook looked at the ClientHello's status_request extension, and no response was ever staged on the connection.

Fix

Server ('OCSPRequest'). BoringSSL's cert_cb is the one server hook that runs after the ClientHello extensions are parsed (so SSL_get_tlsext_status_type is meaningful) and after select_certificate_cb's SNI context swap (which re-derives the connection's CERT and would otherwise discard a response staged earlier), and can pause the handshake. us_cert_cb dispatches the event with the connection's certificate and issuer as DER buffers; a listener that answers synchronously stages its response inline, and an asynchronous one suspends the handshake (SSL_ERROR_WANT_X509_LOOKUP, parked the same way the existing async-SNICallback suspension is) until handle.resumeOCSP() re-drives it. callback(err) surfaces as 'tlsClientError' and drops the connection.

It is registered on every SSL_CTX rather than only on listeners that want it, because SSL_set_SSL_CTX re-derives the per-connection CERT from the SNI-selected context and cert_cb travels with the CERT. It short-circuits unless the handshake is a server one whose listener registered a handler and whose ClientHello asked for stapling.

Client ('OCSPResponse'). requestOCSP enables stapling on the connection before the ClientHello goes out, and BoringSSL's legacy OCSP callback (which it calls from ssl_verify_peer_cert, explicitly "to emulate OpenSSL's client OCSP callback") delivers the response from inside the handshake, the point Node emits the event from, so destroying the socket in the listener to reject a staple still prevents the session from being established.

The JS side of that dispatch is only registered when a connection actually asked for a staple (clientHandlersFor), the same way serverHandlersFor already strips the SNI/ALPN members from a listener that configures neither, so a plain net.connect() doesn't protect a callback that can never fire.

Also fixed along the way: destroying a client socket from inside a handshake callback still emitted 'secureConnect', because us_internal_ssl_close settles the handshake on its way out.

Verification

test/js/node/tls/node-tls-ocsp.test.ts covers async/sync/null responses, a DataView response, an empty response, falsy primitives handed back as the response, no listener, a client that never asks, TLSv1.2, issuer resolution, callback(err), a non-Buffer response, and destroying the client from the 'OCSPResponse' listener. 15 of the 16 fail on main; the sixteenth is the negative contract (no 'OCSPRequest' without a client that asks for it).

Node's own test/parallel/test-tls-ocsp-callback.js (already vendored, skipped without an openssl CLI next to the binary) now passes.

Differential against node v26.3.0 across the variant matrix

Identical output on both runtimes for: async staple on TLS 1.3, synchronous staple, callback(null, null), a client that does not request stapling, a server with no listener, TLS 1.2, issuer resolved via ca, callback(err), SNICallback + stapling on the same connection, and a 4 KiB response.

1) async staple (tls1.3) => ["OCSPRequest cert=true issuer=false","OCSPResponse \"hello world\"","secureConnect proto=TLSv1.3","secureConnection"]
2) sync staple          => ["OCSPRequest cert=true issuer=false","OCSPResponse \"sync staple\"","secureConnect proto=TLSv1.3","secureConnection"]
3) async null response  => ["OCSPRequest cert=true issuer=false","OCSPResponse null","secureConnect proto=TLSv1.3","secureConnection"]
4) client does not ask  => ["secureConnect proto=TLSv1.3","secureConnection"]
5) no server listener   => ["OCSPResponse null","secureConnect proto=TLSv1.3","secureConnection"]
6) tls1.2 async staple  => ["OCSPRequest cert=true issuer=false","OCSPResponse \"twelve\"","secureConnection","secureConnect proto=TLSv1.2"]
7) issuer via ca        => ["OCSPRequest cert=true issuer=true","OCSPResponse \"with issuer\"","secureConnect proto=TLSv1.3","secureConnection"]
8) callback(err)        => ["OCSPRequest cert=true issuer=false","tlsClientError boom","clientError ECONNRESET"]
9) SNICallback + ocsp   => ["OCSPRequest cert=true issuer=false","OCSPResponse \"sni+ocsp\"","secureConnect proto=TLSv1.3","secureConnection"]

Regression runs: all 151 test/js/node/test/parallel/test-tls-*.js, test/js/node/tls/ and test/js/node/net/ (each has the same failures with and without this diff: two of them also fail on a released bun in the same container, the third is a debug-build timeout), and test/js/bun/net/ with no new failures. bun run rust:check-all is clean across all 10 targets.

Rebase onto #31859

#31859 replaced Handlers's thirteen protected JSValues with a GC-visited JSInternalFieldObjectImpl cell, and moved the socket dispatches to ThisPtr<Self> + Rc<Handlers> + an Option<ThisPtr<TLSSocket>> ext slot. The rebase ports OCSP onto that design rather than onto the removed one:

  • Bun::JSSocketHandlers grows from 14 to 16 internal fields (OCSPRequest, OCSPResponse); the Rust Field enum and CALLBACK_COUNT follow.
  • Handlers::validate_callbacks returns the two new entries at the end of its array, and Handlers exposes on_ocsp_request() / on_ocsp_response() accessors.
  • NewSocket::on_ocsp_response, us_dispatch_ocsp_response and us_dispatch_ocsp_request now match the sibling dispatches (ThisPtr<Self>, handlers.enter() scope, Option<ThisPtr<TLSSocket>> ext slot) rather than raw *mut Self + enter_ref.

The squashed commit is 2011126f on top of fc865b398e; the seven pre-rebase commits are in e9e191c if per-step history is useful.

Also in this PR

scripts/build/codegen.ts and src/codegen/bindgenv2/script.ts carry a build-graph fix that is not about TLS: bindgenv2's generated .h files were never declared as ninja outputs, so adding a field to a .bindv2.ts dictionary (which this PR does) could leave an object file compiled against the old struct layout and link a binary that memcpys past the end of the other side's stack slot. It is explained in full here and is separable if you would rather land it on its own.

Not covered

TLS over a generic Duplex stream (tls.connect({ socket: someDuplexStream })) and Windows named pipes run on the SSLWrapper engine rather than a us_socket_t. Its private memory BIO leaves us_ocsp_status_cb with no socket to dispatch 'OCSPResponse' on, and the parked-queue mechanism its 'session'/'keylog' events use drains after the handshake, which is the wrong side of 'secureConnect' for this event. Node honors requestOCSP there, so this is a real compat gap, just a narrower one than the transport this PR fixes.

Rather than leave the same accepted-but-ignored shape behind, requestOCSP() now reports whether it took effect and the connect path warns once when it did not, instead of putting status_request on the wire and discarding the answer. Making it actually work needs a synchronous dispatch path through the SSLWrapper engine; that belongs in its own change.

Out of scope

socket.renegotiate() was raised alongside this. BoringSSL does not implement caller-initiated renegotiation in either role (ssl_lib.cc: "Caller-initiated renegotiation is not supported"), and Bun already reports that through the callback as ERR_TLS_RENEGOTIATION_UNSUPPORTED rather than silently doing nothing, so there is nothing to change there without patching BoringSSL.


[review] gate passed · iteration 7 · 23 files touched

fails on main (without fix)
ASAN without fix: 15 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/tls/node-tls-ocsp.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (3159ecf69)

test/js/node/tls/node-tls-ocsp.test.ts:
80 |   it("staples a response the server produces asynchronously", async () => {
81 |     const { serverLog, clientLog } = await exchange({
82 |       clientOptions: { requestOCSP: true },
83 |       onOCSPRequest: callback => setImmediate(callback, null, Buffer.from("hello ocsp")),
84 |     });
85 |     expect(serverLog).toEqual(["OCSPRequest certificate=Buffer issuer=undefined", "secureConnection"]);
                           ^
error: expect(received).toEqual(expected)

  [
-   "OCSPRequest certificate=Buffer issuer=undefined",
    "secureConnection",
  ]

- Expected  - 1
+ Received  + 0

      at <anonymous> (/workspace/bun/test/js/node/tls/node-tls-ocsp.test.ts:85:23)
(fa
... (truncated)

release without fix: 15 FAILED
bun test v1.3.14 (0d9b296a)

test/js/node/tls/node-tls-ocsp.test.ts:
80 |   it("staples a response the server produces asynchronously", async () => {
81 |     const { serverLog, clientLog } = await exchange({
82 |       clientOptions: { requestOCSP: true },
83 |       onOCSPRequest: callback => setImmediate(callback, null, Buffer.from("hello ocsp")),
84 |     });
85 |     expect(serverLog).toEqual(["OCSPRequest certificate=Buffer issuer=undefined", "secureConnection"]);
                           ^
error: expect(received).toEqual(expected)

  [
-   "OCSPRequest certificate=Buffer issuer=undefined",
    "secureConnection",
  ]

- Expected  - 1
+ Received  + 0

      at <anonymous> (/workspace/bun/test/js/node/tls/node-tls-ocsp.test.ts:85:23)
(fail) OCSP stapling > staples a response the server produces asynchronously [11.79ms]
89 |   it("staples a response the server produces synchronously", async () => {
90 |     const { serverLog, clientLog } = await exchange({
91 |       clientOptions: { requestOCSP: true },
92 |       onOCSPRequest: callback => callback(null, Buffer.from("sync ocsp")),
93 |     });
94 |     expect(serverLog).toEqual(["OCSPRequest certificate=Buff
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/tls/node-tls-ocsp.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (3159ecf69)

test/js/node/tls/node-tls-ocsp.test.ts:
(pass) OCSP stapling > staples a response the server produces asynchronously [789.36ms]
(pass) OCSP stapling > staples a response the server produces synchronously [108.80ms]
(pass) OCSP stapling > staples a response handed back as a DataView [111.73ms]
(pass) OCSP stapling > emits OCSPResponse with null when the server declines to staple [106.89ms]
(pass) OCSP stapling > emits OCSPResponse with null when the server has no OCSPRequest listener [81.74ms]
(pass) OCSP stapling > treats an empty response as no staple [109.22ms]
(pass) OCSP stapling > treats callback(null, 0) as no staple [83.22ms]
(pass) OCSP stapling > treats callback(null, false) as no staple [79.83ms]
(pass) OC
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     3159ecf69e
  features     (none)

22 deps, 114 codegen, 1168 objects in 633ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1230] install /workspace/bun
bun install v1.3.14 (0d9b296a)

Checked 124 installs across 170 packages (no changes) [10.00ms]
[2/1230] install /workspace/bun/packages/bun-error
bun install v1.3.14 (0d9b296a)

Checked 1 install across 2 packages (no changes) [2.00ms]
[3/1230] install /workspace/bun/src/node-fallbacks
bun install v1.3.14 (0d9b296a)

Checked 129 installs across 147 packages (no changes) [7.00ms]
[4/1230] gen ErrorCode+*.h
[5/1230] gen bindgenv2
[6/1230] gen .bind.ts → GeneratedBindings.cpp
[7/1230] fetch picohttpparser
[picohttpparser] up to date
[8/1230] fetch zlib
[zlib] up to dat
... (truncated)
diff hotspot
packages/bun-usockets/src/context.c              |   1 +
 packages/bun-usockets/src/crypto/openssl.c       | 207 +++++++++++++++++++-
 packages/bun-usockets/src/internal/internal.h    |   6 +
 packages/bun-usockets/src/libusockets.h          |  20 ++
 scripts/build/codegen.ts                         |  30 ++-
 src/codegen/bindgenv2/script.ts                  |   8 +
 src/js/node/net.ts                               | 153 ++++++++++++++-
 src/js/node/tls.ts                               |   7 +
 src/jsc/bindings/JSSocketHandlers.h              |   6 +-
 src/jsc/generated.rs                             |   6 +
 src/runtime/socket/Handlers.rs                   |  10 +
 src/runtime/socket/JSSocketHandlers.rs           |   8 +-
 src/runtime/socket/Listener.rs                   | 124 ++++++++++++
 src/runtime/socket/SocketConfig.bindv2.ts        |   2 +
 src/runtime/socket/socket_body.rs                |  77 ++++++++
 src/runtime/socket/sockets.classes.ts            |   8 +
 src/runtime/socket/tls_socket_functions.rs       | 151 +++++++++++++++
 src/runtime/socket/uws_dispatch.rs               |  34 ++++
 src/uws_sys/ListenSocket.rs                      |  15 ++
 src/uws_sys/socket.rs                            |  32 ++++
 src/uws_sys/us_socket_t.rs                       |  27 +++
 test/js/node/tls/node-tls-ocsp-duplex.fixture.ts |  57 ++++++
 test/js/node/tls/node-tls-ocsp.test.ts           | 232 +++++++++++++++++++++++
 23 files changed, 1192 insertions(+), 29 deletions(-)

gate history · 2 passed · 0 rejected · iteration 7

evidence per changed file
file                                           reads  edits  tests
packages/bun-usockets/src/context.c                1      1      0
packages/bun-usockets/src/crypto/openssl.c        11     19      0
packages/bun-usockets/src/internal/internal.h      1      2      0
packages/bun-usockets/src/libusockets.h            1      2      0
scripts/build/codegen.ts                           2      3      0
src/codegen/bindgenv2/script.ts                    1      1      0
src/js/node/net.ts                                28     31      0
src/js/node/tls.ts                                 2      4      0
src/jsc/bindings/JSSocketHandlers.h                1      1      0
src/jsc/generated.rs                               1      3      0
src/runtime/socket/Handlers.rs                     5     10      0
src/runtime/socket/JSSocketHandlers.rs             1      4      0
src/runtime/socket/Listener.rs                     5      7      0
src/runtime/socket/SocketConfig.bindv2.ts          1      2      0
src/runtime/socket/socket_body.rs                 10      9      0
src/runtime/socket/sockets.classes.ts              1      2      0
(+ 7 more files)

@github-actions github-actions Bot added the claude label Jul 3, 2026
@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:56 AM PT - Jul 9th, 2026

@robobun, your commit 3159ecf has 4 failures in Build #71085 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33297

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

bun-33297 --bun

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds OCSP stapling support across native TLS, Rust socket bindings, JS TLS/net plumbing, tests, and build graph output declaration handling. It wires OCSP request callbacks, stapled response delivery, async handshake suspension/resumption, and client request flags.

Changes

OCSP Stapling Implementation

Layer / File(s) Summary
uSockets/OpenSSL OCSP core
packages/bun-usockets/src/context.c, packages/bun-usockets/src/crypto/openssl.c, packages/bun-usockets/src/internal/internal.h, packages/bun-usockets/src/libusockets.h
Adds OCSP callback initialization, per-SSL pending state, OpenSSL cert/status callbacks, handshake suspension and resumption handling, and native APIs for staging, requesting, and resolving OCSP stapling.
Handler schema and listener registration
src/jsc/generated.rs, src/runtime/socket/Handlers.rs, src/runtime/socket/Listener.rs, src/runtime/socket/SocketConfig.bindv2.ts
Adds OCSP handler fields to the generated socket config and runtime handler state, then registers the server OCSP dispatch callback during listener setup.
Runtime socket OCSP helpers
src/runtime/socket/socket_body.rs, src/runtime/socket/tls_socket_functions.rs, src/runtime/socket/uws_dispatch.rs, src/uws_sys/ListenSocket.rs, src/uws_sys/socket.rs, src/uws_sys/us_socket_t.rs, src/runtime/socket/sockets.classes.ts
Adds OCSP response dispatch, TLS certificate extraction, request/resume helpers, native FFI wrappers, and socket methods for stapling and async resolution.
JS TLS and net OCSP wiring
src/js/node/tls.ts, src/js/node/net.ts
Tracks client requestOCSP, forwards it into native TLS setup, emits OCSPResponse, handles OCSPRequest listener outcomes, and guards handshake completion when OCSP handlers destroy sockets.
OCSP stapling test suite
test/js/node/tls/node-tls-ocsp.test.ts, test/js/node/tls/node-tls-ocsp-duplex.fixture.ts
Adds coverage for sync/async stapling, declined or missing handlers, issuer propagation, callback failures, destroyed sockets, and Duplex transport warning behavior.

Changes

Build graph updates

Layer / File(s) Summary
Bindgen output declarations
scripts/build/codegen.ts, src/codegen/bindgenv2/script.ts
Updates bindgenv2 output classification, declared header outputs, and supporting build comments for generated source/header pairs.
🚥 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 clearly and accurately summarizes the main change: adding OCSP stapling support for OCSPRequest/OCSPResponse.
Description check ✅ Passed The description covers the feature, root cause, fix, and verification, though it doesn't use the exact template headings.

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

@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/net.ts`:
- Line 783: The early return in the net state handling reads state.failed twice,
which triggers the JS lint rule; update the logic around the state.failed check
to cache that value in a local variable before using it in the condition and
return path. Keep the change within the same control flow in the relevant net.ts
state handling so the returned value still comes from state.failed while
avoiding the duplicate property read.

In `@src/runtime/socket/Listener.rs`:
- Around line 2111-2118: In `Listener::set_ocsp_response` handling, remove the
empty-bytes shortcut so explicit `Buffer`/TypedArray results always call
`tls.socket.get().set_ocsp_response(...)`, including `Buffer.alloc(0)`, and only
treat `null`/`undefined` as “no staple”. Keep the existing failure path with
`report(1)` for any rejected OCSP response, and update the async `resumeOCSP`
path to mirror the same validation logic if it has the same `bytes.is_empty()`
guard.

In `@src/runtime/socket/tls_socket_functions.rs`:
- Around line 1407-1412: The async OCSP flow in `resumeOCSP()` is ignoring the
failure from `socket.set_ocsp_response(bytes)` and still calling
`socket.ocsp_resolve()`, unlike the synchronous path. Update `resumeOCSP()` (and
the surrounding OCSP handling in `tls_socket_functions.rs`) to check the return
value from `set_ocsp_response`, and if it fails, surface that failure back to JS
so `failOCSPConnection(...)` can terminate the socket instead of resuming the
handshake. Keep the successful path unchanged, but only invoke `ocsp_resolve()`
when the OCSP response was accepted.

In `@src/runtime/socket/uws_dispatch.rs`:
- Around line 312-314: The OCSP response dispatch in
`TLSSocket::on_ocsp_response` is ignoring a `JsResult`, which can leave a
pending JS exception before returning to BoringSSL. Update the call site in
`uws_dispatch` to either handle the `Err` immediately by clearing/reporting the
exception before crossing the C ABI, or change `TLSSocket::on_ocsp_response` to
fully consume its error state and return `()`. Make sure the exception state is
not dropped after the JS-entrant call.
🪄 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: e6b478f5-6cde-4f17-920d-251b24bca2bd

📥 Commits

Reviewing files that changed from the base of the PR and between 1498d7b and 31f832d.

📒 Files selected for processing (18)
  • packages/bun-usockets/src/context.c
  • packages/bun-usockets/src/crypto/openssl.c
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/libusockets.h
  • src/js/node/net.ts
  • src/js/node/tls.ts
  • src/jsc/generated.rs
  • src/runtime/socket/Handlers.rs
  • src/runtime/socket/Listener.rs
  • src/runtime/socket/SocketConfig.bindv2.ts
  • src/runtime/socket/socket_body.rs
  • src/runtime/socket/sockets.classes.ts
  • src/runtime/socket/tls_socket_functions.rs
  • src/runtime/socket/uws_dispatch.rs
  • src/uws_sys/ListenSocket.rs
  • src/uws_sys/socket.rs
  • src/uws_sys/us_socket_t.rs
  • test/js/node/tls/node-tls-ocsp.test.ts

Comment thread src/js/node/net.ts Outdated
Comment thread src/runtime/socket/Listener.rs
Comment thread src/runtime/socket/tls_socket_functions.rs
Comment thread src/runtime/socket/uws_dispatch.rs 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.

I didn't find any bugs, but this wires new BoringSSL callbacks (cert_cb / tlsext_status_cb) onto every SSL_CTX and adds a new async handshake-suspension path with JS re-entering from inside the handshake — worth a human pass on the TLS/memory-safety side.

Extended reasoning...

Overview

Implements OCSP stapling for node:tls ('OCSPRequest' / 'OCSPResponse' events, requestOCSP option). The change spans 18 files across the C usockets layer (openssl.c, context.c, headers), Rust FFI/dispatch (uws_sys/, runtime/socket/), JS built-ins (net.ts, tls.ts), codegen inputs, and a new 10-case test file. Roughly 700 net-new lines.

Key mechanisms: registers SSL_CTX_set_cert_cb and SSL_CTX_set_tlsext_status_cb on every SSL_CTX built via us_ssl_ctx_build_raw; adds a new per-SSL ex_data slot (us_ssl_ocsp_pending_idx) with malloc'd suspension state; extends the handshake driver to treat SSL_ERROR_WANT_X509_LOOKUP as a park state; dispatches into JS from inside cert_cb / the OCSP status callback (with loop-state save/restore); adds resolve_issuer (X509 refcount management via chain certs + trust store); and adds two new JS-visible methods (requestOCSP, resumeOCSP) plus two new socket-handler slots.

Security risks

This is TLS-handshake-critical code. The new cert_cb is installed on every SSL context — including client contexts and the HTTP-client thread's contexts — and short-circuits based on SSL_is_server / listener-ex-data / status_request presence. A logic error there would affect all TLS connections, not just those opting into OCSP. JS runs from inside BoringSSL callbacks and can synchronously destroy the socket; the PR handles this via the existing ssl_in_use deferral and loop-state save/restore, but this re-entrancy surface is exactly the class the repo's review guidelines flag as most-blocked. The staple bytes are user-controlled and passed to SSL_set_ocsp_response (BoringSSL copies), and issuer resolution walks the trust store with +1 X509 refs.

Level of scrutiny

High. Per the approval guidelines this is (a) large and cross-cutting, (b) touches security-sensitive TLS/crypto paths, (c) introduces new global behavior on every SSL_CTX, and (d) involves FFI memory management (ex_data free callbacks, X509 refcounting, malloc'd suspension state) plus JS re-entrancy from inside the handshake. None of that is mechanical.

Other factors

The implementation is well-structured, closely mirrors the existing async-SNICallback suspension pattern, and comes with a thorough test matrix plus a differential against Node v26.3.0. The bug-hunting pass found nothing. That raises confidence, but doesn't change the scrutiny level a TLS-handshake change warrants — deferring to a human reviewer.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review in 9714433. Three of the four findings were real:

  • net.ts lint (no-duplicate-conditional-property-access): hoisted state.failed into a local. The same rule caught a second site I introduced while fixing the resumeOCSP one; bun run lint is clean.
  • resumeOCSP swallowed set_ocsp_response's failure and resumed the handshake anyway. It now returns a boolean, only calls ocsp_resolve() when the response was accepted, and onOCSPResolution tears the connection down through failOCSPConnection on false instead of completing a handshake without the staple the client asked for.
  • on_ocsp_response handed a pending JS exception back to BoringSSL: it now returns () and consumes the buffer-creation error itself (take_exception + call_error_handler).

Declined the fourth (Buffer.alloc(0) should go through BoringSSL validation rather than being treated as "no staple"), because Node completes that handshake rather than aborting it:

node v26.3.0: ["OCSPRequest","OCSPResponse null","secureConnect","secureConnection"]
bun:          ["OCSPRequest","OCSPResponse null","secureConnect","secureConnection"]

Node's setOCSPResponse stages a zero-length response and nothing ever reaches the wire, so at the protocol level Buffer.alloc(0) and "no staple" really are the same state. Aborting would be a divergence. I did make the sync and async paths decide that the same way, with a comment saying why, and pinned it with a treats an empty response as no staple test case.

Re-verified after the change: all 151 test/js/node/test/parallel/test-tls-*.js pass, node's test-tls-ocsp-callback.js passes, and test/js/node/tls/ is unchanged (the 3 remaining failures there reproduce on a released bun in the same container). The OCSP test file is now 11 cases, 10 of which fail on main.

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

I didn't find any bugs, but this is a substantial TLS-handshake change spanning BoringSSL callbacks (C), Rust FFI/dispatch with in-handshake JS reentrancy, and X509 refcount handling — worth a human pass on the memory-safety and abort/suspend semantics.

Extended reasoning...

Overview

This PR wires OCSP stapling through the full stack: new cert_cb/tlsext_status_cb hooks and an async-suspension ex_data slot in openssl.c, SSL_ERROR_WANT_X509_LOOKUP handling in the read/handshake loops, Rust FFI wrappers in uws_sys, two new extern "C" dispatches (us_dispatch_ocsp_request/_response) that invoke JS from inside the BoringSSL handshake, DER encoding and X509 issuer resolution with manual X509_up_ref/X509_STORE_CTX lifecycle in tls_socket_functions.rs, new prototype methods (requestOCSP/resumeOCSP) via .classes.ts, and the 'OCSPRequest'/'OCSPResponse' event plumbing in net.ts/tls.ts. 18 files, ~700 net lines.

Security risks

OCSP stapling is a TLS security feature and the change sits directly in the server/client handshake path. The tri-state abort_handshake out-param (0=proceed, 1=abort, 2=suspend) and its interaction with SSL_set_SSL_CTX (SNI context swap) determine whether a handshake completes, aborts, or parks; getting the ordering or a fall-through wrong could complete a handshake without the intended staple or hang a connection. The client-side status callback runs before the session is established so a listener can destroy the socket to reject a staple — the PR adds if (self.destroyed) return guards in the handshake completion path to make that stick. There's also new manual memory management: a us_calloc'd per-SSL pending struct freed via ex_data destructor, and resolve_issuer returning a +1 X509 that the caller must free.

Level of scrutiny

High. This is production TLS code with new C allocations, BoringSSL callback registration on every SSL_CTX, JS reentrancy from inside SSL_do_handshake/SSL_read (bracketed by us_internal_ssl_loop_state_save/restore), and cross-language ownership transfers. It follows the existing async-SNI suspension pattern closely, which is reassuring, but the surface area and criticality warrant a maintainer familiar with the uSockets SSL layer.

Other factors

Test coverage is solid (10 cases including sync/async/null/error/TLSv1.2/issuer/destroy-in-listener, plus the vendored Node test-tls-ocsp-callback.js), and the PR description documents a differential run against Node v26.3.0. Two CodeRabbit comments remain marked unresolved: the empty-buffer-as-no-staple behavior appears intentional (backed by a test and a code comment citing Node's semantics), and the JsResult-drop concern looks addressed (on_ocsp_response now returns () and consumes exceptions internally). No human review has happened yet.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

CI caught a real crash in the first version of this, fixed in d90f4b7. Flagging it because it's the kind of thing a reviewer should know the shape of.

Every proxy test segfaulted. The client-side OCSP callback recovered loop_ssl_data the way the other handshake callbacks in openssl.c do:

struct loop_ssl_data *lsd = BIO_get_data(SSL_get_wbio(ssl));
struct us_socket_t *s = lsd ? lsd->ssl_socket : NULL;

and I gated it on nothing, on the reasoning that the callback only fires once a connection enabled stapling, and the only thing that enables stapling is us_socket_request_ocsp_stapling — which only ever runs on a socket us_internal_ssl_attach gave the loop's shared BIO pair to.

That reasoning was wrong. configure_http_client_with_alpn (src/http/lib.rs) already calls SSL_enable_ocsp_stapling on every connection the HTTP client opens, so its ClientHello looks like a browser's. That includes the proxy tunnel, whose SSL is driven by the Rust SSLWrapper over a private memory BIO. BIO_get_data on that BIO returns the wrapper's own state, and reading ->ssl_socket off it is a wild pointer.

==12351==ERROR: AddressSanitizer: use-after-poison
    #0 us_ocsp_status_cb openssl.c:2258
    #1 bssl::ssl_verify_peer_cert(bssl::SSL_HANDSHAKE*) handshake.cc:331
    ...
    #8 <SSLWrapper<*mut HTTPClient>>::update_handshake_state src/uws/lib.rs:897
   #11 <ProxyTunnel>::receive src/http/ProxyTunnel.rs:755

The fix marks the SSLs whose BIO pair us_internal_ssl_attach installed (the only place that installs it) and gates the callback on that marker, plus on us_socket_kind(s) == BUN_SOCKET_KIND_BUN_SOCKET_TLS — the HTTP client's own sockets have no 'OCSPResponse' event to deliver anyway. The equivalent server-side callback was already safe: it reads the listener out of ex_data first and only touches the BIO once that proves the socket was attached.

Verified after the fix: proxy.test.ts (49), all seven proxy-stress-*.test.ts (839 total), the websocket/fetch proxy suites, and regression/issue/17793.test.ts (bun install → CONNECT tunnel → HTTPS registry, same ProxyTunnel path as the bun-install-proxy failure, which needs Docker so I can't run it locally). OCSP tests still 11/11, node's test-tls-*.js still 151/151.

Comment thread src/uws_sys/socket.rs Outdated
Comment thread test/js/node/tls/node-tls-ocsp.test.ts Outdated
Comment thread src/js/node/net.ts Outdated

@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/node/tls/node-tls-ocsp.test.ts`:
- Around line 242-247: The OCSP warning assertion in node-tls-ocsp.test.ts is
too strict because warnedOCSPUnsupported is process-global and only logs once.
Update the test around the secureConnect/secureConnection checks to either run
this case in an isolated subprocess or assert more flexibly so it passes whether
warnings contains the OCSP message or is empty due to prior requestOCSP calls.
🪄 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: 8d3463e6-3ea3-49dc-bf1a-c5920dc38150

📥 Commits

Reviewing files that changed from the base of the PR and between 31f832d and d2ba10d.

📒 Files selected for processing (7)
  • packages/bun-usockets/src/crypto/openssl.c
  • src/js/node/net.ts
  • src/runtime/socket/socket_body.rs
  • src/runtime/socket/tls_socket_functions.rs
  • src/runtime/socket/uws_dispatch.rs
  • src/uws_sys/socket.rs
  • test/js/node/tls/node-tls-ocsp.test.ts

Comment thread test/js/node/tls/node-tls-ocsp.test.ts Outdated
@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

77aa02a8 carries a build-system fix that doesn't look like it belongs in a TLS PR, so: here's why it's here, and why I think it's worth having on its own.

The bug

scripts/build/codegen.ts already documented this as a known tradeoff:

bindgenv2 emits Generated<Type>.h per type (list-outputs skips .h)"Compilation emits .d depfiles that track the .h files for NEXT build"

"Next build" is the hole. A .bindv2.ts dictionary gaining a field grows Generated<Name>.h while leaving the Generated<Other>.cpp that embeds it textually identical. Ninja settles the build plan before the codegen edge rewrites the header, and since the header isn't a declared output it never reconsiders the object — which keeps the struct layout it was last compiled against.

For a struct that crosses the C++/Rust FFI boundary, that links a binary whose C++ side memcpys past the end of the Rust side's stack slot:

==36390==ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 368 at 0x7ffcbd6b9100
    #1 bindgenConvertJSToSocketConfig GeneratedSocketConfig.cpp:193
    #2 <bun_jsc::generated::SocketConfig>::from_js src/jsc/generated.rs:723

Without ASAN it's silent stack corruption. It reproduces on any incremental build that adds or removes a field from a .bindv2.ts dictionary — I hit it three times before working out it wasn't my code.

The fix

Declare the headers as outputs of the codegen edge, so ninja rebuilds the objects whose depfiles name them. restat = 1 is already set on the rule, so unchanged headers still prune downstream.

$ ninja -C build/debug -t query codegen/GeneratedSocketConfigHandlers.h
codegen/GeneratedSocketConfigHandlers.h:
  input: codegen            # ← ninja now knows this edge produces it

The other undeclared-output cases named in that comment (bindgen.ts, bundle-modules.ts) are untouched; they have the same latent hole and should follow when someone is in there.

Why it surfaced here

The 'OCSPRequest'/'OCSPResponse' handlers needed two new slots on the SocketConfigHandlers dictionary, which is exactly the resize that triggers it. With the build producing a corrupt binary, the without-fix verification of this PR couldn't run at all — it crashed before reaching a single test, which reads as "no failures" rather than "build is broken". With the headers declared:

src+packages at main:  1 pass, 12 fail   ← the tests prove the fix
src+packages restored: 13 pass, 0 fail

Also in this commit, both from review: a DataView OCSP response is no longer rejected by a guard whose own error message listed DataView as acceptable, and the Duplex-transport case moved to a subprocess because the warning it asserts on fires once per process.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

On the alpine test-net-connect-memleak.js failure

The last three builds here were red on test/js/node/test/parallel/test-net-connect-memleak.js, alpine 3.23 x64 and x64-baseline (shard 18/20 on both). It is not caused by this diff: the same test fails on the same two lanes on branches that touch nothing near net/tls.

build branch files touched
#68197 child-process-spawn-error-contract src/js/node/child_process.ts
#68094 node26-dgram-compat dgram / udp
#67992 net-pending-after-half-close net

The test asserts that the implicit once('connect') listener is collectable after the event fires: globalThis.gc() (a synchronous full collect), then one setImmediate, then the FinalizationRegistry callback must already have run. It is a conservative-stack-scan coin flip, and musl-x64 loses it sometimes. There is no object-graph edge from this diff to the listener closure: the only thing it added to the plain-TCP connect path was one more protected JSValue pointing at a module-level function.

That said, the callback had no business being on that path at all, so e9e191c takes it off:

  • ocspResponse no longer sits on the default client handler table. A client can only ever receive an OCSPResponse if it connected with requestOCSP, so clientHandlersFor() picks the OCSP-carrying table only then. Every plain net.connect() now protects one fewer JSValue and skips one withAsyncContextIfNeeded(). This mirrors serverHandlersFor(), which already strips the SNI/ALPN members from a listener that configures neither.
  • The fd-adoption table (SocketHandlers) is only ever installed on a plain socket that never upgrades to TLS, so its OCSP members were dead and are gone.

test/js/node/tls/node-tls-ocsp.test.ts still passes 13/13, and test/js/node/tls/ has the same three failures with and without the diff (two fail on the released binary too; the third is a debug-build timeout).

@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 prior findings are addressed and I didn't spot anything new, but this wires new BoringSSL callbacks (cert_cb / tlsext_status_cb) onto every SSL_CTX, adds a handshake-suspension state, and carries an unrelated bindgenv2 build fix — someone familiar with openssl.c should sign off on the design.

Extended reasoning...

Overview

Implements node:tls OCSP stapling end-to-end: server-side 'OCSPRequest' via BoringSSL's cert_cb (with async handshake suspension over SSL_ERROR_WANT_X509_LOOKUP), client-side 'OCSPResponse' via the legacy status callback, plus the Rust/JS plumbing across bun-usockets, uws_sys, socket_body.rs, Listener.rs, net.ts/tls.ts, and generated bindings. It also folds in a scripts/build/codegen.ts / bindgenv2 fix so generated headers are declared ninja outputs. 21 files, ~600 lines added.

Security risks

This is TLS handshake code. SSL_CTX_set_cert_cb and SSL_CTX_set_tlsext_status_cb are now registered on every context (gated at runtime by listener/kind checks and the new us_ssl_loop_bio_idx marker), and SSL_ERROR_WANT_X509_LOOKUP is added to the pause conditions in both ssl_update_handshake and us_internal_ssl_on_data. JS listeners run from inside SSL_do_handshake and can destroy the socket reentrantly. The PR already surfaced one real ASAN crash during development (proxy tunnels' SSLWrapper BIO being misread as loop_ssl_data), which was fixed with the ex-data marker — but that's exactly the class of hazard that warrants a maintainer's eye on the callback-registration and BIO-recovery design.

Level of scrutiny

High. Native TLS handshake state machine + cross-language FFI (C ↔ Rust ↔ JS) + reentrant user callbacks + memory-lifetime management via SSL ex_data. The bindgenv2 build-system change is sound in isolation but is a separate concern riding in the same PR.

Other factors

Test coverage is thorough (12 cases + a subprocess fixture, differential-verified against Node v26.3.0, plus the 151 vendored test-tls-* and proxy suites re-run). All four CodeRabbit findings and my three prior inline findings were addressed with code changes and pinned tests. Nothing outstanding blocks review; this just isn't a change I can approve without a human who knows packages/bun-usockets/src/crypto/openssl.c confirming the hook placement and suspension semantics.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed on where the eyes are needed. A reviewer's map of the three decisions that are actually load-bearing, so whoever knows openssl.c can check them without reading the whole diff:

1. Why cert_cb and not tlsext_status_cb on the server (us_cert_cb)

cert_cb is the only server hook that runs after the ClientHello extensions are parsed (so SSL_get_tlsext_status_type answers) and after select_certificate_cb's SSL_set_SSL_CTX, which re-derives the connection's CERT and discards anything staged earlier, and can pause (rv < 0 -> SSL_ERROR_WANT_X509_LOOKUP). Staging from the status callback instead would lose the response on any SNI context swap.

2. Why it is registered on every SSL_CTX (us_ssl_ctx_build_raw)

ssl_cert_dup copies cert_cb/cert_cb_arg along with the CERT, so a callback installed only on listeners that want it would still have to survive the SNI swap into an arbitrary context. Registering unconditionally and short-circuiting is the cheaper invariant: us_cert_cb returns 1 immediately unless the handshake is a server one, the listener registered a handler, and the ClientHello asked for stapling.

3. Why the client status callback gates on an ex_data marker (us_ocsp_status_cb)

This is the one that bit. The callback recovers loop_ssl_data out of BIO_get_data(SSL_get_wbio(ssl)), which is only a loop_ssl_data for SSLs whose BIO pair us_internal_ssl_attach installed. Bun's HTTP client calls SSL_enable_ocsp_stapling() on every connection it opens (src/http/lib.rs), including proxy tunnels, which run on the Rust SSLWrapper's private memory BIO. Reading that as loop_ssl_data segfaulted every proxy test in CI. us_ssl_loop_bio_idx is set in us_internal_ssl_attach and the callback also checks the socket kind before dispatching.

The handshake suspension itself reuses the existing async-SNICallback parking: SSL_ERROR_WANT_X509_LOOKUP is added next to SSL_ERROR_PENDING_CERTIFICATE in both places that treat a paused handshake like WANT_READ (ssl_update_handshake, us_internal_ssl_on_data). No new suspension mechanism.

On the bindgenv2 change riding along: it is separable, and I'd rather it landed on its own if that's easier. It is here because adding a field to a .bindv2.ts dictionary is what exposed it, and without it the incremental build of this very PR links a binary that corrupts the stack. Details in this comment.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Status after the rebase: diff is green, CI is flaking on tests every other PR hits

test-net-connect-memleak.js is gone. It was the only failure that tracked this branch before the rebase (#68185, #68201, #68209, #68224), and alpine shard 18 is now green on both x64 lanes in #71085. #31859 replaced the per-connection gcProtect bookkeeping with a GC-visited cell, which takes the handler values off the conservative-stack path that test was losing a coin flip on.

#71085 at 3159ecf6: 282 jobs passed, 4 failed. Each of the four appears as an error annotation on many other branches in the last ~100 builds, none of which are near this diff's area:

test lane other branches hit
proxy-stress-concurrent.test.ts (1/1200 requests failed) darwin 14 x64 24 (e.g. blob-file-part-bytes, color-arena-churn, mkdtemp-encoding, fix-nested-npm-alias)
postgres-binary-array-bounds.test.ts (ERR_POSTGRES_CONNECTION_REFUSED) win 2019 x64-baseline 20+ (same class as postgres-invalid-message-length, e.g. worker-emfile-timer, streams-heap-analyzer)
26030.test.ts (mysql container) alpine 3.23 x64-baseline 8+ (vm-module-evaluate-idempotent, blob-file-part-bytes, ...)
webview.test.ts (host process SIGTRAP) darwin 14 aarch64 8+ (fix-nested-npm-alias, webstreams-audit-fixes, ...)

I checked proxy-stress-concurrent more closely because proxies are where this PR's one real CI crash lived earlier: that was a deterministic segfault on every proxy connection (reading loop_ssl_data out of the wrong BIO), fixed in d90f4b75, and it never recurred on any build after. This is 1 request out of 1200 failing on one darwin-x64 shard, and 24 unrelated branches hit it.

So: the diff is green, the new OCSP test is 16/16, all previously-attributed red is gone, and the four remaining failures are flake every other open PR sees. The one thing this is waiting on is a reviewer familiar with packages/bun-usockets/src/crypto/openssl.c signing off on the hook placement (map of the three load-bearing decisions is here).

Rebased onto fc865b3 across #31859's Handlers rework: the OCSP
callbacks are now two more fields on JSSocketHandlers
(JSInternalFieldObjectImpl<16>), and the socket dispatches use the
ThisPtr + Rc<Handlers> shape the sibling dispatches moved to.

Server side: BoringSSL's cert_cb (the one server hook that runs after
the ClientHello extensions are parsed, after select_certificate_cb's
SNI context swap, and can pause) dispatches 'OCSPRequest' with the
connection's certificate and issuer as DER; an asynchronous listener
suspends the handshake (SSL_ERROR_WANT_X509_LOOKUP, parked like the
existing async-SNICallback suspension) until handle.resumeOCSP()
re-drives it. callback(err) surfaces as 'tlsClientError' and drops the
connection.

Client side: requestOCSP enables stapling before the ClientHello goes
out, and BoringSSL's legacy OCSP callback delivers the response from
inside the handshake via us_dispatch_ocsp_response, so destroying the
socket in the listener still aborts the session.

Destroying a client socket from inside a handshake callback no longer
emits 'secureConnect'; us_internal_ssl_close settles the handshake on
its way out.

TLS over a generic Duplex / a Windows named pipe (the SSLWrapper engine)
warns once instead of silently accepting requestOCSP.

scripts/build/codegen.ts declares bindgenv2's generated headers as
ninja outputs, so adding a field to a .bindv2.ts dictionary no longer
leaves an object file compiled against the old struct layout.
@robobun
robobun force-pushed the farm/785ccecc/tls-ocsp-stapling branch from e9e191c to 2011126 Compare July 9, 2026 16:36
Comment thread src/js/node/net.ts
Node's onOCSP (internal/tls/wrap.js) gates on `if (response)`, not
`response != null`: callback(null, 0) / callback(null, false) /
callback(null, "") complete the handshake unstapled. A truthy non-Buffer
is still rejected the way Node's native setOCSPResponse would
(THROW_AND_RETURN_IF_NOT_BUFFER), only routed through 'tlsClientError'
instead of landing as an uncaught exception in the listener's call stack.
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.

1 participant