Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c6b2abc
tls: ask for the server name on the stream engine, one wrap for tls a…
robobun Oct 15, 2026
a3923b5
test: the http2 FIN test reads the backlog of the TLS socket, one nod…
robobun Oct 15, 2026
d441806
test: an injected http2 socket is released when its session goes down
robobun Oct 15, 2026
be22348
http2: keep the upgrade wrap, it leaves in a pull of its own
robobun Oct 16, 2026
45b476d
tls: suspend the stream engine at the select-certificate callback the…
robobun Oct 16, 2026
21cccdc
test: server names on every injected door, with the answers an SNICal…
robobun Oct 16, 2026
7a586f6
test: SNICallback over the socket of an http 'connect' event
robobun Oct 16, 2026
059036d
Merge remote-tracking branch 'origin/main' into robobun/12858a05/tls-…
robobun Oct 16, 2026
76774ae
tls: keep the early data of a suspended handshake for BoringSSL, up t…
robobun Oct 17, 2026
1d7d36f
tls: shorten the comments of the stream engine's server-name step
robobun Oct 17, 2026
b35e626
Merge remote-tracking branch 'origin/main' into robobun/12858a05/tls-…
robobun Oct 17, 2026
d30ba30
tls test: replay the first flight of an early-data client, with no op…
robobun Oct 17, 2026
6b2a101
tls: a server-side socket does not set its servername option as the S…
robobun Oct 17, 2026
7f51049
tls test: send exactly the early-data limit, and one byte more
robobun Oct 17, 2026
cbae857
tls test: more answers of an SNICallback over a stream, and a deadlin…
robobun Oct 17, 2026
91696b3
tls test: count only the contexts that answered, and settle the refus…
robobun Oct 17, 2026
e23abf7
Merge remote-tracking branch 'origin/main' into robobun/12858a05/tls-…
robobun Oct 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions packages/bun-usockets/src/crypto/openssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ static void us_ssl_rare_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad,

/* Defined in src/uws/lib.rs. `wrapper` is what us_ssl_set_wrapper stored. */
extern int us_ssl_wrapper_server_identity(void *wrapper, SSL *ssl);
extern int us_ssl_wrapper_select_cert(void *wrapper, SSL *ssl);
extern int us_ssl_wrapper_identity_checked(void *wrapper);
extern void us_ssl_wrapper_new_session(void *wrapper, SSL_SESSION *session);
extern void us_ssl_wrapper_keylog(void *wrapper, const char *line, size_t length);
Expand Down Expand Up @@ -3199,7 +3200,7 @@ void us_ssl_ctx_set_sni_policy(SSL_CTX *ctx, int request_cert, int reject_unauth
* authenticated again, against this context's CA. That refusal is deliberate
* (RFC 6066 section 3; openssl/ssl.h: "partition session caches between SNI
* hosts") and must not be relaxed for parity with another runtime. */
static void us_ssl_apply_selected_ctx(SSL *ssl, SSL_CTX *ctx) {
void us_ssl_apply_selected_ctx(SSL *ssl, SSL_CTX *ctx) {
SSL_set_SSL_CTX(ssl, ctx);
if (us_ctx_sni_policy_ex_idx < 0) return;
uintptr_t policy = (uintptr_t)SSL_CTX_get_ex_data(ctx, us_ctx_sni_policy_ex_idx);
Expand Down Expand Up @@ -3266,7 +3267,12 @@ static size_t us_client_hello_servername(const SSL_CLIENT_HELLO *hello, char *ou
static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *hello) {
SSL *ssl = hello->ssl;
struct us_socket_t *s = ssl ? us_ssl_socket(ssl) : NULL;
if (!s) return ssl_select_cert_success;
if (!s) {
/* An SSL that no us_socket_t drives: its wrapper decides (src/uws/lib.rs). */
void *wrapper = ssl ? us_ssl_wrapper(ssl) : NULL;
if (wrapper && !us_ssl_wrapper_select_cert(wrapper, ssl)) return ssl_select_cert_retry;
return ssl_select_cert_success;
}
struct us_ssl_rare_t *rare = us_ssl_rare(ssl);
struct us_server_names_t *names = rare ? rare->server_names : NULL;

Expand Down Expand Up @@ -3481,6 +3487,12 @@ void us_socket_on_server_name(struct us_socket_t *s) {
if (ctx) SSL_CTX_set_select_certificate_cb(ctx, us_select_cert_cb);
}

/* As us_socket_on_server_name, for an SSL that no us_socket_t drives. */
void us_ssl_wrapper_on_server_name(SSL *ssl) {
SSL_CTX *ctx = SSL_get_SSL_CTX(ssl);
if (ctx) SSL_CTX_set_select_certificate_cb(ctx, us_select_cert_cb);
}

void *us_socket_server_name_userdata(struct us_socket_t *s) {
if (!s->ssl || !s_ssl(s) || us_sni_ex_idx < 0) return NULL;
return SSL_CTX_get_ex_data(SSL_get_SSL_CTX(s_ssl(s)), us_sni_ex_idx);
Expand Down
4 changes: 4 additions & 0 deletions packages/bun-usockets/src/libusockets.h
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,10 @@ int us_ssl_take_error_reason(char *reason, size_t length);
void us_ssl_set_wrapper(struct ssl_st *ssl, void *wrapper);
/* `ctx` is the X509_STORE_CTX of a verify callback. */
void *us_ssl_wrapper_from_verify(void *ctx);
/* Switches `ssl` to the context that its server name selected. */
void us_ssl_apply_selected_ctx(struct ssl_st *ssl, struct ssl_ctx_st *ctx);
/* Makes the select-certificate step of `ssl` ask its wrapper (us_ssl_wrapper_select_cert). */
void us_ssl_wrapper_on_server_name(struct ssl_st *ssl);
/* Owner data for us_dispatch_new_session. `on_free(sink)` runs once, on SSL_free. */
void us_socket_set_session_sink(us_socket_r s, void *sink, void (*on_free)(void *));
void *us_socket_session_sink(us_socket_r s);
Expand Down
1 change: 1 addition & 0 deletions src/boringssl_sys/boringssl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,7 @@ pub const SSL_ERROR_WANT_READ: c_int = 2;
pub const SSL_ERROR_WANT_WRITE: c_int = 3;
pub const SSL_ERROR_SYSCALL: c_int = 5;
pub const SSL_ERROR_ZERO_RETURN: c_int = 6;
pub const SSL_ERROR_PENDING_CERTIFICATE: c_int = 12;
pub const SSL_ERROR_WANT_RENEGOTIATE: c_int = 19;

pub const SSL_VERIFY_NONE: c_int = 0x00;
Expand Down
1 change: 1 addition & 0 deletions src/http/ProxyTunnel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,7 @@ impl ProxyTunnel {
on_session: None,
on_keylog: None,
server_identity: Some(server_identity),
on_server_name: None,
ctx: this.as_erased_ptr().as_ptr(),
},
) {
Expand Down
1 change: 1 addition & 0 deletions src/http_jsc/websocket_client/WebSocketProxyTunnel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ impl WebSocketProxyTunnel {
on_session: None,
on_keylog: None,
server_identity: Some(Self::server_identity),
on_server_name: None,
},
)
.map_err(|_| crate::Error::InvalidOptions)?;
Expand Down
17 changes: 8 additions & 9 deletions src/js/node/net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1098,22 +1098,21 @@ function toSNIError(err) {

// Applies one SNICallback resolution to the dispatch state. Node assigns
// `sni_context = context.context || context`: both the SecureContext wrapper
// and a raw native context are accepted, null/undefined falls through to the
// and a raw native context are accepted, a falsy or primitive value keeps the
// default context, and anything else is an invalid SNI context that drops the
// connection before the handshake completes.
function consumeSNIResult(state, err, context) {
if (err) {
state.failed = toSNIError(err);
return;
}
if (context == null) return;
const innerContext = typeof context === "object" ? context.context : undefined;
if (innerContext) {
state.selected = innerContext;
} else if (state.server?.[kNativeSecureContextCtor] && context instanceof state.server[kNativeSecureContextCtor]) {
state.selected = context;
} else {
state.failed = new Error("Invalid SNI context");
if (!context) return;
const selected = context.context || context;
const NativeSecureContext = state.server?.[kNativeSecureContextCtor];
if (NativeSecureContext && selected instanceof NativeSecureContext) {
state.selected = selected;
} else if (typeof selected === "object" || typeof selected === "function") {
state.failed = new TypeError("Invalid SNI context");
}
}

Expand Down
88 changes: 56 additions & 32 deletions src/runtime/socket/Listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2057,39 +2057,55 @@ pub(crate) extern "C" fn us_dispatch_socket_server_name(
dispatch_server_name(socket, hostname, abort_handshake).cast()
}

/// Shared decoding of what the JS SNI handler returned. See
/// `us_dispatch_server_name` for the contract.
fn decode_sni_result(result: JSValue, abort_handshake: *mut core::ffi::c_int) -> *mut c_void {
if result.is_boolean() && result.to_boolean() {
if !abort_handshake.is_null() {
// SAFETY: live out-parameter for the duration of this dispatch.
unsafe { *abort_handshake = 2 };
/// What the JS `serverName` handler answered (the contract is at `us_dispatch_server_name`).
pub(crate) enum ServerNameAnswer {
/// undefined / null: the default context (on a listener, its SNI tree first).
Default,
/// A native SecureContext, for this handshake only.
Context(boring_sys::OwnedSslCtx),
/// `true`: the SNICallback is asynchronous. `handle.resumeSNI(...)` answers.
Pending,
/// An Error, a throw, or any other value: the connection is dropped with no TLS alert.
Refused,
}

impl ServerNameAnswer {
fn from_js(result: JSValue) -> Self {
if result.is_boolean() && result.to_boolean() {
return Self::Pending;
}
if result.to_error().is_some() {
return Self::Refused;
}
if result.is_undefined_or_null() {
return Self::Default;
}
match result.as_class_ref::<SecureContext>() {
Some(sc) => {
let ctx = sc.ctx.clone();
super::socket_body::install_sni_alpn_selector(ctx.as_ptr());
Self::Context(ctx)
}
// Node treats anything else as an invalid SNI context.
None => Self::Refused,
}
return core::ptr::null_mut();
}
if result.to_error().is_some() {

/// The form `us_dispatch_server_name` returns to openssl.c.
fn into_raw(self, abort_handshake: *mut core::ffi::c_int) -> *mut c_void {
let code = match self {
Self::Default => return core::ptr::null_mut(),
// The C dispatcher frees this +1 after `SSL_set_SSL_CTX` takes its own.
Self::Context(ctx) => return ctx.into_raw().cast(),
Self::Refused => 1,
Self::Pending => 2,
};
if !abort_handshake.is_null() {
// SAFETY: live out-parameter for the duration of this dispatch.
unsafe { *abort_handshake = 1 };
unsafe { *abort_handshake = code };
}
return core::ptr::null_mut();
core::ptr::null_mut()
}
if result.is_undefined_or_null() {
return core::ptr::null_mut();
}
if let Some(sc) = result.as_class_ref::<SecureContext>() {
// The C dispatcher frees this +1 after `SSL_set_SSL_CTX` takes its own.
let ctx = sc.ctx.clone().into_raw();
super::socket_body::install_sni_alpn_selector(ctx);
return ctx.cast();
}
// Anything else is not a SecureContext: Node treats this as an invalid SNI
// context and drops the connection.
if !abort_handshake.is_null() {
// SAFETY: live out-parameter for the duration of this dispatch.
unsafe { *abort_handshake = 1 };
}
core::ptr::null_mut()
}

/// `openssl.c`'s `us_select_cert_cb` (the early select-certificate callback)
Expand Down Expand Up @@ -2134,18 +2150,28 @@ fn dispatch_server_name(
Some(tls) => tls,
None => return core::ptr::null_mut(),
};
// SAFETY: `hostname` is NUL-terminated per the fn contract.
let name = unsafe { core::ffi::CStr::from_ptr(hostname) };
ask_server_name(tls, name).into_raw(abort_handshake)
}

/// Asks the `serverName` handler of a server-side socket, for both TLS engines.
pub(crate) fn ask_server_name(
tls: bun_ptr::ThisPtr<TLSSocket>,
name: &core::ffi::CStr,
) -> ServerNameAnswer {
// An idle socket can drop its Handlers while the us_socket_t lives on;
// `get_handlers()` would panic. Same guard as `select_alpn_callback`.
if !tls.has_handlers() {
return core::ptr::null_mut();
return ServerNameAnswer::Default;
}
// No `Handlers::enter`/`exit` scope here: that protocol tracks the
// accepted-socket callback lifecycle, and running it from inside the
// handshake corrupts `active_connections` for every subsequent accept.
let handlers = tls.get_handlers();
let callback = handlers.on_server_name();
if callback.is_empty() {
return core::ptr::null_mut();
return ServerNameAnswer::Default;
}
let global = handlers.global_object;
// The resume handle an asynchronous SNICallback uses (`handle.resumeSNI(...)`).
Expand All @@ -2159,8 +2185,6 @@ fn dispatch_server_name(
None => TLSSocket::data_get_cached(socket_handle),
}
.unwrap_or(JSValue::UNDEFINED);
// SAFETY: `hostname` is NUL-terminated per the fn contract.
let name = unsafe { core::ffi::CStr::from_ptr(hostname) };
// Peer-supplied SNI bytes, decoded as Latin-1 like Node's `OneByteString`.
let js_name = EncodedSlice::latin1(name.to_bytes()).to_js(&global);
let result = match callback.call(&global, this_value, &[this_value, js_name, socket_handle]) {
Expand All @@ -2176,5 +2200,5 @@ fn dispatch_server_name(
// threw) -> abort the handshake; the connection is dropped without an
// alert and the JS side emits 'tlsClientError' from the
// handshake-failure path with the stashed error.
decode_sni_result(result, abort_handshake)
ServerNameAnswer::from_js(result)
}
42 changes: 42 additions & 0 deletions src/runtime/socket/UpgradedDuplex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ pub(crate) struct Handlers {
pub(crate) on_keylog: fn(*mut (), &[u8]),
pub(crate) server_identity:
fn(*mut (), &mut bun_boringssl_sys::SSL) -> bun_boringssl::ServerIdentity,
/// Server wraps only: see `ssl_wrapper::Handlers::on_server_name`.
pub(crate) on_server_name: Option<fn(*mut (), &CStr)>,
}

use crate::jsc_hooks::timer_all_mut as timer_all;
Expand Down Expand Up @@ -211,6 +213,25 @@ impl UpgradedDuplex {
unsafe { &*this }.finish_close();
}

fn on_server_name(this: *mut Self, name: &CStr) {
bun_output::scoped_log!(UpgradedDuplex, "onServerName");
// SAFETY: see handler note above.
let this = unsafe { &*this };
if let Some(on_server_name) = this.handlers.on_server_name {
on_server_name(this.handlers.ctx, name);
}
}

/// Answers `Handlers::on_server_name`. A closed engine drops the answer.
pub(crate) fn resolve_server_name(
&self,
answer: Result<Option<bun_boringssl_sys::OwnedSslCtx>, ()>,
) {
if let Some(w) = self.wrapper_ref() {
w.resolve_server_name(answer);
}
}

pub(super) fn finish_close(&self) {
// Keep the wrapper (and so its visited `duplex*` slots) reachable
// across `handlers.on_close`, which downgrades the socket's own strong
Expand Down Expand Up @@ -535,6 +556,10 @@ impl UpgradedDuplex {
on_session: Some(Self::on_session),
on_keylog: Some(Self::on_keylog),
server_identity: Some(Self::server_identity),
on_server_name: self
.handlers
.on_server_name
.map(|_| Self::on_server_name as fn(*mut UpgradedDuplex, &CStr)),
}
}

Expand Down Expand Up @@ -862,6 +887,23 @@ extern "C" fn UpgradedDuplex__set_inline_reject(this: *const c_void) {
}
}

/// `handle.resumeSNI(ctx, isError)`: takes the reference `ctx` carries.
#[unsafe(no_mangle)]
extern "C" fn UpgradedDuplex__sni_resolve(
this: *const c_void,
ctx: *mut bun_boringssl_sys::SSL_CTX,
error: bool,
) {
// SAFETY: `ctx` is null or carries a reference the caller gives up.
let ctx = unsafe { bun_boringssl_sys::OwnedSslCtx::from_raw(ctx) };
// SAFETY: `this` is a live `*const UpgradedDuplex` from the uws_sys opaque handle.
unsafe { &*this.cast::<UpgradedDuplex>() }.resolve_server_name(if error {
Err(())
} else {
Ok(ctx)
});
}

#[unsafe(no_mangle)]
extern "C" fn UpgradedDuplex__latest_session(
this: *const c_void,
Expand Down
1 change: 1 addition & 0 deletions src/runtime/socket/WindowsNamedPipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ impl WindowsNamedPipe {
on_session: Some(Self::ssl_on_session),
on_keylog: Some(Self::ssl_on_keylog),
server_identity: Some(Self::ssl_server_identity),
on_server_name: None,
Comment thread
robobun marked this conversation as resolved.
Resolved
}
}

Expand Down
Loading
Loading