diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 1ae954c56e63..cee6bbb2c40a 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -1522,6 +1522,16 @@ void us_internal_ssl_ctx_unref(SSL_CTX *p) { if (p) SSL_CTX_free(p); } +/* Clears the per-domain userdata (uWS HttpRouter*) stored on a SNI SSL_CTX. + * App.h::removeServerName() deletes the router while live keep-alive + * connections may still hold a ref on this SSL_CTX via SSL_set_SSL_CTX(); + * without clearing, the next request on such a connection would read the + * freed router through us_socket_server_name_userdata(). After clearing, + * HttpContext.h falls back to the default router. */ +void us_internal_ssl_ctx_clear_sni_userdata(struct ssl_ctx_st *p) { + if (p && us_sni_ex_idx >= 0) SSL_CTX_set_ex_data(p, us_sni_ex_idx, NULL); +} + /* ── Per-socket SSL attach/detach ────────────────────────────────────────── */ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 9e23b45b7f49..6a53c9c50e20 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -554,6 +554,7 @@ struct ssl_ctx_st *us_ssl_ctx_from_options( * (uWS App.h) that don't pull in BoringSSL headers. */ void us_internal_ssl_ctx_up_ref(struct ssl_ctx_st *ssl_ctx); void us_internal_ssl_ctx_unref(struct ssl_ctx_st *ssl_ctx); +void us_internal_ssl_ctx_clear_sni_userdata(struct ssl_ctx_st *ssl_ctx); long us_ssl_ctx_live_count(void); /* Appends the certificates in the PEM `content` to `ctx`'s trust store; * returns 0 when nothing could be added. */ diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index eb5ee2ba132d..bcf2e3f7f4c7 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -188,6 +188,14 @@ struct TemplatedApp { }); for (auto it = pendingServerNames.begin(); it != pendingServerNames.end(); ) { if (it->hostname == hostname_pattern) { + /* Live keep-alive connections accepted under this SNI + * still hold a ref on it->ctx (via SSL_set_SSL_CTX in + * sni_cb) and would read the freed router through + * us_socket_server_name_userdata() on their next + * request. Clear the ex_data slot so those connections + * fall back to the default router instead of + * dereferencing freed memory. */ + us_internal_ssl_ctx_clear_sni_userdata(it->ctx); us_internal_ssl_ctx_unref(it->ctx); delete it->router; it = pendingServerNames.erase(it); diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 19c94ee3fe6c..1c31a84d730b 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -75,6 +75,7 @@ const serverSymbol = Symbol.for("::bunternal::"); const kPendingCallbacks = Symbol("pendingCallbacks"); const kRequest = Symbol("request"); const kCloseCallback = Symbol("closeCallback"); +const kSNIContexts = Symbol("sniContexts"); export const enum ClientRequestEmitState { socket = 1, @@ -597,6 +598,7 @@ export { kRequest, kRes, kReusedSocket, + kSNIContexts, kSignal, kSocketPath, kTimeoutTimer, diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index c826d944b4c1..f9c156ec112c 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -178,9 +178,112 @@ function processPfxOptions(options) { return out; } +// Node.js only requests a client certificate when `requestCert: true`. +// The uSockets SSL context treats `ca` alone as "verify peer", so without +// these two flags an `https.Server({ ca })` would reject every client that +// doesn't present a cert. Mirror tls.Server (net.ts): default `requestCert` +// to false and, when not requesting, force `rejectUnauthorized` to false so +// the CA is loaded into the trust store without requiring a client cert. +function normalizeServerTls(tls) { + const requestCert = !!tls.requestCert; + tls.requestCert = requestCert; + tls.rejectUnauthorized = requestCert ? tls.rejectUnauthorized !== false : false; + return tls; +} + +/** + * Turns the TLS options Node's http.Server / https.Server accept (in the + * constructor, `setSecureContext()` and `addContext()`) into the `tls` object + * handed to `Bun.serve`. Every option is validated before anything is built, + * so a throw leaves the caller's current config untouched. + * + * Returns `null` when the options carry no key material (pfx/cert/key/ca) + * unless `alwaysTls` is set: a plain http.Server only becomes a TLS server + * when given key material, while an https.Server is one regardless. + */ +function serverTlsFromOptions(options, alwaysTls: boolean) { + let hasKeyMaterial = false; + let tlsOptions = options; + if (options.pfx) { + tlsOptions = processPfxOptions(options); + hasKeyMaterial = true; + } + + const cert = tlsOptions.cert; + if (cert) { + throwOnInvalidTLSArray("options.cert", cert); + hasKeyMaterial = true; + } + + const key = tlsOptions.key; + if (key) { + throwOnInvalidTLSArray("options.key", key); + hasKeyMaterial = true; + } + + let ca = tlsOptions.ca; + // PKCS#12-embedded CAs extend the trust set; the server path hands raw + // {key, cert, ca} to the native config and has no addCACert hook, so fold + // them into `ca` (mirrors tls.Server.setSecureContext). + const pfxExtraCAs = tlsOptions._pfxExtraCACerts; + if (pfxExtraCAs?.length) { + ca = ca == null ? pfxExtraCAs : $isArray(ca) ? [...ca, ...pfxExtraCAs] : [ca, ...pfxExtraCAs]; + } + if (ca) { + throwOnInvalidTLSArray("options.ca", ca); + hasKeyMaterial = true; + } + + const passphrase = options.passphrase; + if (passphrase && typeof passphrase !== "string") { + throw $ERR_INVALID_ARG_TYPE("options.passphrase", "string", passphrase); + } + + const serverName = options.servername; + if (serverName && typeof serverName !== "string") { + throw $ERR_INVALID_ARG_TYPE("options.servername", "string", serverName); + } + + const secureOptions = options.secureOptions || 0; + if (secureOptions && typeof secureOptions !== "number") { + throw $ERR_INVALID_ARG_TYPE("options.secureOptions", "number", secureOptions); + } + + if (!hasKeyMaterial && !alwaysTls) return null; + + // Translate minVersion/maxVersion/secureProtocol into the integer protocol + // range the native layer applies (secureProtocol wins, like Node's + // SecureContext::Init); 0 keeps the native defaults. + validateSecureProtocol(options.secureProtocol); + let minVersion, maxVersion; + const range = secureProtocolToVersionRange(options.secureProtocol); + if (range) { + minVersion = range[0]; + maxVersion = range[1]; + } else { + minVersion = tlsStringToProtocolVersion(options.minVersion); + maxVersion = tlsStringToProtocolVersion(options.maxVersion); + } + return normalizeServerTls({ + serverName, + key, + cert, + ca, + passphrase, + secureOptions, + minVersion, + maxVersion, + ciphers: typeof options.ciphers === "string" && options.ciphers ? options.ciphers : undefined, + requestCert: options.requestCert, + rejectUnauthorized: options.rejectUnauthorized, + }); +} + export { + normalizeServerTls, processPfxOptions, secureProtocolToVersionRange, + serverTlsFromOptions, throwOnInvalidTLSArray, tlsStringToProtocolVersion, validateSecureProtocol, diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 3d32533df07a..8bc2f745a28b 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -22,13 +22,7 @@ const { ConnResetException, hasObserver, startPerf, stopPerf, kInternalSendOptio const kServerResponseStatistics = Symbol("ServerResponseStatistics"); const { isPrimary } = require("internal/cluster/isPrimary"); -const { - throwOnInvalidTLSArray, - tlsStringToProtocolVersion, - secureProtocolToVersionRange, - processPfxOptions, - validateSecureProtocol, -} = require("internal/tls"); +const { normalizeServerTls, serverTlsFromOptions } = require("internal/tls"); const { kInternalSocketData, serverSymbol, @@ -36,6 +30,7 @@ const { kRealListen, tlsSymbol, optionsSymbol, + kSNIContexts, headerStateSymbol, NodeHTTPHeaderState, kPendingCallbacks, @@ -245,19 +240,6 @@ function emitListeningNextTick(self, hostname, port) { } } -// Node.js only requests a client certificate when `requestCert: true`. -// The uSockets SSL context treats `ca` alone as "verify peer", so without -// these two flags an `https.Server({ ca })` would reject every client that -// doesn't present a cert. Mirror tls.Server (net.ts): default `requestCert` -// to false and, when not requesting, force `rejectUnauthorized` to false so -// the CA is loaded into the trust store without requiring a client cert. -function normalizeServerTls(tls) { - const requestCert = !!tls.requestCert; - tls.requestCert = requestCert; - tls.rejectUnauthorized = requestCert ? tls.rejectUnauthorized !== false : false; - return tls; -} - // Node registers connectionListener on every http.Server so `server.emit("connection", socket)` // works for foreign Duplex sockets. The native listener handles its own sockets end to end; // this picks up the rest. https://github.com/nodejs/node/blob/main/lib/_http_server.js @@ -300,84 +282,12 @@ function Server(options, callback): void { validateObject(options, "options"); options = { ...options }; - // Node's https.Server accepts PKCS#12 bundles (pfx [+ passphrase]); fold - // them into plain key/cert/ca so the native TLS config sees PEM material. - let tlsOptions = options; - if (options.pfx) { - tlsOptions = processPfxOptions(options); - this[isTlsSymbol] = true; - } - - let cert = tlsOptions.cert; - if (cert) { - throwOnInvalidTLSArray("options.cert", cert); - this[isTlsSymbol] = true; - } - - let key = tlsOptions.key; - if (key) { - throwOnInvalidTLSArray("options.key", key); - this[isTlsSymbol] = true; - } - - let ca = tlsOptions.ca; - // PKCS#12-embedded CAs extend the trust set; the server path hands raw - // {key, cert, ca} to the native config and has no addCACert hook, so fold - // them into `ca` (mirrors tls.Server.setSecureContext). - const pfxExtraCAs = tlsOptions._pfxExtraCACerts; - if (pfxExtraCAs?.length) { - ca = ca == null ? pfxExtraCAs : $isArray(ca) ? [...ca, ...pfxExtraCAs] : [ca, ...pfxExtraCAs]; - } - if (ca) { - throwOnInvalidTLSArray("options.ca", ca); - this[isTlsSymbol] = true; - } - - let passphrase = options.passphrase; - if (passphrase && typeof passphrase !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.passphrase", "string", passphrase); - } - - let serverName = options.servername; - if (serverName && typeof serverName !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.servername", "string", serverName); - } - - let secureOptions = options.secureOptions || 0; - if (secureOptions && typeof secureOptions !== "number") { - throw $ERR_INVALID_ARG_TYPE("options.secureOptions", "number", secureOptions); - } - - if (this[isTlsSymbol]) { - // Translate minVersion/maxVersion/secureProtocol into the integer - // protocol range the native layer applies (secureProtocol wins, like - // Node's SecureContext::Init); 0 keeps the native defaults. - validateSecureProtocol(options.secureProtocol); - let minVersion, maxVersion; - const range = secureProtocolToVersionRange(options.secureProtocol); - if (range) { - minVersion = range[0]; - maxVersion = range[1]; - } else { - minVersion = tlsStringToProtocolVersion(options.minVersion); - maxVersion = tlsStringToProtocolVersion(options.maxVersion); - } - this[tlsSymbol] = normalizeServerTls({ - serverName, - key, - cert, - ca, - passphrase, - secureOptions, - minVersion, - maxVersion, - ciphers: typeof options.ciphers === "string" && options.ciphers ? options.ciphers : undefined, - requestCert: options.requestCert, - rejectUnauthorized: options.rejectUnauthorized, - }); - } else { - this[tlsSymbol] = null; - } + // https.Server sets isTlsSymbol before calling: it is a TLS server even + // when constructed without key material (certificates can arrive later via + // setSecureContext()/addContext()). An http.Server is one only if given some. + const tls = serverTlsFromOptions(options, this[isTlsSymbol] === true); + if (tls !== null) this[isTlsSymbol] = true; + this[tlsSymbol] = tls; } this[optionsSymbol] = options; @@ -637,6 +547,17 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort if (tls) { this.serverName = tls.serverName || host || "localhost"; } + const sniContexts = this[kSNIContexts]; + const sniContextsLength = sniContexts ? sniContexts.length : 0; + if (sniContextsLength > 0) { + // tls array: [default context, ...SNI contexts]. The first entry must be + // the default so an SNI context is never promoted to it. + const tlsArray = [tls ?? { requestCert: false, rejectUnauthorized: false }]; + for (let i = 0; i < sniContextsLength; i++) { + tlsArray.push(sniContexts[i]); + } + tls = tlsArray; + } this[serverSymbol] = Bun.serve({ idleTimeout: 0, // nodejs dont have a idleTimeout by default tls, diff --git a/src/js/node/https.ts b/src/js/node/https.ts index 9d0ba688ee63..7727c560c706 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -8,10 +8,23 @@ const net = require("node:net"); const { urlToHttpOptions } = require("internal/url"); const { kEmptyObject, once } = require("internal/shared"); const { validateObject } = require("internal/validators"); -const { kProxyConfig, checkShouldUseProxy, kWaitForProxyTunnel } = require("internal/http"); +const { + kProxyConfig, + checkShouldUseProxy, + kWaitForProxyTunnel, + kSNIContexts, + isTlsSymbol, + tlsSymbol, + serverSymbol, +} = require("internal/http"); const { validateHeaderValue } = require("node:_http_common"); +const { serverTlsFromOptions } = require("internal/tls"); + +const httpServerAddServerName = $newRustFunction("node_http_binding.rs", "httpServerAddServerName", 3); const ArrayPrototypeShift = Array.prototype.shift; +const ArrayPrototypePush = Array.prototype.push; +const ArrayPrototypeSplice = Array.prototype.splice; const ObjectAssign = Object.assign; const ArrayPrototypeUnshift = Array.prototype.unshift; const JSONStringify = JSON.stringify; @@ -494,14 +507,13 @@ Agent.prototype._evictSession = function _evictSession(key) { delete this._sessionCache.map[key]; }; -const { shouldUseEnvProxy } = require("node:_http_agent"); - // Like Node's https.Server constructor: default ALPNProtocols to ['http/1.1'] // when neither ALPNProtocols nor ALPNCallback was given, and store the // normalized protocol list / callback on the server instance the way // tls.Server does (test-https-argument-of-creating.js). // https://github.com/nodejs/node/blob/v26.3.0/lib/https.js#L82-L97 -function createServer(options, requestListener) { +function Server(options, requestListener): void { + if (!(this instanceof Server)) return new Server(options, requestListener); if (typeof options === "function") { requestListener = options; options = {}; @@ -516,14 +528,76 @@ function createServer(options, requestListener) { // ALPN requests are always answered with http/1.1. options.ALPNProtocols = ["http/1.1"]; } - const server = http.createServer(options, requestListener); + // Tells http.Server to build a TLS config even when no key material was given. + this[isTlsSymbol] = true; + http.Server.$call(this, options, requestListener); const optionsALPNProtocols = options.ALPNProtocols; if (optionsALPNProtocols) { - tls.convertALPNProtocols(optionsALPNProtocols, server); + tls.convertALPNProtocols(optionsALPNProtocols, this); } - server.ALPNCallback = options.ALPNCallback; - return server; + this.ALPNCallback = options.ALPNCallback; } +$toClass(Server, "Server", http.Server); + +Server.prototype.addContext = function (hostname, context) { + if (typeof hostname !== "string") { + throw new TypeError("hostname must be a string"); + } + if (hostname === "") { + throw $ERR_TLS_REQUIRED_SERVER_NAME('"servername" is required parameter for Server.addContext'); + } + const entry = serverTlsFromOptions(context ?? kEmptyObject, true); + entry.serverName = hostname; + const contexts = (this[kSNIContexts] ??= []); + const bunServer = this[serverSymbol]; + if (bunServer) { + // Throws on bad key material, before the previous entry below is dropped. + httpServerAddServerName(bunServer, hostname, entry); + } + // Last context added for a hostname wins, as in Node; Bun.serve rejects duplicate serverNames. + for (let i = contexts.length - 1; i >= 0; i--) { + if (contexts[i].serverName === hostname) { + ArrayPrototypeSplice.$call(contexts, i, 1); + } + } + ArrayPrototypePush.$call(contexts, entry); +}; + +Server.prototype.setSecureContext = function (options) { + if (options == null) return; + validateObject(options, "options"); + // Built in full from `options` (so anything omitted is cleared, as in Node) + // and only assigned once every option has been validated. + const next = serverTlsFromOptions(options, true); + const previous = this[tlsSymbol]; + if (previous) { + // The client certificate policy belongs to the server, not to the + // certificate material: Node's setSecureContext() leaves it alone too. + next.requestCert = previous.requestCert; + next.rejectUnauthorized = previous.rejectUnauthorized; + } + this[tlsSymbol] = next; +}; + +Server.prototype.getTicketKeys = function () { + throw Error("Not implemented in Bun yet"); +}; + +Server.prototype.setTicketKeys = function (keys) { + if (!ArrayBuffer.isView(keys)) { + throw $ERR_INVALID_ARG_TYPE("buffer", ["Buffer", "TypedArray", "DataView"], keys); + } + if (keys.byteLength !== 48) { + throw $ERR_INVALID_ARG_VALUE("buffer", keys, "Session ticket keys must be a 48-byte buffer"); + } + throw Error("Not implemented in Bun yet"); +}; + +function createServer(options, requestListener) { + return new Server(options, requestListener); +} + +const { shouldUseEnvProxy } = require("node:_http_agent"); var https = { Agent, @@ -533,7 +607,7 @@ var https = { timeout: 5000, proxyEnv: shouldUseEnvProxy() ? process.env : undefined, }), - Server: http.Server, + Server, createServer, get, request, diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 5b99788ee43c..f90d8498129d 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1220,6 +1220,9 @@ function Server(options, secureConnectionListener): void { if (typeof hostname !== "string") { throw new TypeError("hostname must be a string"); } + if (hostname === "") { + throw $ERR_TLS_REQUIRED_SERVER_NAME('"servername" is required parameter for Server.addContext'); + } if (!(context instanceof InternalSecureContext)) { context = new InternalSecureContext(context, true); } @@ -1416,7 +1419,7 @@ function Server(options, secureConnectionListener): void { Server.prototype[kNativeSecureContextCtor] = NativeSecureContext; Server.prototype.getTicketKeys = function () { - throw Error("Not implented in Bun yet"); + throw Error("Not implemented in Bun yet"); }; Server.prototype.setTicketKeys = function (keys) { @@ -1426,7 +1429,7 @@ function Server(options, secureConnectionListener): void { if (keys.byteLength !== 48) { throw $ERR_INVALID_ARG_VALUE("buffer", keys, "Session ticket keys must be a 48-byte buffer"); } - throw Error("Not implented in Bun yet"); + throw Error("Not implemented in Bun yet"); }; this[buntls] = function (port, host, isClient) { diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index d1ce4e83dc24..09d2d28f5106 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -378,5 +378,6 @@ const errors: ErrorCodeMapping = [ ["ERR_INSPECTOR_NOT_CONNECTED", Error], ["ERR_INSPECTOR_NOT_WORKER", Error], ["ERR_INSPECTOR_COMMAND", Error], + ["ERR_TLS_REQUIRED_SERVER_NAME", Error], ]; export default errors; diff --git a/src/runtime/node/node_http_binding.rs b/src/runtime/node/node_http_binding.rs index fe288a39bbc4..d3f9c722e543 100644 --- a/src/runtime/node/node_http_binding.rs +++ b/src/runtime/node/node_http_binding.rs @@ -1,5 +1,5 @@ //! `node:http` native binding — `getBunServerAllClosedPromise` / -//! `{get,set}MaxHTTPHeaderSize`. +//! `httpServerAddServerName` / `{get,set}MaxHTTPHeaderSize`. use bun_jsc::{CallFrame, JSGlobalObject, JSValue, JsResult}; @@ -39,6 +39,40 @@ pub(crate) fn get_bun_server_all_closed_promise( Err(global.throw_invalid_argument_type_value("server", "bun.Server", value)) } +pub(crate) fn http_server_add_server_name( + global: &JSGlobalObject, + frame: &CallFrame, +) -> JsResult { + let arguments = frame.arguments(); + if arguments.len() < 3 { + return Err(global.throw_not_enough_arguments("addServerName", 3, arguments.len())); + } + + let server = arguments[0]; + let hostname = arguments[1]; + let options = arguments[2]; + + let name = bun_core::OwnedString::new(hostname.to_bun_string(global)?); + let name_utf8 = name.to_utf8_bytes(); + + macro_rules! try_server { + ($ty:ty) => { + if let Some(this) = server.as_::<$ty>() { + // SAFETY: `JSValue::as_` returns a non-null pointer to the live + // JS-owned server instance; we hold the JS thread for the + // duration of this call so the GC cannot collect it under us. + return unsafe { &mut *this }.add_sni_context(global, &name_utf8, options); + } + }; + } + try_server!(HTTPSServer); + try_server!(DebugHTTPSServer); + try_server!(HTTPServer); + try_server!(DebugHTTPServer); + + Err(global.throw_invalid_argument_type_value("server", "bun.Server", server)) +} + pub(crate) fn get_max_http_header_size( _global: &JSGlobalObject, _frame: &CallFrame, diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index d5f704f50fca..03e9876c9afd 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -2617,6 +2617,112 @@ where Ok(JSValue::js_number(closed as f64)) } + /// `node:https` `Server#addContext()` after `listen()`: registers the SNI + /// context on the running app and installs this server's routes for it. + pub(crate) fn add_sni_context( + &mut self, + global: &JSGlobalObject, + hostname: &[u8], + options: JSValue, + ) -> JsResult { + use crate::socket::{SSLConfig, SSLConfigFromJs}; + if !SSL { + return Err( + global.throw_invalid_arguments(format_args!("addContext requires SSL support")) + ); + } + if self.app.is_none() { + return Ok(JSValue::UNDEFINED); + } + // Same guard as `Listener::add_server_name`: the SNI tree would file an + // empty name on its root, where nothing can ever match it. + if hostname.is_empty() { + return Err( + global.throw_invalid_arguments(format_args!("hostname pattern cannot be empty")) + ); + } + let server_name = match std::ffi::CString::new(hostname) { + Ok(s) => s, + Err(_) => { + return Err(global + .throw_invalid_arguments(format_args!("hostname must not contain NUL bytes"))); + } + }; + let ssl_config = + SSLConfig::from_js(self.vm(), global, options)?.unwrap_or_else(SSLConfig::zero); + let ssl_opts = ssl_config.as_usockets(); + // Build the SSL_CTX once up front: bad key material must fail before + // remove_server_name() below drops the hostname's existing context. + { + let mut create_err = uws::create_bun_socket_error_t::none; + match ssl_opts.create_ssl_context(&mut create_err) { + Some(probe) => { + // SAFETY: create_ssl_context returned a +1 ref; release it. + unsafe { bun_boringssl_sys::SSL_CTX_free(probe) }; + } + None => { + if create_err != uws::create_bun_socket_error_t::none { + return Err(global.throw_value( + crate::socket::uws_jsc::create_bun_socket_error_to_js( + create_err, global, + ), + )); + } + if !super::throw_ssl_error_if_necessary(global) { + return Err(global.throw(format_args!( + "Failed to create SSL context for serverName: {}", + bstr::BStr::new(server_name.to_bytes()) + ))); + } + return Err(JsError::Thrown); + } + } + } + // Last addContext() for a hostname wins (Node semantics); uWS's + // addServerName fails on a duplicate and unregisters the existing one. + self.app_mut().remove_server_name(&server_name); + // apply_client_cert_policy: same as the per-serverName `tls` entries in `listen`. + if self + .app_mut() + .add_server_name_with_options(&server_name, &ssl_opts, true) + .is_err() + { + if !global.has_exception() && !super::throw_ssl_error_if_necessary(global) { + return Err(global.throw(format_args!( + "Failed to add serverName: {}", + bstr::BStr::new(server_name.to_bytes()) + ))); + } + return Err(JsError::Thrown); + } + // SAFETY: server_name is a CString with a trailing NUL byte. + let z = unsafe { + bun_core::ZStr::from_raw(server_name.as_ptr().cast(), server_name.as_bytes().len()) + }; + if Self::HAS_H3 { + if let Some(h3_app) = self.h3_app { + if bun_opaque::opaque_deref_mut(h3_app) + .add_server_name_with_options(z, &ssl_opts) + .is_err() + { + // Don't leave the entry registered above serving with no routes. + self.app_mut().remove_server_name(&server_name); + if !global.has_exception() && !super::throw_ssl_error_if_necessary(global) { + return Err(global.throw(format_args!( + "Failed to add serverName \"{}\" for HTTP/3", + bstr::BStr::new(server_name.to_bytes()) + ))); + } + return Err(JsError::Thrown); + } + } + } + // Routes are installed into the router domain() selects. + self.app_mut().domain(z); + let _ = self.set_routes(); + Ok(JSValue::UNDEFINED) + } + pub(crate) fn stop_from_js(&mut self, abruptly: Option) -> JSValue { let rc = self.get_all_closed_promise(&self.global()); diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index c051b5aeb846..16ddfd1a38d6 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -343,6 +343,17 @@ impl App { } } + pub fn remove_server_name(&mut self, hostname_pattern: &core::ffi::CStr) { + // SAFETY: self is a valid app; hostname_pattern is NUL-terminated. + unsafe { + c::uws_remove_server_name( + Self::SSL_FLAG, + std::ptr::from_mut::(self).cast::(), + hostname_pattern.as_ptr(), + ) + } + } + pub fn add_server_name_with_options( &mut self, hostname_pattern: &core::ffi::CStr, @@ -617,6 +628,11 @@ pub mod c { opcode: Opcode, compress: bool, ) -> SendStatus; + pub(crate) fn uws_remove_server_name( + ssl: i32, + app: *mut uws_app_t, + hostname_pattern: *const c_char, + ); pub(crate) fn uws_add_server_name_with_options( ssl: i32, app: *mut uws_app_t, diff --git a/test/js/node/http/node-https-server-context.test.ts b/test/js/node/http/node-https-server-context.test.ts new file mode 100644 index 000000000000..cca64c51dd11 --- /dev/null +++ b/test/js/node/http/node-https-server-context.test.ts @@ -0,0 +1,386 @@ +// https://github.com/oven-sh/bun/issues/12157 +// https.Server should expose the same SNI helpers as tls.Server. +import { describe, expect, test } from "bun:test"; +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import http from "node:http"; +import https from "node:https"; +import type { AddressInfo } from "node:net"; +import { join } from "node:path"; +import tls from "node:tls"; + +const fixtures = join(import.meta.dir, "..", "tls", "fixtures"); +const load = (name: string) => readFileSync(join(fixtures, name), "utf8"); + +const agent1Cert = load("agent1-cert.pem"); +const agent1Key = load("agent1-key.pem"); +const agent2Cert = load("agent2-cert.pem"); +const agent2Key = load("agent2-key.pem"); +const agent3Cert = load("agent3-cert.pem"); +const agent3Key = load("agent3-key.pem"); +const ca1 = load("ca1-cert.pem"); +// agent1's key and certificate as a PKCS#12 bundle (passphrase "sample"). +const agent1Pfx = readFileSync(join(import.meta.dir, "..", "test", "fixtures", "keys", "agent1.pfx")); + +async function peerCN(port: number, servername?: string, extra: tls.ConnectionOptions = {}) { + const socket = tls.connect({ host: "127.0.0.1", port, servername, rejectUnauthorized: false, ...extra }); + const errored = once(socket, "error"); + await Promise.race([once(socket, "secureConnect"), errored.then(([e]) => Promise.reject(e))]); + const cert = socket.getPeerCertificate(); + socket.destroy(); + return cert.subject?.CN; +} + +// peerCN() that resolves with the error code when the handshake is refused. +// Deliberately not `expect().rejects`: its nested event loop spin currently segfaults on Windows. +async function handshakeOutcome(port: number, extra: tls.ConnectionOptions) { + try { + return { cn: await peerCN(port, undefined, extra) }; + } catch (err) { + return { code: (err as NodeJS.ErrnoException).code }; + } +} + +// Resolves with the server certificate's CN when a request round-trips, or with +// the error code when the server refuses the client (at the handshake or, for +// TLS 1.3 client-certificate failures, right after it). +async function requestOutcome(port: number, extra: https.RequestOptions = {}) { + const { promise, resolve } = Promise.withResolvers<{ cn: string | undefined } | { code: string }>(); + https + .get({ host: "127.0.0.1", port, rejectUnauthorized: false, agent: false, ...extra }, res => { + res.resume(); + resolve({ cn: (res.socket as tls.TLSSocket).getPeerCertificate().subject?.CN }); + }) + .on("error", (err: NodeJS.ErrnoException) => resolve({ code: err.code ?? err.message })); + return promise; +} + +// `agent: false` so every call opens a fresh connection and therefore a fresh +// SNI lookup; a pooled keep-alive socket would keep serving the cert (and +// router) selected when it was first opened. +async function httpsGetViaSNI(port: number, servername: string) { + const { promise, resolve, reject } = Promise.withResolvers<{ cn: string | undefined; body: string }>(); + https + .get( + { host: "127.0.0.1", port, servername, headers: { Host: servername }, rejectUnauthorized: false, agent: false }, + res => { + const cn = (res.socket as tls.TLSSocket).getPeerCertificate().subject?.CN; + res.setEncoding("utf8"); + let body = ""; + res.on("data", chunk => (body += chunk)); + res.on("error", reject); + res.on("end", () => resolve({ cn, body })); + }, + ) + .on("error", reject); + return promise; +} + +async function listen(server: https.Server) { + const listenErr = once(server, "error"); + server.listen(0); + await Promise.race([once(server, "listening"), listenErr.then(([e]) => Promise.reject(e))]); + return (server.address() as AddressInfo).port; +} + +describe("https.Server", () => { + test("exposes tls.Server methods and is an http.Server subclass", () => { + const server = https.createServer({ key: agent1Key, cert: agent1Cert }); + expect({ + addContext: typeof server.addContext, + setSecureContext: typeof server.setSecureContext, + getTicketKeys: typeof server.getTicketKeys, + setTicketKeys: typeof server.setTicketKeys, + }).toEqual({ + addContext: "function", + setSecureContext: "function", + getTicketKeys: "function", + setTicketKeys: "function", + }); + expect(server instanceof https.Server).toBe(true); + expect(server instanceof http.Server).toBe(true); + expect(() => server.addContext(123 as any, {})).toThrow(TypeError); + expect(() => server.addContext(123 as any, {})).toThrow("hostname must be a string"); + }); + + // https://github.com/oven-sh/bun/issues/31125 + // supertest <= 6.1.6 and @astrojs/node pick the protocol with + // `app instanceof https.Server`, so a plain http.Server must not match. + test("is a distinct class from http.Server", () => { + expect(https.Server).not.toBe(http.Server); + const plain = http.createServer(); + const secure = new https.Server({ key: agent1Key, cert: agent1Cert }); + expect({ + plainIsHttp: plain instanceof http.Server, + plainIsHttps: plain instanceof https.Server, + secureIsHttp: secure instanceof http.Server, + secureIsHttps: secure instanceof https.Server, + httpServerHasAddContext: "addContext" in plain, + }).toEqual({ + plainIsHttp: true, + plainIsHttps: false, + secureIsHttp: true, + secureIsHttps: true, + httpServerHasAddContext: false, + }); + }); + + test("addContext registers a SNI context before listen", async () => { + const server = https.createServer({ key: agent2Key, cert: agent2Cert }, (req, res) => { + res.writeHead(200); + res.end("ok"); + }); + try { + server.addContext("a.example.com", { key: agent1Key, cert: agent1Cert }); + server.addContext("b.example.com", { key: agent3Key, cert: agent3Cert }); + + const port = await listen(server); + + expect(await peerCN(port, "a.example.com")).toBe("agent1"); + expect(await peerCN(port, "b.example.com")).toBe("agent3"); + // A hostname with no SNI match falls through to the default context. + expect(await peerCN(port, "unknown.example.com")).toBe("agent2"); + } finally { + server.close(); + } + }); + + test("addContext registers a SNI context after listen", async () => { + const server = https.createServer({ key: agent2Key, cert: agent2Cert }, (req, res) => { + res.writeHead(200); + res.end("ok"); + }); + try { + const port = await listen(server); + expect(await peerCN(port, "a.example.com")).toBe("agent2"); + + server.addContext("a.example.com", { key: agent1Key, cert: agent1Cert }); + server.addContext("b.example.com", { key: agent3Key, cert: agent3Cert }); + + expect(await peerCN(port, "a.example.com")).toBe("agent1"); + expect(await peerCN(port, "b.example.com")).toBe("agent3"); + expect(await peerCN(port, "unknown.example.com")).toBe("agent2"); + + // The SNI-selected domain must also have routes installed (not just + // a TLS context), so an HTTP request over that SNI reaches the + // request handler. + expect(await httpsGetViaSNI(port, "a.example.com")).toEqual({ cn: "agent1", body: "ok" }); + expect(await httpsGetViaSNI(port, "b.example.com")).toEqual({ cn: "agent3", body: "ok" }); + } finally { + server.close(); + } + }); + + test("addContext with a repeated hostname replaces the previous context", async () => { + const server = https.createServer({ key: agent2Key, cert: agent2Cert }, (req, res) => { + res.writeHead(200); + res.end("ok"); + }); + try { + server.addContext("a.example.com", { key: agent1Key, cert: agent1Cert }); + server.addContext("a.example.com", { key: agent3Key, cert: agent3Cert }); + + const port = await listen(server); + // pre-listen: the most recently added context wins + expect(await peerCN(port, "a.example.com")).toBe("agent3"); + + // post-listen: re-adding the same hostname replaces rather than throws + server.addContext("a.example.com", { key: agent1Key, cert: agent1Cert }); + expect(await peerCN(port, "a.example.com")).toBe("agent1"); + expect(await httpsGetViaSNI(port, "a.example.com")).toEqual({ cn: "agent1", body: "ok" }); + + server.addContext("a.example.com", { key: agent3Key, cert: agent3Cert }); + expect(await peerCN(port, "a.example.com")).toBe("agent3"); + + // A re-add with a malformed cert throws, and must not strip the + // previous working SNI entry. + expect(() => + server.addContext("a.example.com", { key: agent1Key, cert: "-----BEGIN CERTIFICATE-----\ntruncated" }), + ).toThrow("PEM routines"); + expect(await peerCN(port, "a.example.com")).toBe("agent3"); + } finally { + server.close(); + } + }); + + test("addContext re-add does not break keep-alive connections on the previous SNI context", async () => { + const server = https.createServer({ key: agent2Key, cert: agent2Cert }, (req, res) => { + res.writeHead(200, { "Content-Length": "2" }); + res.end("ok"); + }); + try { + const port = await listen(server); + server.addContext("a.example.com", { key: agent1Key, cert: agent1Cert }); + + const socket = tls.connect({ host: "127.0.0.1", port, servername: "a.example.com", rejectUnauthorized: false }); + const errored = once(socket, "error").then(([e]) => Promise.reject(e)); + const closed = once(socket, "close").then(() => Promise.reject(new Error("socket closed before response"))); + try { + await Promise.race([once(socket, "secureConnect"), errored, closed]); + expect(socket.getPeerCertificate().subject?.CN).toBe("agent1"); + + const readResponse = async () => { + const chunks: Buffer[] = []; + while (true) { + const [chunk] = await Promise.race([once(socket, "data"), closed, errored]); + chunks.push(chunk); + const raw = Buffer.concat(chunks).toString("utf8"); + const sep = raw.indexOf("\r\n\r\n"); + if (sep >= 0 && raw.length >= sep + 4 + 2) return raw.slice(sep + 4, sep + 4 + 2); + } + }; + + socket.write("GET / HTTP/1.1\r\nHost: a.example.com\r\n\r\n"); + expect(await readResponse()).toBe("ok"); + + // Replace the SNI context while the keep-alive connection is open; + // the per-domain router for the previous SSL_CTX is freed here. + server.addContext("a.example.com", { key: agent3Key, cert: agent3Cert }); + + // A second request on the same connection must fall back to the + // default router rather than dereferencing the freed per-domain one. + socket.write("GET / HTTP/1.1\r\nHost: a.example.com\r\n\r\n"); + expect(await readResponse()).toBe("ok"); + } finally { + socket.destroy(); + } + } finally { + server.close(); + } + }); + + test("addContext rejects an empty hostname before and after listen", async () => { + const server = https.createServer({ key: agent2Key, cert: agent2Cert }); + try { + const requiredServerName = '"servername" is required parameter for Server.addContext'; + expect(() => server.addContext("", { key: agent1Key, cert: agent1Cert })).toThrow(requiredServerName); + // The rejected call must not have queued anything that breaks listen(). + const port = await listen(server); + expect(await peerCN(port)).toBe("agent2"); + expect(() => server.addContext("", { key: agent1Key, cert: agent1Cert })).toThrow(requiredServerName); + expect(await peerCN(port)).toBe("agent2"); + } finally { + server.close(); + } + }); + + test("addContext accepts the same options as the constructor (pfx) before and after listen", async () => { + const server = https.createServer({ key: agent2Key, cert: agent2Cert }); + try { + server.addContext("a.example.com", { pfx: agent1Pfx, passphrase: "sample" }); + const port = await listen(server); + server.addContext("b.example.com", { pfx: agent1Pfx, passphrase: "sample" }); + expect({ + a: await peerCN(port, "a.example.com"), + b: await peerCN(port, "b.example.com"), + other: await peerCN(port, "c.example.com"), + }).toEqual({ a: "agent1", b: "agent1", other: "agent2" }); + } finally { + server.close(); + } + }); + + test("setSecureContext replaces the default context before listen", async () => { + const server = https.createServer({ key: agent2Key, cert: agent2Cert }, (req, res) => { + res.writeHead(200); + res.end("ok"); + }); + try { + server.setSecureContext({ key: agent3Key, cert: agent3Cert }); + const port = await listen(server); + expect(await peerCN(port)).toBe("agent3"); + } finally { + server.close(); + } + }); + + test("setSecureContext with an invalid option applies nothing", async () => { + const server = https.createServer({ key: agent2Key, cert: agent2Cert }, (req, res) => { + res.writeHead(200); + res.end("ok"); + }); + try { + // Rejected on `key`, after `cert` was already read: the new cert must not + // be left paired with the old key. + expect(() => server.setSecureContext({ cert: agent3Cert, key: 123 as any })).toThrow( + 'The "options.key" property must be of type string or an instance of Buffer, TypedArray, or DataView.', + ); + const port = await listen(server); + expect(await peerCN(port)).toBe("agent2"); + } finally { + server.close(); + } + }); + + test("setSecureContext on a server with no initial TLS options does not require a client certificate", async () => { + const server = https.createServer((req, res) => { + res.writeHead(200); + res.end("ok"); + }); + try { + server.setSecureContext({ key: agent1Key, cert: agent1Cert, ca: ca1 }); + const port = await listen(server); + expect(await peerCN(port)).toBe("agent1"); + } finally { + server.close(); + } + }); + + test("setSecureContext accepts the same options as the constructor (pfx, minVersion)", async () => { + const server = https.createServer({ key: agent2Key, cert: agent2Cert }); + try { + server.setSecureContext({ pfx: agent1Pfx, passphrase: "sample", minVersion: "TLSv1.3" }); + const port = await listen(server); + expect(await peerCN(port)).toBe("agent1"); + expect(await handshakeOutcome(port, { maxVersion: "TLSv1.2" })).toEqual({ + code: "ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION", + }); + } finally { + server.close(); + } + }); + + test("setSecureContext clears options the constructor had set but the new call omits", async () => { + const server = https.createServer({ key: agent2Key, cert: agent2Cert, minVersion: "TLSv1.3" }); + try { + server.setSecureContext({ key: agent3Key, cert: agent3Cert }); + const port = await listen(server); + expect(await peerCN(port, undefined, { maxVersion: "TLSv1.2" })).toBe("agent3"); + } finally { + server.close(); + } + }); + + test("setSecureContext rejects an unknown secureProtocol and applies nothing", async () => { + const server = https.createServer({ key: agent2Key, cert: agent2Cert }); + try { + expect(() => + server.setSecureContext({ key: agent3Key, cert: agent3Cert, secureProtocol: "bogus_method" }), + ).toThrow( + expect.objectContaining({ code: "ERR_TLS_INVALID_PROTOCOL_METHOD", message: "Unknown method: bogus_method" }), + ); + const port = await listen(server); + expect(await peerCN(port)).toBe("agent2"); + } finally { + server.close(); + } + }); + + test("setSecureContext keeps the client certificate policy the server was created with", async () => { + const server = https.createServer( + { key: agent2Key, cert: agent2Cert, ca: ca1, requestCert: true, rejectUnauthorized: true }, + (req, res) => res.end("ok"), + ); + try { + // Like Node, requestCert/rejectUnauthorized are server settings; swapping + // the certificate (with a call that does not mention them) keeps them. + server.setSecureContext({ key: agent3Key, cert: agent3Cert, ca: ca1 }); + const port = await listen(server); + // agent1 is issued by ca1, so it is the one client the server accepts. + expect(await requestOutcome(port, { key: agent1Key, cert: agent1Cert })).toEqual({ cn: "agent3" }); + expect(await requestOutcome(port)).toEqual({ code: expect.stringMatching(/^ERR_SSL_|^ECONNRESET$/) }); + } finally { + server.close(); + } + }); +}); diff --git a/test/js/node/tls/node-tls-context.test.ts b/test/js/node/tls/node-tls-context.test.ts index 4a4abf3a0358..77e10e1dabf9 100644 --- a/test/js/node/tls/node-tls-context.test.ts +++ b/test/js/node/tls/node-tls-context.test.ts @@ -140,6 +140,26 @@ describe("tls.Server", () => { } }); + it("addContext rejects an empty hostname up front, like Node", async () => { + const server = tls.createServer({ key: agent2Key, cert: agent2Cert }); + const context = { key: agent1Key, cert: agent1Cert }; + const requiredServerName = expect.objectContaining({ + code: "ERR_TLS_REQUIRED_SERVER_NAME", + message: '"servername" is required parameter for Server.addContext', + }); + try { + expect(() => server.addContext("", context)).toThrow(requiredServerName); + // Nothing was buffered for the empty name, so listen() still succeeds. + const { promise, resolve, reject } = Promise.withResolvers(); + server.once("error", reject); + server.listen(0, resolve); + await promise; + expect(() => server.addContext("", context)).toThrow(requiredServerName); + } finally { + server.close(); + } + }); + it("should select the most recently added SecureContext", async () => { let listening_server: tls.Server | null = null; const { promise, resolve, reject } = Promise.withResolvers();