diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index c8528b0016e2..87de3ea9dfc6 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -191,11 +191,16 @@ static pthread_once_t us_ex_idx_once = PTHREAD_ONCE_INIT; /* Async SNICallback suspension state, hung off the SSL via ex_data. * Allocated the first time a dynamic resolver answers "pending"; freed with * the SSL. The resolved ctx carries one reference owned by this struct until - * select_cert_cb consumes it (SSL_set_SSL_CTX takes its own). */ + * select_cert_cb consumes it (SSL_set_SSL_CTX takes its own). + * `owner` is an opaque handle a resolver attaches so it learns, via + * `owner_free`, that this handshake's SSL is gone and a late resolution must + * not touch the socket. */ struct us_ssl_sni_pending_t { /* 0 = none, 1 = waiting for the JS resolution, 2 = resolved, 3 = error */ int state; struct ssl_ctx_st *resolved_ctx; + void *owner; + void (*owner_free)(void *owner); }; static void us_ssl_sni_pending_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, @@ -204,9 +209,25 @@ static void us_ssl_sni_pending_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, struct us_ssl_sni_pending_t *st = ptr; if (!st) return; if (st->resolved_ctx) SSL_CTX_free(st->resolved_ctx); + if (st->owner_free) st->owner_free(st->owner); us_free(st); } +/* Get (creating if needed) the suspension state for this SSL. NULL when the + * state could not be attached. */ +static struct us_ssl_sni_pending_t *us_ssl_sni_pending_get(SSL *ssl) { + if (us_ssl_sni_pending_idx < 0) return NULL; + struct us_ssl_sni_pending_t *pending = SSL_get_ex_data(ssl, us_ssl_sni_pending_idx); + if (!pending) { + pending = us_calloc(1, sizeof(*pending)); + if (!SSL_set_ex_data(ssl, us_ssl_sni_pending_idx, pending)) { + us_free(pending); + return NULL; + } + } + return pending; +} + struct us_ssl_reneg_state_t { uint64_t window_start_ms; uint32_t count; @@ -2218,6 +2239,27 @@ void us_socket_sni_resolve(struct us_socket_t *s, struct ssl_ctx_st *ctx, int er ssl_update_handshake(s); } +/* Hand an opaque resume handle to this handshake's suspension state. The + * handle's `owner_free` runs exactly once, when the SSL is freed - which + * always precedes the socket's own free (us_internal_ssl_detach) - so a + * resolver that nulls its socket pointer there can never resume a dangling + * socket. Called by a dynamic SNI resolver right before it answers "pending". */ +void us_socket_sni_attach_resume(struct us_socket_t *s, void *owner, + void (*owner_free)(void *owner)) { + if (!s || us_socket_is_closed(s) || !s->ssl || !s_ssl(s)) { + if (owner_free) owner_free(owner); + return; + } + struct us_ssl_sni_pending_t *pending = us_ssl_sni_pending_get(s_ssl(s)); + if (!pending) { + if (owner_free) owner_free(owner); + return; + } + if (pending->owner_free) pending->owner_free(pending->owner); + pending->owner = owner; + pending->owner_free = owner_free; +} + void us_internal_ssl_handshake_abort(struct us_socket_t *s) { s->ssl_fatal_error = 1; ssl_close(s, 0, NULL); @@ -2399,14 +2441,11 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h return ssl_select_cert_error; } if (abort_handshake == 2) { - /* The JS resolver answered "pending": suspend until us_socket_sni_resolve. */ - if (us_ssl_sni_pending_idx >= 0) { - if (!pending) { - pending = us_calloc(1, sizeof(*pending)); - SSL_set_ex_data(ssl, us_ssl_sni_pending_idx, pending); - } - pending->state = 1; - } + /* The JS resolver answered "pending": suspend until us_socket_sni_resolve. + * Re-fetch rather than reuse the `pending` read at entry: the resolver may + * have just created the state to attach its resume handle to it. */ + pending = us_ssl_sni_pending_get(ssl); + if (pending) pending->state = 1; return ssl_select_cert_retry; } if (dyn) { diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index c1ccae19acfe..681ccf7b9127 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -369,6 +369,11 @@ void us_listen_socket_on_server_name(struct us_listen_socket_t *ls, * call consumes the reference. `error` != 0 aborts the handshake. Safe to call * after the socket closed (no-op). */ void us_socket_sni_resolve(us_socket_r s, struct ssl_ctx_st *ctx, int error); +/* Attach an opaque resume handle to the in-flight handshake. `owner_free` runs + * exactly once, when the handshake's SSL is freed (which always precedes the + * socket's free), so the resolver can invalidate a pending JS resolution. */ +void us_socket_sni_attach_resume(us_socket_r s, void *owner, + void (*owner_free)(void *owner)); void *us_socket_server_name_userdata(us_socket_r s); /* ── Connect ────────────────────────────────────────────────────────────── diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index cccd7263ccd9..dc4f33637177 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -189,7 +189,21 @@ struct TemplatedApp { if (!constructorFailed()) { httpContext->getSocketContextData()->missingServerNameHandler = std::move(handler); forEachListenSocket([&](us_listen_socket_t *ls) { - us_listen_socket_on_server_name(ls, &onMissingServerName); + us_listen_socket_on_server_name(ls, &onServerNameDispatch); + }); + } + return std::move(*this); + } + + /* Per-handshake certificate selector (node:https SNICallback). Takes + * precedence over the static SNI tree and the missingServerName handler. */ + TemplatedApp &&serverNameResolver(typename HttpContextData::ServerNameResolver resolver, void *userData) { + if (!constructorFailed()) { + auto *data = httpContext->getSocketContextData(); + data->serverNameResolver = resolver; + data->serverNameResolverUserData = userData; + forEachListenSocket([&](us_listen_socket_t *ls) { + us_listen_socket_on_server_name(ls, &onServerNameDispatch); }); } return std::move(*this); @@ -308,13 +322,27 @@ struct TemplatedApp { TemplatedApp(TemplatedApp &&other) = delete; private: - static struct ssl_ctx_st *onMissingServerName(struct us_listen_socket_t *ls, const char *hostname, int *abort_handshake, struct us_socket_t *socket) { + static struct ssl_ctx_st *onServerNameDispatch(struct us_listen_socket_t *ls, const char *hostname, int *abort_handshake, struct us_socket_t *socket) { + auto *httpContext = (HttpContext *) us_socket_group_ext(us_listen_socket_group(ls)); + auto *data = httpContext->getSocketContextData(); + + if (data->serverNameResolver) { + struct ssl_ctx_st *ctx = data->serverNameResolver(data->serverNameResolverUserData, hostname, abort_handshake, socket); + /* A selection, an abort or a suspension all end the dispatch; only + * "selected nothing, handshake still live" falls through. */ + if (ctx || *abort_handshake) { + return ctx; + } + } + + if (!data->missingServerNameHandler) { + /* us_select_cert_cb falls back to the static SNI tree on nullptr. */ + return nullptr; + } + /* Bun.serve's missingServerName handler registers a context or lets the * default serve the request - it never aborts or suspends the handshake. */ - (void) abort_handshake; - (void) socket; - auto *httpContext = (HttpContext *) us_socket_group_ext(us_listen_socket_group(ls)); - httpContext->getSocketContextData()->missingServerNameHandler(hostname); + data->missingServerNameHandler(hostname); /* The handler is expected to have registered the name via * addServerName(); hand the newly-registered context back so the * in-flight handshake uses it (the resolver no longer re-checks the @@ -687,8 +715,9 @@ struct TemplatedApp { for (auto &p : pendingServerNames) { us_listen_socket_add_server_name(ls, p.hostname.c_str(), p.ctx, p.router); } - if (httpContext->getSocketContextData()->missingServerNameHandler) { - us_listen_socket_on_server_name(ls, &onMissingServerName); + auto *data = httpContext->getSocketContextData(); + if (data->missingServerNameHandler || data->serverNameResolver) { + us_listen_socket_on_server_name(ls, &onServerNameDispatch); } } return ls; diff --git a/packages/bun-uws/src/HttpContextData.h b/packages/bun-uws/src/HttpContextData.h index 538537c92c8f..e118a8d471cf 100644 --- a/packages/bun-uws/src/HttpContextData.h +++ b/packages/bun-uws/src/HttpContextData.h @@ -23,6 +23,12 @@ #include #include "MoveOnlyFunction.h" #include "HttpParser.h" + +/* Global scope, not uWS: an elaborated-type-specifier first seen inside the + * namespace would declare a distinct uWS::ssl_ctx_st. */ +struct ssl_ctx_st; +struct us_socket_t; + namespace uWS { template struct HttpResponse; struct HttpRequest; @@ -51,6 +57,13 @@ struct alignas(16) HttpContextData { MoveOnlyFunction missingServerNameHandler; + /* Dynamic per-handshake certificate selector (node:https SNICallback). + * Runs before the static SNI tree and may abort (*abort_handshake = 1) or + * suspend (= 2) the handshake. Returns an owned SSL_CTX* or nullptr. */ + using ServerNameResolver = struct ssl_ctx_st *(*)(void *userData, const char *hostname, int *abort_handshake, struct us_socket_t *socket); + ServerNameResolver serverNameResolver = nullptr; + void *serverNameResolverUserData = nullptr; + struct RouterData { HttpResponse *httpResponse; HttpRequest *httpRequest; diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index a44142705651..12e2f577cd57 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -12,6 +12,7 @@ const { getCompleteWebRequestOrResponseBodyValueAsArrayBuffer, drainMicrotasks, setServerIdleTimeout, + resumeServerSNI, } = $cpp("NodeHTTP.cpp", "createNodeHTTPInternalBinding") as { getHeader: (headers: Headers, name: string) => string | undefined; setHeader: (headers: Headers, name: string, value: string) => void; @@ -30,6 +31,8 @@ const { getCompleteWebRequestOrResponseBodyValueAsArrayBuffer: (arg: any) => ArrayBuffer | undefined; drainMicrotasks: () => void; setServerIdleTimeout: (server: any, timeout: number) => void; + /** Completes a handshake an asynchronous `SNICallback` left suspended. */ + resumeServerSNI: (token: number, context: any, isError: boolean) => void; }; const getRawKeys = $newCppFunction("JSFetchHeaders.cpp", "jsFetchHeaders_getRawKeys", 0); @@ -579,6 +582,7 @@ export { parseProxyConfigFromEnv, parseProxyUrl, reqSymbol, + resumeServerSNI, runSymbol, serverSymbol, setHeader, diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 6a01d6883869..e76886e86afb 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -51,6 +51,7 @@ const { eofInProgress, runSymbol, drainMicrotasks, + resumeServerSNI, setServerIdleTimeout, setServerCustomOptions, getMaxHTTPHeaderSize, @@ -247,6 +248,66 @@ function normalizeServerTls(tls) { return tls; } +// ─── SNICallback dispatch (https.Server) ───────────────────────────────────── +// The native dispatch (Bun.serve's `onServerName`) runs from inside the +// ClientHello and expects back: the selected native SecureContext, `undefined` +// to fall through to the default context, `true` to suspend the handshake until +// `resumeServerSNI(token, ...)`, or an Error to refuse the connection. +// Node's SNICallback is `(servername, cb)` and may answer either way, so the +// resolution has to be recognised as synchronous or asynchronous after the fact. + +// Node assigns `sni_context = context.context || context`: both the +// tls.createSecureContext() wrapper and a raw native context are accepted, and +// null/undefined falls through to the default context. The native side rejects +// anything that is not a context, so no validation is needed here. +function unwrapSNIContext(context) { + if (context == null) return undefined; + return (typeof context === "object" && context.context) || context; +} + +// Non-Error rejections (cb(true), cb("reason"), throw true) are normalized: the +// native dispatch reads an Error return as the abort signal, and a literal +// `true` would collide with the handshake-suspension sentinel. +function toSNIError(err) { + return err instanceof Error ? err : Object.assign(new Error("SNI callback error"), { reason: err }); +} + +// The user SNICallback's completion callback, bound to the per-handshake state. +// A synchronous resolution is carried by dispatchServerSNI's return value; an +// asynchronous one completes the parked handshake through resumeServerSNI. +function onSNIResolution(state, err, context) { + if (state.settled) return; // an SNICallback must resolve exactly once + state.settled = true; + if (err) { + state.failed = toSNIError(err); + } else { + state.selected = unwrapSNIContext(context); + } + if (!state.suspended) return; // synchronous - the return value carries it + if (state.failed !== undefined) { + resumeServerSNI(state.token, undefined, true); + } else { + resumeServerSNI(state.token, state.selected, false); + } +} + +function dispatchServerSNI(this: any, servername, token) { + const cb = this._SNICallback; + if (typeof cb !== "function" || !servername) return undefined; + const state = { token, selected: undefined, failed: undefined, settled: false, suspended: false }; + try { + cb.$call(this, servername, onSNIResolution.bind(null, state)); + } catch (err) { + state.settled = true; + state.failed = toSNIError(err); + } + if (!state.settled) { + state.suspended = true; + return true; + } + return state.failed !== undefined ? state.failed : state.selected; +} + function Server(options, callback): void { if (!(this instanceof Server)) return new Server(options, callback); EventEmitter.$call(this); @@ -302,6 +363,13 @@ function Server(options, callback): void { throw $ERR_INVALID_ARG_TYPE("options.secureOptions", "number", secureOptions); } + // https.Server accepts tls.Server's per-handshake certificate selector. + const sniCallback = options.SNICallback; + if (sniCallback != null) { + validateFunction(sniCallback, "options.SNICallback"); + this._SNICallback = sniCallback; + } + if (this[isTlsSymbol]) { this[tlsSymbol] = normalizeServerTls({ serverName, @@ -330,6 +398,9 @@ Server.prototype[kIncomingMessage] = undefined; Server.prototype[kServerResponse] = undefined; +// tls.Server's per-handshake certificate selector, honored by https.Server. +Server.prototype._SNICallback = undefined; + Server.prototype[kConnectionsCheckingInterval] = undefined; function rethrowUncaught(err) { @@ -562,6 +633,8 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort this[serverSymbol] = Bun.serve({ idleTimeout: 0, // nodejs dont have a idleTimeout by default tls, + // A plain server never pays the JS round-trip from inside the handshake. + onServerName: tls && server._SNICallback ? dispatchServerSNI.bind(server) : undefined, port, hostname: host, unix: socketPath, diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 9ad2c767dd11..a28bfe4466c1 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -38,6 +38,7 @@ extern "C" void Request__setInternalEventCallback(void*, EncodedJSValue, JSC::JS extern "C" void Request__setTimeout(void*, EncodedJSValue, JSC::JSGlobalObject*); extern "C" bool NodeHTTPResponse__setTimeout(void*, EncodedJSValue, JSC::JSGlobalObject*); extern "C" void Server__setIdleTimeout(EncodedJSValue, EncodedJSValue, JSC::JSGlobalObject*); +extern "C" void Bun__resumeServerSNI(double token, EncodedJSValue context, bool isError); extern "C" EncodedJSValue Server__setAppFlags(JSC::JSGlobalObject*, EncodedJSValue, bool require_host_header, bool use_strict_method_validation); extern "C" EncodedJSValue Server__setOnClientError(JSC::JSGlobalObject*, EncodedJSValue, EncodedJSValue); extern "C" EncodedJSValue Server__setMaxHTTPHeaderSize(JSC::JSGlobalObject*, EncodedJSValue, uint64_t); @@ -984,6 +985,24 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetServerIdleTimeout, (JSGlobalObject * globalObj return JSValue::encode(jsUndefined()); } +/* resumeServerSNI(token, secureContextOrUndefined, isError) — completes a + * handshake that an asynchronous node:https SNICallback left suspended. + * Internal binding: `token` is the number the native SNI dispatch handed the + * callback, so an unknown (stale, already-resolved) one is a no-op. */ +JSC_DEFINE_HOST_FUNCTION(jsHTTPResumeServerSNI, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + ASSERT(callFrame->argumentCount() == 3); + + JSValue tokenValue = callFrame->uncheckedArgument(0); + if (!tokenValue.isNumber()) { + return JSValue::encode(jsUndefined()); + } + + Bun__resumeServerSNI(tokenValue.asNumber(), JSValue::encode(callFrame->uncheckedArgument(1)), callFrame->uncheckedArgument(2).isTrue()); + + return JSValue::encode(jsUndefined()); +} + JSC_DEFINE_HOST_FUNCTION(jsHTTPSetCustomOptions, (JSGlobalObject * globalObject, CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); @@ -1163,6 +1182,10 @@ JSValue createNodeHTTPInternalBinding(Zig::GlobalObject* globalObject) vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setServerIdleTimeout"_s)), JSC::JSFunction::create(vm, globalObject, 2, "setServerIdleTimeout"_s, jsHTTPSetServerIdleTimeout, ImplementationVisibility::Public), 0); + obj->putDirect( + vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "resumeServerSNI"_s)), + JSC::JSFunction::create(vm, globalObject, 3, "resumeServerSNI"_s, jsHTTPResumeServerSNI, ImplementationVisibility::Public), 0); + obj->putDirect( vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setServerCustomOptions"_s)), JSC::JSFunction::create(vm, globalObject, 2, "setServerCustomOptions"_s, jsHTTPSetCustomOptions, ImplementationVisibility::Public), 0); diff --git a/src/runtime/server/ServerConfig.rs b/src/runtime/server/ServerConfig.rs index 95d4ccf20d11..32e3a8675411 100644 --- a/src/runtime/server/ServerConfig.rs +++ b/src/runtime/server/ServerConfig.rs @@ -48,6 +48,9 @@ pub struct ServerConfig { pub on_error: Option, pub on_request: Option, pub on_node_http_request: Option, + /// Internal hook behind node:https `SNICallback`: picks the certificate for + /// a single handshake. See `trampoline::on_server_name` for the contract. + pub on_server_name: Option, pub websocket: Option, @@ -84,6 +87,7 @@ impl Default for ServerConfig { on_error: None, on_request: None, on_node_http_request: None, + on_server_name: None, websocket: None, reuse_port: false, id: Box::default(), @@ -277,6 +281,7 @@ impl ServerConfig { on_error: self.on_error.take(), on_request: self.on_request.take(), on_node_http_request: self.on_node_http_request.take(), + on_server_name: self.on_server_name.take(), websocket: self.websocket.take(), reuse_port: self.reuse_port, id: core::mem::take(&mut self.id), @@ -1330,6 +1335,16 @@ impl ServerConfig { args.on_node_http_request = Some(Strong::create(on_request, global)); } + if let Some(on_server_name) = arg.get_truthy(global, "onServerName")? { + if !on_server_name.is_callable() { + return Err(global.throw_invalid_arguments(format_args!( + "Expected onServerName to be a function", + ))); + } + let on_server_name = on_server_name.with_async_context_if_needed(global); + args.on_server_name = Some(Strong::create(on_server_name, global)); + } + if let Some(on_request_) = arg.get_truthy(global, "fetch")? { if !on_request_.is_callable() { return Err(global diff --git a/src/runtime/server/ServerSNI.rs b/src/runtime/server/ServerSNI.rs new file mode 100644 index 000000000000..7b8eb24daa38 --- /dev/null +++ b/src/runtime/server/ServerSNI.rs @@ -0,0 +1,102 @@ +//! Per-handshake certificate selection for `Bun.serve` — the machinery behind +//! node:https `SNICallback`. +//! +//! `Bun.serve`'s static `tls: [{serverName}, …]` array registers contexts in +//! uSockets' SNI tree up front. A `SNICallback` instead picks a context while +//! the ClientHello is being processed, possibly asynchronously, and possibly +//! refusing the connection. uSockets already supports that shape for +//! `Bun.listen`/node:tls (`us_listen_socket_on_server_name` + +//! `us_socket_sni_resolve`); this module is the `Bun.serve` side of the same +//! contract, plus the suspension registry an asynchronous resolution needs. + +use core::cell::{Cell, RefCell}; +use core::ffi::c_void; + +use bun_collections::HashMap; +use bun_jsc::{JSValue, JsClass as _}; +use bun_uws_sys as uws_sys; + +use crate::api::bun_secure_context::SecureContext; + +thread_local! { + /// Handshakes parked by an asynchronous `SNICallback`, keyed by the token + /// handed to JS. The entry is removed when the handshake's `SSL` is freed + /// (see [`owner_free`]), which always happens before the socket itself is, + /// so a resolution that outlives its connection resolves into nothing + /// rather than touching freed memory. + static SUSPENDED: RefCell> = + RefCell::new(HashMap::new()); + /// Never reused, so a late resolution cannot resume an unrelated handshake + /// that happened to land on a recycled id. + static NEXT_TOKEN: Cell = const { Cell::new(1) }; +} + +/// Allocate the token a suspended handshake is resumed with. Exact as an f64 in +/// JS until 2^53 handshakes have been dispatched on this thread. +pub(crate) fn next_token() -> u64 { + NEXT_TOKEN.with(|c| { + let token = c.get(); + c.set(token + 1); + token + }) +} + +/// Park `socket` until JS resolves `token`. Returns false when the handshake +/// could not be parked (the socket died underneath us); the caller must then +/// fall through to the default context rather than suspend. +pub(crate) fn suspend(socket: *mut uws_sys::us_socket_t, token: u64) -> bool { + if socket.is_null() { + return false; + } + SUSPENDED.with(|map| map.borrow_mut().insert(token, socket)); + // Ownership of the box moves into the SSL's suspension state; `owner_free` + // reclaims it (and drops the registry entry) when the SSL is freed — or + // synchronously, right here, when the handshake can no longer be parked. + let owner = bun_core::heap::into_raw(Box::new(token)).cast::(); + uws_sys::us_socket_t::opaque_mut(socket).sni_attach_resume(owner, owner_free); + SUSPENDED.with(|map| map.borrow().contains_key(&token)) +} + +/// uSockets calls this exactly once per attached handle, when the handshake's +/// `SSL` is freed. After it runs the socket the entry names may be freed at any +/// time, so the entry must not survive it. +extern "C" fn owner_free(owner: *mut c_void) { + if owner.is_null() { + return; + } + // SAFETY: `owner` is the box `suspend()` handed to `sni_attach_resume`, + // returned here exactly once; `take` reclaims and frees it. + let token = *unsafe { bun_core::heap::take(owner.cast::()) }; + SUSPENDED.with(|map| map.borrow_mut().remove(&token)); +} + +/// `resumeServerSNI(token, contextOrUndefined, isError)` — completes a +/// handshake parked by an asynchronous `SNICallback`. A no-op when the token is +/// unknown: the connection went away, or the resolution already happened. +/// +/// `context` is the native `SecureContext` the callback selected; `undefined` +/// or `null` falls through to the server's default context. Any other value is +/// not a usable context, so the handshake is refused rather than silently +/// served the default certificate. +#[unsafe(no_mangle)] +pub extern "C" fn Bun__resumeServerSNI(token: f64, context: JSValue, is_error: bool) { + let Some(socket) = SUSPENDED.with(|map| map.borrow_mut().remove(&(token as u64))) else { + return; + }; + + let mut error = is_error; + let mut ctx: *mut uws_sys::SslCtx = core::ptr::null_mut(); + if !error && !context.is_undefined_or_null() { + match SecureContext::from_js(context) { + // SAFETY: `from_js` returned a live SecureContext; `borrow()` hands + // back an owned SSL_CTX reference that `sni_resolve` consumes. + Some(sc) => ctx = unsafe { (*sc).borrow() }.cast(), + None => error = true, + } + } + + // SAFETY: the registry entry is dropped before the socket can be freed + // (`owner_free` runs on SSL free, which precedes the socket's own free), so + // a token we just took out still names a live socket. + uws_sys::us_socket_t::opaque_mut(socket).sni_resolve(ctx, error); +} diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 12e0defe5c89..03993527f840 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -69,6 +69,9 @@ pub use web_socket_server_context::{Handler as WebSocketServerHandler, WebSocket pub mod server_config; pub use server_config::ServerConfig; +#[path = "ServerSNI.rs"] +pub mod server_sni; + #[path = "StaticRoute.rs"] pub mod static_route; pub use static_route::StaticRoute; @@ -2538,6 +2541,17 @@ impl NewServer { // SAFETY: `this` is the live boxed server from `init()`; no other borrow is live. route_list_value = unsafe { &mut *this }.set_routes(); + // node:https SNICallback: pick the certificate per handshake, ahead + // of the static SNI tree below. Registered on the app before it + // binds, so every listen socket it creates inherits the resolver. + if this_ref.config.on_server_name.is_some() { + // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. + bun_opaque::opaque_deref_mut(app).server_name_resolver( + Some(trampoline::on_server_name::), + this.cast::(), + ); + } + // add serverName to the SSL context using the default ssl options // extract raw (ptr, len) so no `&self.config` borrow // outlives the `set_routes()` call below. set_routes() does not @@ -2896,6 +2910,9 @@ mod route_list_cached { // the bodies downcast `user_data` and forward into the typed method. mod trampoline { use super::*; + use bun_jsc::JsClass as _; + use bun_jsc::ZigStringJsc as _; + use bun_jsc::zig_string::ZigString; use bun_uws_sys::{ListenSocket as UwsListenSocket, Request as UwsRequest, uws_res}; pub(super) extern "C" fn on_listen( @@ -2921,6 +2938,92 @@ mod trampoline { on_listen::(socket, user_data); } + /// `us_select_cert_cb` → uws `onServerNameDispatch` → here, once per + /// ClientHello carrying a servername, ahead of the static SNI tree (Node's + /// precedence: a user `SNICallback` replaces the default SNI handling). + /// + /// The JS handler answers with: + /// - `undefined`/`null` → fall through to the static tree, then the + /// default context + /// - a native `SecureContext`→ serve this handshake from it + /// - `true` → the callback is asynchronous; suspend the + /// handshake until `resumeServerSNI(token, …)` + /// - an `Error` → refuse the connection (no TLS alert, same + /// as node:tls) + /// Anything else is not a usable context, which Node also treats as a + /// refusal. + pub(super) extern "C" fn on_server_name( + user_data: *mut c_void, + hostname: *const c_char, + abort_handshake: *mut c_int, + socket: *mut uws_sys::us_socket_t, + ) -> *mut uws_sys::SslCtx { + jsc::mark_binding!(); + if hostname.is_null() || user_data.is_null() { + return core::ptr::null_mut(); + } + // SAFETY: `user_data` is the `*mut NewServer<..>` handed to + // `App::server_name_resolver`, which the app (owned by the server) + // outlives; `abort_handshake` is a live out-param for this call. + let this_ref = bun_ptr::BackRef::from( + core::ptr::NonNull::new(user_data.cast::>()) + .expect("on_server_name: user_data non-null"), + ); + if this_ref.vm().is_shutting_down() { + return core::ptr::null_mut(); + } + let Some(callback) = this_ref.config.on_server_name.as_ref().map(|s| s.get()) else { + return core::ptr::null_mut(); + }; + + let global = this_ref.global_this(); + let this_object = this_ref.js_value.try_get().unwrap_or(JSValue::UNDEFINED); + // SAFETY: `hostname` is NUL-terminated for the duration of the call. + let name = unsafe { core::ffi::CStr::from_ptr(hostname) }; + let js_name = ZigString::init(name.to_bytes()).to_js(global); + // Minted before the call because the handler has to hand it to the + // user's `cb`, which may only be invoked after the handler returns. The + // handshake is only registered against it if the handler suspends. + let token = server_sni::next_token(); + + let result = + match callback.call(global, this_object, &[js_name, JSValue::from(token as f64)]) { + Ok(v) => v, + Err(err) => global.take_exception(err), + }; + + let set_abort = |code: c_int| { + if !abort_handshake.is_null() { + // SAFETY: live out-param for the duration of this dispatch. + unsafe { *abort_handshake = code }; + } + }; + + if result.is_boolean() && result.to_boolean() { + if server_sni::suspend(socket, token) { + set_abort(2); + } + // The socket died while the handler ran: there is nothing left to + // suspend, so let the handshake run its course into teardown. + return core::ptr::null_mut(); + } + if result.to_error().is_some() { + set_abort(1); + return core::ptr::null_mut(); + } + if result.is_undefined_or_null() { + return core::ptr::null_mut(); + } + if let Some(sc) = crate::api::bun_secure_context::SecureContext::from_js(result) { + // SAFETY: `from_js` returned a live SecureContext; `borrow()` hands + // back an owned SSL_CTX reference, which the C caller consumes via + // SSL_set_SSL_CTX + SSL_CTX_free. + return unsafe { (*sc).borrow() }.cast(); + } + set_abort(1); + core::ptr::null_mut() + } + pub(super) extern "C" fn on_404( res: *mut uws_res, _req: *mut UwsRequest, diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index a1d0af41f224..88bc6746d9f7 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -396,6 +396,18 @@ impl App { c::uws_missing_server_name(Self::SSL_FLAG, self.as_raw(), handler, user_data) } + /// Install a per-handshake certificate selector (node:https `SNICallback`). + /// It runs ahead of the static SNI tree on every listen socket this app + /// has, and on every one it creates afterwards. `user_data` must outlive + /// the app. + pub fn server_name_resolver( + &mut self, + resolver: c::uws_server_name_resolver, + user_data: *mut c_void, + ) { + c::uws_server_name_resolver_set(Self::SSL_FLAG, self.as_raw(), resolver, user_data) + } + pub fn filter(&mut self, handler: c::uws_filter_handler, user_data: *mut c_void) { c::uws_filter(Self::SSL_FLAG, self.as_raw(), handler, user_data) } @@ -493,6 +505,15 @@ pub mod c { Option; pub(crate) type uws_filter_handler = Option; pub(crate) type uws_missing_server_handler = Option; + /// `(user_data, hostname, abort_handshake, socket) -> owned SSL_CTX*`. + pub type uws_server_name_resolver = Option< + extern "C" fn( + *mut c_void, + *const c_char, + *mut core::ffi::c_int, + *mut crate::us_socket_t, + ) -> *mut crate::SslCtx, + >; unsafe extern "C" { pub(crate) safe fn uws_app_close(ssl: i32, app: &mut uws_app_s); @@ -657,6 +678,12 @@ pub mod c { hostname_pattern: *const c_char, options: BunSocketContextOptions, ) -> i32; + pub(crate) safe fn uws_server_name_resolver_set( + ssl: i32, + app: &mut uws_app_t, + resolver: uws_server_name_resolver, + user_data: *mut c_void, + ); pub(crate) safe fn uws_missing_server_name( ssl: i32, app: &mut uws_app_t, diff --git a/src/uws_sys/_libusockets.h b/src/uws_sys/_libusockets.h index 8f49e123ca36..4471dbc5e580 100644 --- a/src/uws_sys/_libusockets.h +++ b/src/uws_sys/_libusockets.h @@ -123,6 +123,10 @@ typedef struct { uws_websocket_close_handler close; } uws_socket_behavior_t; +struct us_listen_socket_t; +struct us_socket_t; +struct ssl_ctx_st; + typedef void (*uws_listen_handler)(struct us_listen_socket_t* listen_socket, void* user_data); typedef void (*uws_listen_domain_handler)( @@ -134,6 +138,11 @@ typedef void (*uws_method_handler)(uws_res_t* response, uws_req_t* request, typedef void (*uws_filter_handler)(uws_res_t* response, int, void* user_data); typedef void (*uws_missing_server_handler)(const char* hostname, void* user_data); +/* Per-handshake certificate selector. Returns an owned SSL_CTX* (or NULL to + * fall through), may set *abort_handshake to 1 (refuse) or 2 (suspend until + * us_socket_sni_resolve). */ +typedef struct ssl_ctx_st* (*uws_server_name_resolver)(void* user_data, + const char* hostname, int* abort_handshake, struct us_socket_t* socket); typedef void (*uws_get_headers_server_handler)(const char* header_name, size_t header_name_size, const char* header_value, diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index b9a49d00bb6c..0618d7487d04 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -685,6 +685,23 @@ extern "C" { handler(hostname, user_data); }); } } + + void uws_server_name_resolver_set(int ssl, uws_app_t *app, + uws_server_name_resolver resolver, + void *user_data) + { + if (ssl) + { + uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; + uwsApp->serverNameResolver(resolver, user_data); + } + else + { + uWS::App *uwsApp = (uWS::App *)app; + uwsApp->serverNameResolver(resolver, user_data); + } + } + void uws_filter(int ssl, uws_app_t *app, uws_filter_handler handler, void *user_data) { diff --git a/src/uws_sys/us_socket_t.rs b/src/uws_sys/us_socket_t.rs index e4e148e4e6e9..e1500a418d62 100644 --- a/src/uws_sys/us_socket_t.rs +++ b/src/uws_sys/us_socket_t.rs @@ -197,6 +197,18 @@ impl us_socket_t { c::us_socket_sni_resolve(self, ctx, error as c_int); } + /// Hand this handshake an opaque resume handle. `owner_free` is invoked + /// exactly once, when the handshake's `SSL` is freed (always before the + /// socket itself is), which is how the owner learns a late resolution must + /// not touch `self`. Takes ownership of `owner`. + pub fn sni_attach_resume( + &mut self, + owner: *mut core::ffi::c_void, + owner_free: extern "C" fn(*mut core::ffi::c_void), + ) { + c::us_socket_sni_attach_resume(self, owner, owner_free); + } + /// `SSL*` if TLS, else null. Use `get_fd()` for the descriptor. pub fn ssl(&mut self) -> Option<&mut bun_boringssl_sys::SSL> { if !self.is_tls() { @@ -504,6 +516,11 @@ mod c { ctx: *mut SslCtx, error: c_int, ); + pub(super) safe fn us_socket_sni_attach_resume( + s: &mut us_socket_t, + owner: *mut core::ffi::c_void, + owner_free: extern "C" fn(*mut core::ffi::c_void), + ); pub(super) safe fn us_socket_keepalive( s: &mut us_socket_t, enable: c_int, diff --git a/test/js/node/http/node-https-sni.test.ts b/test/js/node/http/node-https-sni.test.ts new file mode 100644 index 000000000000..a37e49e2851e --- /dev/null +++ b/test/js/node/http/node-https-sni.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "fs"; +import { tls as defaultCert } from "harness"; +import { once } from "node:events"; +import https from "node:https"; +import type { AddressInfo } from "node:net"; +import tls from "node:tls"; +import { join } from "path"; + +// The default identity is CN=server-bun; the SNI identity is CN=agent1, so the +// certificate the server picked is readable straight off the peer certificate. +const keys = join(import.meta.dir, "../test/fixtures/keys"); +const sniCert = { + key: readFileSync(join(keys, "agent1-key.pem"), "utf8"), + cert: readFileSync(join(keys, "agent1-cert.pem"), "utf8"), +}; + +type Outcome = { cn: string } | { error: string }; + +/** Resolves to the CN the server served, or the error the handshake failed with. */ +function handshake(port: number, servername: string): Promise { + const { promise, resolve } = Promise.withResolvers(); + const socket = tls.connect({ port, host: "127.0.0.1", servername, rejectUnauthorized: false }, () => { + resolve({ cn: String(socket.getPeerCertificate().subject?.CN) }); + socket.end(); + }); + socket.on("error", err => resolve({ error: (err as NodeJS.ErrnoException).code ?? err.message })); + return promise; +} + +async function listen(server: https.Server): Promise { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return (server.address() as AddressInfo).port; +} + +// Each test owns its own server on port 0 and shares no mutable state. +describe.concurrent("https.Server SNICallback", () => { + it("https.Server SNICallback selects the certificate per connection", async () => { + const names: string[] = []; + const altContext = tls.createSecureContext(sniCert); + const server = https.createServer( + { + ...defaultCert, + SNICallback(name, cb) { + names.push(name); + cb(null, name === "alt.example" ? altContext : undefined); + }, + }, + (_req, res) => res.end("ok"), + ); + + try { + const port = await listen(server); + expect(await handshake(port, "alt.example")).toEqual({ cn: "agent1" }); + expect(await handshake(port, "localhost")).toEqual({ cn: "server-bun" }); + expect(names).toEqual(["alt.example", "localhost"]); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("https.Server serves requests through the SNICallback-selected certificate", async () => { + const altContext = tls.createSecureContext(sniCert); + const server = https.createServer( + { ...defaultCert, SNICallback: (_name, cb) => cb(null, altContext) }, + (_req, res) => res.end("hello"), + ); + + try { + const port = await listen(server); + const { promise, resolve, reject } = Promise.withResolvers<{ cn: string; body: string }>(); + const req = https.get( + { port, host: "127.0.0.1", servername: "alt.example", rejectUnauthorized: false, path: "/" }, + res => { + const cn = String((res.socket as tls.TLSSocket).getPeerCertificate().subject?.CN); + let body = ""; + res.setEncoding("utf8"); + res.on("data", chunk => (body += chunk)); + res.on("end", () => resolve({ cn, body })); + res.on("error", reject); + }, + ); + req.on("error", reject); + expect(await promise).toEqual({ cn: "agent1", body: "hello" }); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("https.Server SNICallback errors refuse the handshake", async () => { + const cases: [string, (name: string, cb: (err: Error | null, ctx?: unknown) => void) => void][] = [ + ["cb(error)", (_name, cb) => cb(new Error("sni rejected"))], + ["invalid context", (_name, cb) => cb(null, {})], + [ + "throw", + () => { + throw new Error("sni threw"); + }, + ], + ]; + + for (const [label, SNICallback] of cases) { + const server = https.createServer({ ...defaultCert, SNICallback }, (_req, res) => res.end("must not happen")); + try { + const port = await listen(server); + const outcome = await handshake(port, "refused.example"); + // The connection is dropped before the handshake completes, without a TLS + // alert, exactly like tls.Server does for the same callback shapes. + expect(outcome, label).toEqual({ error: expect.stringMatching(/ECONNRESET|EPROTO|ERR_SSL|disconnected/) }); + } finally { + server.close(); + await once(server, "close"); + } + } + }); + + it("https.Server SNICallback selecting no context falls through to the default", async () => { + const server = https.createServer({ ...defaultCert, SNICallback: (_name, cb) => cb(null, null) }, (_req, res) => + res.end("ok"), + ); + try { + const port = await listen(server); + expect(await handshake(port, "unknown.example")).toEqual({ cn: "server-bun" }); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("https.Server suspends the handshake for an asynchronous SNICallback", async () => { + const altContext = tls.createSecureContext(sniCert); + let resolvedAsynchronously = false; + const server = https.createServer( + { + ...defaultCert, + SNICallback(_name, cb) { + // setImmediate runs strictly after the native dispatch returned, so the + // handshake has to park until the resolution lands. + setImmediate(() => { + resolvedAsynchronously = true; + cb(null, altContext); + }); + }, + }, + (_req, res) => res.end("ok"), + ); + + try { + const port = await listen(server); + expect(await handshake(port, "async.example")).toEqual({ cn: "agent1" }); + expect(resolvedAsynchronously).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("https.Server aborts a suspended handshake when the asynchronous SNICallback errors", async () => { + const server = https.createServer( + { ...defaultCert, SNICallback: (_name, cb) => setImmediate(() => cb(new Error("async sni rejected"))) }, + (_req, res) => res.end("must not happen"), + ); + try { + const port = await listen(server); + expect(await handshake(port, "async-refused.example")).toEqual({ + error: expect.stringMatching(/ECONNRESET|EPROTO|ERR_SSL|disconnected/), + }); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("https.Server survives a connection destroyed while its SNICallback is pending", async () => { + let resolveLater: (() => void) | undefined; + const { promise: dispatched, resolve: onDispatch } = Promise.withResolvers(); + const altContext = tls.createSecureContext(sniCert); + const server = https.createServer( + { + ...defaultCert, + SNICallback(name, cb) { + if (name === "gone.example") { + // Stash the resolution so it fires only after the client is gone. + resolveLater = () => cb(null, altContext); + onDispatch(); + return; + } + cb(null, altContext); + }, + }, + (_req, res) => res.end("ok"), + ); + + try { + const port = await listen(server); + const client = tls.connect({ port, host: "127.0.0.1", servername: "gone.example", rejectUnauthorized: false }); + client.on("error", () => {}); + await dispatched; + const closed = once(client, "close"); + client.destroy(); + await closed; + // Resolving a handshake whose connection already died must be a safe no-op. + resolveLater!(); + + // The server must still be usable after the stale resolution. + expect(await handshake(port, "alt.example")).toEqual({ cn: "agent1" }); + } finally { + server.close(); + await once(server, "close"); + } + }); +});