Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 26 additions & 11 deletions packages/bun-usockets/src/crypto/openssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -3190,12 +3190,11 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h

/* The dynamic resolver (the user's SNICallback) runs FIRST, matching Node
* where a user-provided SNICallback replaces the default SNI handling
* entirely - including for the bind hostname, which Listener.rs always
* registers in the static tree (so tree-first would shadow the callback
* for the most-requested name and break per-connection cert rotation).
* The static tree (bind hostname + addContext entries) is the fallback
* when the resolver selects nothing, which is also the no-user-callback
* path: the JS dispatch returns undefined immediately in that case. */
* entirely (tree-first would shadow the callback for every name that also
* has an addContext entry). The static tree (addContext entries) is the
* fallback when the resolver selects nothing, which is also the
* no-user-callback path: the JS dispatch returns undefined immediately in
* that case. */

/* The socket processing this ClientHello - the JS resolver needs it as the
* resume handle for an asynchronous SNICallback. */
Expand Down Expand Up @@ -3237,8 +3236,8 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h
return ssl_select_cert_success;
}

/* No dynamic selection: fall back to the static SNI tree (the bind
* hostname and addContext() entries). An adopted socket has no tree. */
/* No dynamic selection: fall back to the static SNI tree (the
* addContext() entries). An adopted socket has no tree. */
if (ls) {
struct sni_node_t *node = resolve_listener_ctx(ls, hostname);
if (node) {
Expand All @@ -3260,9 +3259,8 @@ static int sni_cb(SSL *ssl, int *al, void *arg) {
/* A dynamic resolver (user SNICallback) exists: us_select_cert_cb already
* ran it - and the static-tree fallback - at the earlier
* select-certificate stage. Consulting the tree again here would
* OVERWRITE the resolver's per-connection selection with the tree entry
* (the bind hostname is always registered there), undoing the
* SNICallback-takes-precedence contract. */
* OVERWRITE the resolver's per-connection selection with the tree entry,
* undoing the SNICallback-takes-precedence contract. */
return SSL_TLSEXT_ERR_OK;
}
const char *hostname = SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name);
Expand Down Expand Up @@ -3336,6 +3334,23 @@ struct ssl_ctx_st *us_listen_socket_find_server_name_ctx(struct us_listen_socket
return node->ctx;
}

void us_listen_socket_set_default_ssl_ctx(struct us_listen_socket_t *ls,
SSL_CTX *ctx) {
if (ls->ssl_ctx == ctx) return;
SSL_CTX_up_ref(ctx);
/* Carry over the listener-level callbacks registered on the old default. */
if (ls->sni) {
SSL_CTX_set_tlsext_servername_callback(ctx, sni_cb);
}
if (ls->on_server_name) {
SSL_CTX_set_select_certificate_cb(ctx, us_select_cert_cb);
}
if (ls->ssl_ctx) {
us_internal_ssl_ctx_unref(ls->ssl_ctx);
}
ls->ssl_ctx = ctx;
}

void us_listen_socket_on_server_name(struct us_listen_socket_t *ls,
struct ssl_ctx_st *(*cb)(struct us_listen_socket_t *, const char *, int *, struct us_socket_t *)) {
ls->on_server_name = cb;
Expand Down
5 changes: 5 additions & 0 deletions packages/bun-usockets/src/libusockets.h
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,11 @@ void *us_listen_socket_find_server_name_userdata(struct us_listen_socket_t *ls,
/* Returns an owned reference; the caller must release it. */
struct ssl_ctx_st *us_listen_socket_find_server_name_ctx(struct us_listen_socket_t *ls,
const char *hostname_pattern) nonnull_fn_decl;
/* tls.Server#setSecureContext(): swap the default SSL_CTX used for NEWLY
* accepted sockets (SNI-selected contexts are untouched). Up_refs ctx; live
* connections keep the previous context alive through their own SSL refs. */
void us_listen_socket_set_default_ssl_ctx(struct us_listen_socket_t *ls,
struct ssl_ctx_st *ctx) __attribute__((nonnull(1, 2)));
/* Parses a PKCS#12 blob into malloc'd PEM key/cert/ca strings (caller frees);
* returns 0 with a static *err_reason tag on failure. */
int us_ssl_parse_pkcs12(const char *data, size_t len, const char *pass,
Expand Down
8 changes: 5 additions & 3 deletions src/js/node/net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3862,9 +3862,6 @@ Server.prototype.listen = function listen(port, hostname, onListen) {
options.servername = tls.serverName;
options[kSocketClass] = TLSSocketClass;
contexts = tls.contexts;
if (!tls.requestCert) {
tls.rejectUnauthorized = false;
}
} else {
options[kSocketClass] = Socket;
}
Expand Down Expand Up @@ -4187,6 +4184,11 @@ function listenInCluster(
// The primary owns the socket file; the adopted fd only needs to report it from address().
server[kClusterUnixPath] = path;
try {
// The reply is asynchronous: a setSecureContext() or addContext() made since listen() counts.
if (tls) {
tls = server[bunTlsSymbol](port, hostname, false)[0];
contexts = tls.contexts;
}
server[kRealListen](
undefined,
port,
Expand Down
27 changes: 15 additions & 12 deletions src/js/node/tls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const net = require("node:net");
const Duplex = require("internal/streams/duplex");
const EventEmitter = require("node:events");
const addServerName = $newRustFunction("Listener.rs", "jsAddServerName", 3);
const setListenerSecureContext = $newRustFunction("Listener.rs", "jsSetSecureContext", 2);
const { throwNotImplemented } = require("internal/shared");
const { domainToASCII } = require("internal/url");
const {
Expand Down Expand Up @@ -1236,6 +1237,8 @@ function Server(options, secureConnectionListener): void {
this._rejectUnauthorized = serverOptions?.rejectUnauthorized !== false;
this.servername = undefined;
this.ALPNProtocols = undefined;
// Constructor-only in node, like the two flags above: setSecureContext() never reads it.
if (serverOptions?.ALPNProtocols) convertALPNProtocols(serverOptions.ALPNProtocols, this);
this._sharedCreds = undefined;

let contexts: Map<string, typeof InternalSecureContext> | null = null;
Expand Down Expand Up @@ -1267,14 +1270,6 @@ function Server(options, secureConnectionListener): void {
if (options) {
validateSecureContextOptions(options);
options = processPfxOptions(options);
const { ALPNProtocols } = options;

if (ALPNProtocols) {
convertALPNProtocols(ALPNProtocols, next);
} else {
// An omitted ALPNProtocols clears the previous call's protocols.
next.ALPNProtocols = undefined;
}

let cert = options.cert;
// Assign unconditionally so a later setSecureContext() that omits an
Expand Down Expand Up @@ -1404,13 +1399,19 @@ function Server(options, secureConnectionListener): void {
// validateSecureContextOptions already rejected unknown method names.
// Assign unconditionally so a later setSecureContext() without these
// options clears the previous call's version constraints instead of
// re-applying them on the next listen.
// re-applying them to the next context built.
next.secureProtocol = options.secureProtocol;
next.minVersion = options.minVersion;
next.maxVersion = options.maxVersion;
}
if (options) {
this.ALPNProtocols = next.ALPNProtocols;
// Throws on material BoringSSL rejects, so it runs before the fields change.
const handle = this._handle;
if (handle && !(serverTLSOptions instanceof InternalSecureContext)) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
// [buntls] reads its receiver: the staged fields over the server's own.
const staged = { __proto__: this, ...next };
setListenerSecureContext(handle, staged[buntls](0, undefined, false)[0]);
}
this.cert = next.cert;
this.key = next.key;
this.ca = next.ca;
Expand Down Expand Up @@ -1454,6 +1455,7 @@ function Server(options, secureConnectionListener): void {
};

this[buntls] = function (port, host, isClient) {
const requestCert = isClient ? true : this._requestCert;
return [
{
serverName: this.servername || host || "localhost",
Expand All @@ -1467,8 +1469,9 @@ function Server(options, secureConnectionListener): void {
ecdhCurve: this.ecdhCurve ?? DEFAULT_ECDH_CURVE,
passphrase: this.passphrase,
secureOptions: this.secureOptions,
rejectUnauthorized: this._rejectUnauthorized,
requestCert: isClient ? true : this._requestCert,
// A server that requests no client certificate has none to reject.
rejectUnauthorized: requestCert ? this._rejectUnauthorized : false,
requestCert,
ALPNProtocols: this.ALPNProtocols,
clientRenegotiationLimit: CLIENT_RENEG_LIMIT,
clientRenegotiationWindow: CLIENT_RENEG_WINDOW,
Expand Down
109 changes: 78 additions & 31 deletions src/runtime/socket/Listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,35 +557,15 @@ impl Listener {
.set(Strong::create(default_data, global));
}

if let Some(ssl_config) = ssl_cfg_taken.as_ref() {
// `ssl_enabled` ⇒ `createSSLContext` succeeded above ⇒ `secure_ctx` set.
let secure = this_ref
.secure_ctx
.get()
.as_ref()
.expect("unreachable")
.as_ptr();
if let Some(server_name) = ssl_config.server_name_cstr() {
if !server_name.to_bytes().is_empty() {
// Registering the default cert under its own server_name is a
// hint for sni_cb, not load-bearing — sni_find() miss falls
// through to the default SSL_CTX anyway.
// S008: `ListenSocket` is an `opaque_ffi!` ZST — safe deref.
let _ = bun_opaque::opaque_deref_mut(listen_socket).add_server_name(
server_name,
secure,
core::ptr::null_mut(),
);
}
}
if ssl_enabled {
// Register the dynamic SNI dispatch when the JS config provided a
// `serverName` handler - `us_select_cert_cb` invokes it FIRST for
// every ClientHello carrying a servername (the user callback takes
// precedence over the static SNI tree, Node semantics) and
// installs whichever context it returns on the in-flight SSL. A
// null return falls back to the static tree (bind hostname +
// addContext entries), then the default context; an asynchronous
// resolution suspends the handshake until resumeSNI.
// null return falls back to the static tree (addContext entries),
// then the default context; an asynchronous resolution suspends
// the handshake until resumeSNI.
Comment thread
robobun marked this conversation as resolved.
if !this_ref.handlers.on_server_name().is_empty() {
// S008: `ListenSocket` is an `opaque_ffi!` ZST - safe deref.
bun_opaque::opaque_deref_mut(listen_socket).on_server_name(us_dispatch_server_name);
Expand Down Expand Up @@ -816,6 +796,48 @@ impl Listener {
Ok(JSValue::UNDEFINED)
}

/// `tls.Server#setSecureContext()` while listening. Accepted sockets keep their context.
pub(crate) fn set_secure_context(
this: &Self,
global: &JSGlobalObject,
tls: JSValue,
) -> JsResult<JSValue> {
if !this.ssl {
return Ok(JSValue::UNDEFINED);
}
// SAFETY: per-thread VM; valid for program lifetime.
let vm = VirtualMachine::get().as_mut();
let Some(ssl_config) = SSLConfig::from_js(vm, global, tls)? else {
return Ok(JSValue::UNDEFINED);
};
let mut create_err = uws::create_bun_socket_error_t::none;
let Some(ctx) = ssl_config.as_usockets().create_ssl_context(&mut create_err) else {
return Err(
global.throw_value(crate::socket::uws_jsc::create_bun_socket_error_to_js(
create_err, global,
)),
);
};

// `from_js` runs getters on `tls`, so the listener is read only now.
match this.listener.get() {
ListenerType::Uws(ls) => {
// S008: `ListenSocket` is an `opaque_ffi!` ZST — safe deref.
bun_opaque::opaque_deref_mut(ls).set_default_ssl_ctx(&ctx);
this.secure_ctx.set(Some(ctx));
}
Comment thread
robobun marked this conversation as resolved.
#[cfg(windows)]
ListenerType::NamedPipe(pipe) => {
// SAFETY: the pipe context is live while `this.listener` holds it.
unsafe { pipe.as_ref() }.ctx.set(Some(ctx));
}
#[cfg(not(windows))]
ListenerType::NamedPipe(_) => {}
ListenerType::None => {}
}
Ok(JSValue::UNDEFINED)
}

#[bun_jsc::host_fn(method)]
pub(crate) fn dispose(
this: &Self,
Expand Down Expand Up @@ -1720,6 +1742,28 @@ pub(crate) fn js_add_server_name(global: &JSGlobalObject, frame: &CallFrame) ->
Err(global.throw(format_args!("Expected a Listener instance")))
}

#[bun_jsc::host_fn]
pub(crate) fn js_set_secure_context(
global: &JSGlobalObject,
frame: &CallFrame,
) -> JsResult<JSValue> {
jsc::mark_binding!();

let [listener, tls] = frame.arguments_as_array::<2>();
if frame.arguments_count() < 2 {
return Err(global.throw_not_enough_arguments(
"setSecureContext",
2,
frame.arguments_count() as usize,
));
}
// A cluster worker's `_handle` is no `Listener`: JS wraps its connections.
match listener.as_class_ref::<Listener>() {
Some(this) => Listener::set_secure_context(this, global, tls),
None => Ok(JSValue::UNDEFINED),
}
}

#[cfg(windows)]
fn is_valid_pipe_name(pipe_name: &[u8]) -> bool {
// check for valid pipe names
Expand Down Expand Up @@ -1760,7 +1804,8 @@ pub struct WindowsNamedPipeListeningContext {
/// JSC_BORROW: process-lifetime singleton; `&'static` so call sites read
/// `self.vm.is_shutting_down()` without a raw-pointer deref.
pub(crate) vm: &'static VirtualMachine,
pub ctx: Option<boring_sys::OwnedSslCtx>, // server reuses the same ctx
/// Every accept wraps its pipe with this context.
pub ctx: JsCell<Option<boring_sys::OwnedSslCtx>>,
}

#[cfg(not(windows))]
Expand Down Expand Up @@ -1791,7 +1836,9 @@ impl WindowsNamedPipeListeningContext {
let listener_ref = this_ref.listener.unwrap();
let listener: &Listener = listener_ref.get();
use crate::socket::windows_named_pipe_context::SocketType as PipeSocketType;
let socket: PipeSocketType = if this_ref.ctx.is_some() {
// Owned for the whole accept: JS below can replace the slot through setSecureContext().
let ssl_ctx = this_ref.ctx.get().clone();
let socket: PipeSocketType = if ssl_ctx.is_some() {
PipeSocketType::Tls(Listener::on_name_pipe_created::<true>(listener))
} else {
PipeSocketType::Tcp(Listener::on_name_pipe_created::<false>(listener))
Expand All @@ -1805,7 +1852,7 @@ impl WindowsNamedPipeListeningContext {
let result = unsafe {
(*client)
.named_pipe
.get_accepted_by(&mut (*this).uv_pipe, this_ref.ctx.as_ref())
.get_accepted_by(&mut (*this).uv_pipe, ssl_ctx.as_ref())
};
if result.is_err() {
// connection dropped
Expand Down Expand Up @@ -1866,7 +1913,7 @@ impl WindowsNamedPipeListeningContext {
listener: NonNull::new(listener).map(bun_ptr::BackRef::from),
global_this: GlobalRef::from(global_this),
vm: global_this.bun_vm(),
ctx: None,
ctx: JsCell::new(None),
}));
// Cleanup guard: once the uv pipe handle is registered with the loop it must be closed via
// uv_close; before that point we can free the struct directly. `deinit()` also
Expand All @@ -1890,7 +1937,7 @@ impl WindowsNamedPipeListeningContext {
match ctx_opts.create_ssl_context(&mut err) {
// SAFETY: `this` was just allocated above; scoped field write.
Some(ctx) => unsafe {
(*this).ctx = Some(ctx);
(*this).ctx.set(Some(ctx));
},
None => return Err(ListenPipeError::Other(crate::Error::InvalidOptions)),
}
Expand Down Expand Up @@ -2062,8 +2109,8 @@ fn decode_sni_result(result: JSValue, abort_handshake: *mut core::ffi::c_int) ->
/// returned `SSL_CTX*` applies to the in-flight handshake only - the caller
/// installs it with `SSL_set_SSL_CTX`, which takes its own reference, and
/// nothing is cached in the SNI tree, so the callback runs per-connection the
/// way Node's does. A null return falls back to the static tree (bind
/// hostname + addContext entries), then the default context. An asynchronous
/// way Node's does. A null return falls back to the static tree
/// (addContext entries), then the default context. An asynchronous
Comment thread
robobun marked this conversation as resolved.
/// SNICallback sets `*abort_handshake = 2` instead: the handshake suspends
/// (select-certificate retry) until the JS resolution calls
/// `handle.resumeSNI(...)` -> `us_socket_sni_resolve()`.
Expand Down
9 changes: 9 additions & 0 deletions src/uws_sys/ListenSocket.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use core::ffi::{c_char, c_int, c_void};

use bun_boringssl_sys::OwnedSslCtx;

use crate::{SocketGroup, SslCtx, us_socket_t};

bun_opaque::opaque_ffi! {
Expand Down Expand Up @@ -70,6 +72,12 @@ impl ListenSocket {
unsafe { us_listen_socket_remove_server_name(self, hostname.as_ptr()) }
}

/// Makes `ctx` the default `SSL_CTX` for sockets accepted from now on.
pub fn set_default_ssl_ctx(&mut self, ctx: &OwnedSslCtx) {
// SAFETY: `ctx` owns a live SSL_CTX, which C up-refs before it stores the pointer.
unsafe { us_listen_socket_set_default_ssl_ctx(self, ctx.as_ptr()) }
}

pub fn on_server_name(
&mut self,
cb: extern "C" fn(*mut ListenSocket, *const c_char, *mut c_int, *mut c_void) -> *mut c_void,
Expand All @@ -92,6 +100,7 @@ unsafe extern "C" {
user: *mut c_void,
) -> c_int;
fn us_listen_socket_remove_server_name(ls: *mut ListenSocket, hostname: *const c_char);
fn us_listen_socket_set_default_ssl_ctx(ls: *mut ListenSocket, ctx: *mut SslCtx);
safe fn us_listen_socket_on_server_name(
ls: &mut ListenSocket,
cb: extern "C" fn(*mut ListenSocket, *const c_char, *mut c_int, *mut c_void) -> *mut c_void,
Expand Down
Loading
Loading