diff --git a/packages/bun-usockets/src/context.c b/packages/bun-usockets/src/context.c index 9489f5468fb7..8e94c81b8070 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -377,6 +377,7 @@ static void us_internal_init_listen_socket(struct us_listen_socket_t *ls, ls->on_server_name = NULL; ls->socket_ext_size = socket_ext_size; ls->deferred_accept = 0; + ls->keylog_enabled = 0; /* Link into the group so close_all() / test-isolation can find it. */ ls->next = group->head_listen_sockets; diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 32d451e322be..11c7adc0db7e 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -317,6 +317,12 @@ static void ssl_flush_pending_keylog(struct us_socket_t *s) { if (!s->ssl || us_socket_is_closed(s)) { return; } + /* uWS HTTP server sockets have no keylog dispatch; leave the lines parked + * so the JS connection callback can pop them with us_socket_pop_keylog. + * Whatever is never drained is freed by the ex_data destructor. */ + if (us_socket_kind(s) == BUN_SOCKET_KIND_UWS_HTTP_TLS) { + return; + } struct us_ssl_pending_session_t *pending = SSL_get_ex_data(s->ssl, us_ssl_pending_keylog_idx); if (!pending) { @@ -483,6 +489,18 @@ int us_ssl_pop_pending_keylog(SSL *ssl, unsigned char *out, int out_cap) { return us_ssl_pop_pending(ssl, us_ssl_pending_keylog_idx, out, out_cap); } +/* node:https server 'keylog': arm parking for every socket subsequently + * accepted by `ls` (see the keylog_enabled branch in us_internal_ssl_attach). + * The JS layer drains via us_socket_pop_keylog once the handshake completes. */ +void us_listen_socket_enable_keylog(struct us_listen_socket_t *ls) { + ls->keylog_enabled = 1; +} + +int us_socket_pop_keylog(struct us_socket_t *s, unsigned char *out, int out_cap) { + if (!s->ssl) return 0; + return us_ssl_pop_pending_keylog((SSL *)s->ssl, out, out_cap); +} + /* The resumable session most recently delivered via the new-session callback, * or NULL if none has arrived. The returned pointer is borrowed from the SSL's * ex_data and valid until the next NewSessionTicket or SSL_free. */ @@ -1473,7 +1491,8 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, * sockets lives in accept_kind and may not have been copied onto `s` yet * when its SSL is initialized. */ if (ssl && (us_socket_kind(s) == BUN_SOCKET_KIND_BUN_SOCKET_TLS || - (listener && listener->accept_kind == BUN_SOCKET_KIND_BUN_SOCKET_TLS))) { + (listener && (listener->accept_kind == BUN_SOCKET_KIND_BUN_SOCKET_TLS || + listener->keylog_enabled)))) { /* The very first TLS attach in a process can be a client connection, and * nothing on that path has registered the ex_data indices yet - using the * still--1 index would make CRYPTO_set_ex_data grow its slot array toward @@ -1996,18 +2015,24 @@ struct us_socket_t *us_internal_ssl_on_close(struct us_socket_t *s, int code, vo return ret; } -/* The EOF dispatch below is scoped to uWS HTTP server sockets: their - * context's onEnd owns the EOF (premature-EOF clientError - * HPE_INVALID_EOF_STATE, CONNECT/Upgrade half-open, pipeline drain after - * FIN), and closing without dispatching silently skipped all of it for - * node:https. Every other TLS socket kind predates the dispatch and - * synthesizes its JS 'end' from the close event, so they keep the - * historical force-close (dispatching for them strands sockets whose end - * handler expects the transport to close underneath it). */ -static int ssl_wants_eof_dispatch(struct us_socket_t *s) { +static int ssl_is_uws_http_tls(struct us_socket_t *s) { return us_socket_kind(s) == BUN_SOCKET_KIND_UWS_HTTP_TLS; } +/* EOF dispatch only for kinds whose user layer consumes 'end' + honors + * allow_half_open (uWS HTTP server, Bun.connect/listen ⇒ node:tls); all + * other TLS kinds derive EOF from close and keep the force-close. */ +static int ssl_wants_eof_dispatch(struct us_socket_t *s) { + unsigned char kind = us_socket_kind(s); + if (kind == BUN_SOCKET_KIND_UWS_HTTP_TLS) { + return 1; + } + /* Mid-handshake EOF keeps the force-close so JS surfaces it as a failed + * handshake (ECONNRESET "socket hang up", like Node) not a clean 'end'. */ + return kind == BUN_SOCKET_KIND_BUN_SOCKET_TLS && + s->ssl_handshake_state == HANDSHAKE_COMPLETED; +} + /* Deliver the plaintext EOF to the user layer once, like the plain-TCP path * (loop.c dispatches us_dispatch_end for non-SSL sockets). Both TLS EOF * paths (peer close_notify -> ZERO_RETURN, and the raw TCP FIN that usually @@ -2126,7 +2151,7 @@ struct us_socket_t *us_internal_ssl_on_writable(struct us_socket_t *s) { * onWritable clears the teardown timeout armed at shutdown. node sockets * still get write-completion dispatch after a half-close in either * direction. */ - if (ssl_wants_eof_dispatch(s) && us_internal_ssl_is_shut_down(s)) return s; + if (ssl_is_uws_http_tls(s) && us_internal_ssl_is_shut_down(s)) return s; if (s->ssl_handshake_state == HANDSHAKE_COMPLETED) { s = us_dispatch_writable(s); @@ -2882,6 +2907,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; diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 38106f69c95a..c51eef3e1fe4 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -446,6 +446,9 @@ struct us_listen_socket_t { unsigned int socket_ext_size; /* kind to stamp on accepted sockets. */ unsigned char accept_kind; + /* node:https server 'keylog': park NSS key-log lines on accepted sockets' + * SSLs so the JS layer can drain them after the handshake. */ + unsigned char keylog_enabled; /* Set when TCP_DEFER_ACCEPT/SO_ACCEPTFILTER was successfully applied. */ unsigned char deferred_accept; }; diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 105fdd599670..2af50b3afe5a 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -409,6 +409,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, @@ -551,6 +556,10 @@ int us_ssl_ctx_add_ca_cert(struct ssl_ctx_st *ctx, const char *content); void us_ssl_enable_pending_events(struct ssl_st *ssl); int us_ssl_pop_pending_session(struct ssl_st *ssl, unsigned char *out, int out_cap); int us_ssl_pop_pending_keylog(struct ssl_st *ssl, unsigned char *out, int out_cap); +/* node:https server 'keylog': park key-log lines for sockets accepted by `ls`; + * drain per-socket with us_socket_pop_keylog after the handshake. */ +void us_listen_socket_enable_keylog(struct us_listen_socket_t *ls) nonnull_fn_decl; +int us_socket_pop_keylog(us_socket_r s, unsigned char *out, int out_cap); /* The resumable session most recently delivered via the new-session callback, * or NULL if none. Borrowed; valid until the next NewSessionTicket or SSL_free. */ struct ssl_session_st *us_ssl_get_new_session(struct ssl_st *ssl); diff --git a/src/boringssl_sys/boringssl.rs b/src/boringssl_sys/boringssl.rs index 6f3bd595c248..695b7c5d3508 100644 --- a/src/boringssl_sys/boringssl.rs +++ b/src/boringssl_sys/boringssl.rs @@ -915,6 +915,9 @@ unsafe extern "C" { pub fn SSL_is_init_finished(ssl: *const SSL) -> c_int; pub fn SSL_set_verify(ssl: *mut SSL, mode: c_int, callback: SSL_verify_cb); pub fn SSL_set0_verify_cert_store(ssl: *mut SSL, store: *mut X509_STORE) -> c_int; + pub fn X509_STORE_new() -> *mut X509_STORE; + /// Takes ownership of `store` (consumes one reference). + pub fn SSL_CTX_set_cert_store(ctx: *mut SSL_CTX, store: *mut X509_STORE); pub fn SSL_set_renegotiate_mode(ssl: *mut SSL, mode: ssl_renegotiate_mode_t); pub fn SSL_renegotiate(ssl: *mut SSL) -> c_int; pub fn SSL_get_servername(ssl: *const SSL, ty: c_int) -> *const c_char; diff --git a/src/http/HTTPContext.rs b/src/http/HTTPContext.rs index 4d099b575c36..6e138f8e0279 100644 --- a/src/http/HTTPContext.rs +++ b/src/http/HTTPContext.rs @@ -542,6 +542,49 @@ impl HTTPContext { self.init_with_opts(&opts) } + /// Swap this context's `SSL_CTX` for one trusting exactly `certs` + /// (`tls.setDefaultCACertificates()`); only new connects see it, empty ⇒ + /// empty trust store. + pub(crate) fn replace_ssl_ctx_with_default_ca(&mut self, certs: &[std::ffi::CString]) { + debug_assert!(SSL, "ssl only"); + let ptrs: Vec<*const core::ffi::c_char> = certs.iter().map(|c| c.as_ptr()).collect(); + let mut err = uws::create_bun_socket_error_t::none; + let opts = uws::SocketContext::BunSocketContextOptions { + ca: if ptrs.is_empty() { + core::ptr::null() + } else { + ptrs.as_ptr().cast() + }, + ca_count: u32::try_from(ptrs.len()).expect("int cast"), + request_cert: 1, + ..Default::default() + }; + let Some(ctx) = opts.create_ssl_context(&mut err) else { + // PEMs were pre-validated (parseCACertificates) so this is resource + // exhaustion; keep the previous ctx rather than drop verification. + return; + }; + if ptrs.is_empty() { + // ca_count == 0 seeds the shared default root store, but + // setDefaultCACertificates([]) means an explicitly empty one. + let store = unsafe { bun_boringssl_sys::X509_STORE_new() }; + if store.is_null() { + // SAFETY: ctx was created above and not yet published. + unsafe { bun_boringssl_sys::SSL_CTX_free(ctx) }; + return; + } + // SAFETY: consumes `store`; ctx owns it from here. + unsafe { bun_boringssl_sys::SSL_CTX_set_cert_store(ctx, store) }; + } + // SAFETY: ctx is the fresh SSL_CTX this context is about to own. + unsafe { ssl_ctx_setup(ctx) }; + if let Some(old) = self.secure.replace(ctx) { + // SAFETY: releases the one ref this context held; sockets created + // from the old ctx keep it alive through their own SSL refs. + unsafe { bun_boringssl_sys::SSL_CTX_free(old) }; + } + } + pub(crate) fn init(&mut self) { let owner_ptr = std::ptr::from_mut::(self).cast::(); self.group diff --git a/src/http/HTTPThread.rs b/src/http/HTTPThread.rs index 1118cc0717cb..55c24a49b3b0 100644 --- a/src/http/HTTPThread.rs +++ b/src/http/HTTPThread.rs @@ -105,6 +105,9 @@ pub struct HttpThread { /// `Option::take` is the once-guard (no atomics needed — `connect` is never /// reentrant). lazy_https_init: Option, + /// Last `crate::default_ca::generation()` applied to `https_context`. + /// 0 = the override was never installed. HTTP-thread-only. + applied_default_ca_generation: u64, pub(crate) queued_tasks: Queue, /// Tasks popped from `queued_tasks` that couldn't start because @@ -172,6 +175,7 @@ impl HttpThread { pending_h2_connects: Vec::new(), }, lazy_https_init: None, + applied_default_ca_generation: 0, queued_tasks: Queue::new(), deferred_tasks: Vec::new(), has_pending_queued_abort: false, @@ -460,6 +464,21 @@ impl HttpThread { if let Some(opts) = self.lazy_https_init.take() { self.init_https_context_cold(&opts); } + // `tls.setDefaultCACertificates()` replaced the default CA set since + // the default HTTPS context was built (or since the last replacement): + // rebuild its SSL_CTX so new connects verify against the current set. + let generation = crate::default_ca::generation(); + if generation != self.applied_default_ca_generation { + self.apply_default_ca_override_cold(generation); + } + } + + #[cold] + fn apply_default_ca_override_cold(&mut self, generation: u64) { + self.applied_default_ca_generation = generation; + if let Some(certs) = crate::default_ca::snapshot() { + self.https_context.replace_ssl_ctx_with_default_ca(&certs); + } } #[cold] diff --git a/src/http/default_ca.rs b/src/http/default_ca.rs new file mode 100644 index 000000000000..a72f57c682f7 --- /dev/null +++ b/src/http/default_ca.rs @@ -0,0 +1,31 @@ +//! `tls.setDefaultCACertificates()` override for `fetch()`'s HTTP-thread TLS +//! contexts (node:tls applies it in JS instead — src/js/node/tls.ts). +//! + +use std::ffi::CString; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use bun_threading::Guarded; + +static OVERRIDE: Guarded>>> = Guarded::new(None); +/// 0 = never set. Bumped after the new store is published, so a reader that +/// observes generation N under Acquire sees at least the store published for +/// N when it takes the lock. +static GENERATION: AtomicU64 = AtomicU64::new(0); + +/// Replaces the default CA set. An empty `certs` means an explicitly empty +/// trust store (every verification fails), matching Node's +/// `tls.setDefaultCACertificates([])`. +pub fn set(certs: Vec) { + *OVERRIDE.lock() = Some(Arc::new(certs)); + GENERATION.fetch_add(1, Ordering::Release); +} + +pub fn generation() -> u64 { + GENERATION.load(Ordering::Acquire) +} + +pub fn snapshot() -> Option>> { + OVERRIDE.lock().clone() +} diff --git a/src/http/lib.rs b/src/http/lib.rs index b5edd7cad4eb..5359465ef954 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -62,6 +62,7 @@ pub use proxy_tunnel::ProxyTunnel; pub use send_file::SendFile; pub use signals::Signals; pub use thread_safe_stream_buffer::ThreadSafeStreamBuffer; +pub mod default_ca; #[path = "ssl_config.rs"] pub mod ssl_config; pub use ssl_config::SSLConfig; diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 394f2a08a924..32fae6fc56bf 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -10,6 +10,8 @@ const { webRequestOrResponseHasBodyValue, setServerCustomOptions, setServerAppFlags, + setServerSecureContext, + enableServerKeylog, getCompleteWebRequestOrResponseBodyValueAsArrayBuffer, drainMicrotasks, setServerIdleTimeout, @@ -37,6 +39,8 @@ const { insecureHTTPParser: boolean, httpAllowHalfOpen: boolean, ) => void; + setServerSecureContext: (server: any, tls: any) => void; + enableServerKeylog: (server: any) => void; getCompleteWebRequestOrResponseBodyValueAsArrayBuffer: (arg: any) => ArrayBuffer | undefined; drainMicrotasks: () => void; setServerIdleTimeout: (server: any, timeout: number) => void; @@ -605,6 +609,7 @@ export { emitCloseNTAndComplete, emitEOFIncomingMessage, emitErrorNextTickIfErrorListenerNT, + enableServerKeylog, eofInProgress, fakeSocketSymbol, filterEnvForProxies, @@ -670,6 +675,7 @@ export { setServerAppFlags, setServerCustomOptions, setServerIdleTimeout, + setServerSecureContext, statusCodeSymbol, statusMessageSymbol, timeoutTimerSymbol, diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 038a68813fae..7af358573404 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -60,6 +60,8 @@ const { drainMicrotasks, setServerCustomOptions, setServerAppFlags, + setServerSecureContext, + enableServerKeylog, getMaxHTTPHeaderSize, fakeSocketSymbol, noBodySymbol, @@ -296,6 +298,111 @@ function normalizeServerTls(tls) { return tls; } +// Fold https.Server TLS options into the normalized tls object the native +// config consumes (null when no TLS material); shared by ctor + setSecureContext. +function processServerTlsOptions(options) { + let isTls = false; + + // 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); + isTls = true; + } + + let cert = tlsOptions.cert; + if (cert) { + throwOnInvalidTLSArray("options.cert", cert); + isTls = true; + } + + let key = tlsOptions.key; + if (key) { + throwOnInvalidTLSArray("options.key", key); + isTls = 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); + isTls = 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 (!isTls) 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, + }); +} + +// tls.Server#setSecureContext: rebuild credentials for future connections +// (installed per-instance on TLS servers only, so http.Server lacks it). +// https://github.com/nodejs/node/blob/main/lib/_tls_wrap.js +function serverSetSecureContext(this: Server, options) { + validateObject(options, "options"); + const tls = processServerTlsOptions({ ...options }) ?? normalizeServerTls({}); + this[tlsSymbol] = tls; + const handle = this[serverSymbol]; + if (handle) setServerSecureContext(handle, tls); +} + +// Like Node's tls.Server: the first 'keylog' listener arms native key-log +// collection (for connections accepted from then on); the hook removes +// itself once armed. +function onKeylogNewListener(this: Server, event) { + if (event !== "keylog") return; + this.removeListener("newListener", onKeylogNewListener); + const handle = this[serverSymbol]; + if (handle) enableServerKeylog(handle); + // Not yet listening: kRealListen arms it right after Bun.serve. +} + function Server(options, callback): void { if (!(this instanceof Server)) return new Server(options, callback); EventEmitter.$call(this); @@ -319,84 +426,12 @@ function Server(options, callback): void { } else { 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); + const tlsFromOptions = processServerTlsOptions(options); + this[tlsSymbol] = tlsFromOptions; + if (tlsFromOptions) { 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; + this.setSecureContext = serverSetSecureContext; + this.on("newListener", onKeylogNewListener); } } @@ -721,7 +756,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort const prevIsNextIncomingMessageHTTPS = getIsNextIncomingMessageHTTPS(); setIsNextIncomingMessageHTTPS(isHTTPS); if (!socket) { - socket = new NodeHTTPServerSocket(server, socketHandle, !!tls); + socket = newNodeHTTPServerSocket(server, socketHandle, !!tls); } // Like Node.js's resetSocketTimeout (parserOnIncoming): a new request @@ -1098,6 +1133,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort getBunServerAllClosedPromise(this[serverSymbol]).$then(emitCloseNTServer.bind(this)); isHTTPS = this[serverSymbol].protocol === "https"; applyServerCustomOptions(this); + if (tls && this.listenerCount("keylog") > 0) enableServerKeylog(this[serverSymbol]); if (this?._unref) { this[serverSymbol]?.unref?.(); @@ -1165,7 +1201,18 @@ function onServerConnection(this: Server, socketHandle) { return; } const isTLS = !!this[tlsSymbol]; - const socket = new NodeHTTPServerSocket(this, socketHandle, isTLS); + const socket = newNodeHTTPServerSocket(this, socketHandle, isTLS); + // Node emits the handshake's key-log lines before 'secureConnection'; drain + // what enableServerKeylog parked (post-handshake KeyUpdate lines not surfaced). + // https://github.com/nodejs/node/blob/main/lib/_tls_wrap.js + if (isTLS && this.listenerCount("keylog") > 0) { + const lines = socketHandle.drainKeylog(); + if (lines !== null) { + for (let i = 0; i < lines.length; i++) { + this.emit("keylog", lines[i], socket); + } + } + } // Node's connectionListener attaches the HTTPParser (socket.parser) before // emitting 'connection'; expose the shim here so listeners see it populated. socket.parser = createServerParserShim(socket); @@ -1233,7 +1280,7 @@ function onServerClientError(ssl: boolean, socket: unknown, errorCode: number, r // kTrackedConnections. Reuse it, and only announce genuinely new // connections - the existing duplex already had its 'connection' event. const existingDuplex = (socket as any).duplex; - const nodeSocket = existingDuplex ?? new NodeHTTPServerSocket(self, socket, ssl); + const nodeSocket = existingDuplex ?? newNodeHTTPServerSocket(self, socket, ssl); if (!existingDuplex) { nodeSocket.parser = createServerParserShim(nodeSocket); self.emit("connection", nodeSocket); @@ -2098,6 +2145,36 @@ function _writeHead(statusCode, reason, obj, response) { Object.defineProperty(NodeHTTPServerSocket, "name", { value: "Socket" }); +// Encrypted sockets must satisfy `instanceof tls.TLSSocket`: splice this +// class's method table above TLSSocket.prototype and run only our ctor, so +// TLS-only surface resolves through TLSSocket while I/O stays ours. +let lazyTLSServerSocketTarget; +function newNodeHTTPServerSocket(server, handle, encrypted) { + if (encrypted) { + if (lazyTLSServerSocketTarget === undefined) { + const { TLSSocket } = require("node:tls"); + const descriptors = Object.getOwnPropertyDescriptors(NodeHTTPServerSocket.prototype); + // TLSSocket.prototype names that exist on the old NetSocket chain (e.g. + // _final) must keep that resolution — the TLS-wrap versions never + // complete on a NodeHTTP handle and would pin the event loop. + for (const name of Object.getOwnPropertyNames(TLSSocket.prototype)) { + if (name === "constructor" || name in descriptors) continue; + let proto = NodeHTTPServerSocket.prototype; + let desc; + while (proto && !(desc = Object.getOwnPropertyDescriptor(proto, name))) { + proto = Object.getPrototypeOf(proto); + } + if (desc) descriptors[name] = desc; + } + const target = function SocketTLS() {}; + target.prototype = Object.create(TLSSocket.prototype, descriptors); + lazyTLSServerSocketTarget = target; + } + return Reflect.construct(NodeHTTPServerSocket, [server, handle, true], lazyTLSServerSocketTarget); + } + return new NodeHTTPServerSocket(server, handle, false); +} + function ServerResponse(req, options): void { if (!(this instanceof ServerResponse)) return new ServerResponse(req, options); OutgoingMessage.$call(this, options); diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index bbbafed70d3d..c99e99491f48 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1608,6 +1608,7 @@ function cacheBundledRootCertificates(): string[] { return bundledRootCertificates; } const getUseSystemCA = $newRustFunction("bun.rs", "getUseSystemCA", 0); +const setDefaultCACertificatesNative = $newRustFunction("bun.rs", "setDefaultCACertificates", 1); let defaultCACertificates: string[] | undefined; function cacheDefaultCACertificates() { @@ -1706,6 +1707,9 @@ function setDefaultCACertificates(certs: ReadonlyArray): void { throw $ERR_CRYPTO_OPERATION_FAILED("No valid certificates found in the provided array"); } _defaultCACertificatesOverride = normalized; + // fetch()'s TLS contexts live on the HTTP client thread; hand it the same + // normalized set so its default SSL_CTX is rebuilt on the next connect. + setDefaultCACertificatesNative(normalized); } function getCACertificates(type = "default") { diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 9baf0b8d436c..bda9e8099320 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -42,6 +42,8 @@ extern "C" EncodedJSValue Server__setAppFlags(JSC::JSGlobalObject*, EncodedJSVal extern "C" EncodedJSValue Server__setOnClientError(JSC::JSGlobalObject*, EncodedJSValue, EncodedJSValue); extern "C" EncodedJSValue Server__setOnConnection(JSC::JSGlobalObject*, EncodedJSValue, EncodedJSValue); extern "C" EncodedJSValue Server__setMaxHTTPHeaderSize(JSC::JSGlobalObject*, EncodedJSValue, uint64_t); +extern "C" EncodedJSValue Server__setSecureContext(JSC::JSGlobalObject*, EncodedJSValue, EncodedJSValue); +extern "C" EncodedJSValue Server__enableKeylog(JSC::JSGlobalObject*, EncodedJSValue); static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject* prototype, JSObject* objectValue, JSC::InternalFieldTuple* tuple, JSC::JSGlobalObject* globalObject, JSC::VM& vm) { @@ -1289,6 +1291,31 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetCustomOptions, (JSGlobalObject * globalObject, return JSValue::encode(jsUndefined()); } +// https.Server#setSecureContext(): hand the TLS options object to the Rust +// server, which builds a fresh SSL_CTX and swaps it in as the listener's +// default for newly accepted connections. +JSC_DEFINE_HOST_FUNCTION(jsHTTPSetServerSecureContext, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue serverValue = callFrame->argument(0); + JSValue optionsValue = callFrame->argument(1); + Server__setSecureContext(globalObject, JSValue::encode(serverValue), JSValue::encode(optionsValue)); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsUndefined()); +} + +// https.Server 'keylog': arm key-log-line parking for sockets accepted from +// now on; _http_server.ts drains them per connection via socketHandle.drainKeylog(). +JSC_DEFINE_HOST_FUNCTION(jsHTTPEnableServerKeylog, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Server__enableKeylog(globalObject, JSValue::encode(callFrame->argument(0))); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsUndefined()); +} + // Pushes only the parser/handler flags. Unlike setServerCustomOptions this rebinds no // callbacks, so it is safe to call on a listening server (server.httpAllowHalfOpen is // assignable at any time, like Node's). @@ -1468,6 +1495,12 @@ JSValue createNodeHTTPInternalBinding(Zig::GlobalObject* globalObject) obj->putDirect( vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setServerAppFlags"_s)), JSC::JSFunction::create(vm, globalObject, 5, "setServerAppFlags"_s, jsHTTPSetAppFlags, ImplementationVisibility::Public), 0); + obj->putDirect( + vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setServerSecureContext"_s)), + JSC::JSFunction::create(vm, globalObject, 2, "setServerSecureContext"_s, jsHTTPSetServerSecureContext, ImplementationVisibility::Public), 0); + obj->putDirect( + vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "enableServerKeylog"_s)), + JSC::JSFunction::create(vm, globalObject, 1, "enableServerKeylog"_s, jsHTTPEnableServerKeylog, ImplementationVisibility::Public), 0); obj->putDirect( vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "Response"_s)), globalObject->JSResponseConstructor(), 0); diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp index 36a9d4d9e978..a29b1a6f60b4 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp @@ -85,7 +85,10 @@ void JSNodeHTTPServerSocket::close() flushPartialResponseBeforeClose(socket); } } - us_socket_close(socket, 0, nullptr); + /* socket.destroy() → forceful close: CLEAN_SHUTDOWN would wait on the + * peer's close_notify, which an allowHalfOpen peer never sends, pinning + * the fd and event loop. */ + us_socket_close(socket, LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN, nullptr); } } diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp index 59e3be6835e9..7d06174c3829 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp @@ -3,8 +3,10 @@ #include "JSSocketAddressDTO.h" #include "ZigGlobalObject.h" #include "ZigGeneratedClasses.h" +#include "JSBuffer.h" #include "helpers.h" #include +#include #include #include @@ -36,6 +38,7 @@ JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketSetResponseTrailers); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketIsRequestTimedOut); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketStartPipelinedResponse); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketStopParsing); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketDrainKeylog); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterResponse); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterRemoteAddress); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterLocalAddress); @@ -72,6 +75,7 @@ static const JSC::HashTableValue JSNodeHTTPServerSocketPrototypeTableValues[] = { "isRequestTimedOut"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketIsRequestTimedOut, 2 } }, { "startPipelinedResponse"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketStartPipelinedResponse, 3 } }, { "stopParsing"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketStopParsing, 0 } }, + { "drainKeylog"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketDrainKeylog, 0 } }, { "secureEstablished"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterIsSecureEstablished, noOpSetter } }, { "servername"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterServername, noOpSetter } }, { "authorizationError"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterAuthorizationError, noOpSetter } }, @@ -86,6 +90,38 @@ void JSNodeHTTPServerSocketPrototype::finishCreation(JSC::VM& vm) this->structure()->setMayBePrototype(true); } +extern "C" int us_socket_pop_keylog(us_socket_t* s, unsigned char* out, int out_cap); + +// Pop the NSS key-log lines parked on this socket's SSL during its handshake +// (see us_ssl_keylog_cb in openssl.c) into an array of Buffers; null when +// none. Each entry already carries the trailing newline Node appends. +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketDrainKeylog, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = dynamicDowncast(callFrame->thisValue()); + if (!thisObject || thisObject->isClosed() || !thisObject->socket) [[unlikely]] { + return JSValue::encode(JSC::jsNull()); + } + // US_SSL_PENDING_KEYLOG_LINE_MAX (openssl.c) + the appended '\n'. + unsigned char line[4096 + 1]; + int length = us_socket_pop_keylog(thisObject->socket, line, sizeof(line)); + if (length <= 0) { + return JSValue::encode(JSC::jsNull()); + } + JSC::JSArray* array = JSC::constructEmptyArray(globalObject, nullptr, 0); + RETURN_IF_EXCEPTION(scope, {}); + unsigned index = 0; + while (length > 0) { + auto* buffer = WebCore::createBuffer(globalObject, std::span(line, static_cast(length))); + RETURN_IF_EXCEPTION(scope, {}); + array->putDirectIndex(globalObject, index++, buffer); + RETURN_IF_EXCEPTION(scope, {}); + length = us_socket_pop_keylog(thisObject->socket, line, sizeof(line)); + } + return JSValue::encode(array); +} + // Implementation of host functions JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketClose, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index 143056933394..7e5d48615ca1 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -74,6 +74,28 @@ pub(crate) fn bun_get_use_system_ca( Ok(JSValue::js_boolean(v)) } +/// `tls.setDefaultCACertificates()` bridge for `fetch()`: publish the +/// pre-validated PEM list to `bun_http::default_ca` so the HTTP client thread +/// rebuilds its default `SSL_CTX` on next connect (node:tls applies it in JS). +pub(crate) fn bun_set_default_ca_certificates( + global: &JSGlobalObject, + frame: &CallFrame, +) -> JsResult { + let mut certs: Vec = Vec::new(); + let mut iter = frame.argument(0).array_iterator(global)?; + while let Some(item) = iter.next()? { + let s = bun_core::OwnedString::new(item.to_bun_string(global)?); + let bytes = s.to_owned_slice(); + // PEM re-serialized by parseCACertificates cannot contain NUL. + debug_assert!(!bytes.contains(&0)); + if let Ok(cert) = std::ffi::CString::new(bytes) { + certs.push(cert); + } + } + bun_http::default_ca::set(certs); + Ok(JSValue::UNDEFINED) +} + mod css { pub use bun_css_jsc::css_internals::{ _test, attr_test, minify_error_test_with_options, minify_test, minify_test_with_options, diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 138a48374883..21f4e7debf9f 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -3943,6 +3943,84 @@ fn server_set_on_connection( Ok(JSValue::UNDEFINED) } +/// `https.Server#setSecureContext()`: build a fresh `SSL_CTX` from `options` +/// and swap it in as the listener's default; live connections keep the old +/// ctx. +fn server_set_secure_context( + global: &JSGlobalObject, + server: JSValue, + options: JSValue, +) -> JsResult { + if !server.is_object() { + return Err(global.throw(format_args!( + "Failed to set secure context: The 'this' value is not a Server." + ))); + } + let vm = global.bun_vm(); + macro_rules! handle { + ($T:ty) => { + if let Some(this) = server.as_::<$T>() { + use crate::socket::{SSLConfig, SSLConfigFromJs as _}; + let Some(config) = SSLConfig::from_js(vm, global, options)? else { + return Err(global.throw(format_args!( + "setSecureContext() requires an options object with TLS options" + ))); + }; + let mut err = uws::create_bun_socket_error_t::none; + let Some(ctx) = config.as_usockets().create_ssl_context(&mut err) else { + // `config` drops here and frees its duped strings. + if super::throw_ssl_error_if_necessary(global) { + return Err(bun_jsc::JsError::Thrown); + } + return Err(global.throw(format_args!( + "Failed to create secure context from the provided options" + ))); + }; + // SAFETY: as_ returned a non-null *mut to a live server. + if let Some(listener) = unsafe { &mut *this }.listener { + // S008: `app::ListenSocket` is a ZST opaque — safe deref. + bun_opaque::opaque_deref_mut(listener).set_default_ssl_ctx(ctx); + } + // Release the creation ref; the listener holds its own. + // SAFETY: `ctx` was created above and up_ref'd by the listener. + unsafe { bun_boringssl_sys::SSL_CTX_free(ctx) }; + return Ok(JSValue::UNDEFINED); + } + }; + } + handle!(HTTPSServer); + handle!(DebugHTTPSServer); + Err(global.throw(format_args!( + "setSecureContext is only supported on TLS servers" + ))) +} + +/// `https.Server` 'keylog': arm key-log parking on the listener so sockets +/// accepted from now on collect NSS key-log lines (drained by JS after each +/// handshake). One-way — Node also never disarms once a listener existed. +fn server_enable_keylog(global: &JSGlobalObject, server: JSValue) -> JsResult { + if !server.is_object() { + return Err(global.throw(format_args!( + "Failed to enable keylog: The 'this' value is not a Server." + ))); + } + macro_rules! handle { + ($T:ty) => { + if let Some(this) = server.as_::<$T>() { + // SAFETY: as_ returned a non-null *mut to a live server. + if let Some(listener) = unsafe { &mut *this }.listener { + // S008: `app::ListenSocket` is a ZST opaque — safe deref. + bun_opaque::opaque_deref_mut(listener).enable_keylog(); + } + return Ok(JSValue::UNDEFINED); + } + }; + } + handle!(HTTPSServer); + handle!(DebugHTTPSServer); + Ok(JSValue::UNDEFINED) +} + fn server_set_app_flags( global: &JSGlobalObject, server: JSValue, @@ -4078,6 +4156,20 @@ extern "C" fn server_set_on_connection_shim( host_fn::to_js_host_fn_result(global, server_set_on_connection(global, server, callback)) } +#[unsafe(export_name = "Server__enableKeylog")] +extern "C" fn server_enable_keylog_shim(global: &JSGlobalObject, server: JSValue) -> JSValue { + host_fn::to_js_host_fn_result(global, server_enable_keylog(global, server)) +} + +#[unsafe(export_name = "Server__setSecureContext")] +extern "C" fn server_set_secure_context_shim( + global: &JSGlobalObject, + server: JSValue, + options: JSValue, +) -> JSValue { + host_fn::to_js_host_fn_result(global, server_set_secure_context(global, server, options)) +} + #[unsafe(export_name = "Server__setMaxHTTPHeaderSize")] extern "C" fn server_set_max_http_header_size_shim( global: &JSGlobalObject, diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index feab964e5789..9525c41594ce 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -1649,6 +1649,17 @@ impl FetchTasklet { ..Default::default() }; + if matches!(fail, http::Error::Cert(_)) { + // Node/undici attaches the TLS error as `.cause` on the rejected + // TypeError; mirror that so ported `err.cause.code` checks work. + // https://github.com/nodejs/node/blob/main/lib/internal/deps/undici/undici.js + let global = &self.global_this; + let cause = fetch_error.clone().to_error_instance(global); + let err_js = fetch_error.to_type_error_instance(global); + err_js.put(global, b"cause", cause); + return BodyValueError::JSValue(StrongOptional::create(err_js, global)); + } + BodyValueError::SystemTypeError(fetch_error) } diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index e9b574cdba7c..177406f5d4ce 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -440,6 +440,24 @@ impl ListenSocket { // (a listen socket IS a us_socket_t). crate::socket::NewSocketHandler::::from(std::ptr::from_mut::(self).cast()) } + + /// Swap the default `SSL_CTX` used for newly accepted sockets + /// (`tls.Server#setSecureContext`). See + /// [`crate::ListenSocket::set_default_ssl_ctx`]. + #[inline] + pub fn set_default_ssl_ctx(&mut self, ctx: *mut crate::SslCtx) { + // S008: opaque ZST cast as in `close`. + bun_opaque::opaque_deref_mut(std::ptr::from_mut::(self).cast::()) + .set_default_ssl_ctx(ctx) + } + + /// See [`crate::ListenSocket::enable_keylog`]. + #[inline] + pub fn enable_keylog(&mut self) { + // S008: opaque ZST cast as in `close`. + bun_opaque::opaque_deref_mut(std::ptr::from_mut::(self).cast::()) + .enable_keylog() + } } #[derive(strum::IntoStaticStr, Debug)] diff --git a/src/uws_sys/ListenSocket.rs b/src/uws_sys/ListenSocket.rs index b888dd162907..0cb7f78e621b 100644 --- a/src/uws_sys/ListenSocket.rs +++ b/src/uws_sys/ListenSocket.rs @@ -86,6 +86,21 @@ impl ListenSocket { unsafe { us_listen_socket_remove_server_name(self, hostname.as_ptr()) } } + /// Swap the default `SSL_CTX` for newly accepted sockets + /// (`tls.Server#setSecureContext`). C up_refs `ctx`; caller keeps its own + /// ref. Raw `*mut` for the same shared-ownership reason as [`add_server_name`]. + pub fn set_default_ssl_ctx(&mut self, ctx: *mut SslCtx) { + // SAFETY: self is a live listen socket; caller guarantees `ctx` points + // at a live SSL_CTX (C up-refs and stores it). + unsafe { us_listen_socket_set_default_ssl_ctx(self, ctx) } + } + + /// Park NSS key-log lines for sockets accepted from now on + /// (node:https server 'keylog'); drained per-socket by the JS layer. + pub fn enable_keylog(&mut self) { + us_listen_socket_enable_keylog(self) + } + 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, @@ -109,6 +124,8 @@ 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_enable_keylog(ls: &mut ListenSocket); 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, diff --git a/test/js/node/test/parallel/test-https-agent-keylog.js b/test/js/node/test/parallel/test-https-agent-keylog.js new file mode 100644 index 000000000000..dc6fbfa2ab9c --- /dev/null +++ b/test/js/node/test/parallel/test-https-agent-keylog.js @@ -0,0 +1,44 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const https = require('https'); +const fixtures = require('../common/fixtures'); + +const server = https.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + // Amount of keylog events depends on negotiated protocol + // version, so force a specific one: + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', +}, (req, res) => { + res.end('bye'); +}).listen(() => { + https.get({ + port: server.address().port, + rejectUnauthorized: false, + }, (res) => { + res.resume(); + res.on('end', () => { + // Trigger TLS connection reuse + https.get({ + port: server.address().port, + rejectUnauthorized: false, + }, (res) => { + server.close(); + res.resume(); + }); + }); + }); +}); + +const verifyKeylog = common.mustCallAtLeast((line, tlsSocket) => { + assert(Buffer.isBuffer(line)); + assert.strictEqual(tlsSocket.encrypted, true); +}); +server.on('keylog', common.mustCall(verifyKeylog, 10)); +https.globalAgent.on('keylog', common.mustCall(verifyKeylog, 10)); diff --git a/test/js/node/test/parallel/test-https-timeout-server-2.js b/test/js/node/test/parallel/test-https-timeout-server-2.js new file mode 100644 index 000000000000..4f947039bbe8 --- /dev/null +++ b/test/js/node/test/parallel/test-https-timeout-server-2.js @@ -0,0 +1,54 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const fixtures = require('../common/fixtures'); + +const assert = require('assert'); +const https = require('https'); +const tls = require('tls'); + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem') +}; + +const server = https.createServer(options, common.mustNotCall()); + +server.on('secureConnection', common.mustCall((cleartext) => { + const s = cleartext.setTimeout(50, function() { + cleartext.destroy(); + server.close(); + }); + assert.ok(s instanceof tls.TLSSocket); +})); + +server.listen(0, function() { + tls.connect({ + host: '127.0.0.1', + port: this.address().port, + rejectUnauthorized: false + }); +}); diff --git a/test/js/node/test/parallel/test-tls-connect-allow-half-open-option.js b/test/js/node/test/parallel/test-tls-connect-allow-half-open-option.js new file mode 100644 index 000000000000..bb19e2e9de81 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-connect-allow-half-open-option.js @@ -0,0 +1,73 @@ +'use strict'; + +const common = require('../common'); + +// This test verifies that `tls.connect()` honors the `allowHalfOpen` option. + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const tls = require('tls'); + +{ + const socket = tls.connect({ port: 42, lookup() {} }); + assert.strictEqual(socket.allowHalfOpen, false); +} + +{ + const socket = tls.connect({ port: 42, allowHalfOpen: false, lookup() {} }); + assert.strictEqual(socket.allowHalfOpen, false); +} + +const server = tls.createServer({ + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), +}, common.mustCall((socket) => { + server.close(); + + let message = ''; + + socket.setEncoding('utf8'); + socket.on('data', (chunk) => { + message += chunk; + + if (message === 'Hello') { + socket.end(message); + message = ''; + } + }); + + socket.on('end', common.mustCall(() => { + assert.strictEqual(message, 'Bye'); + })); +})); + +server.listen(0, common.mustCall(() => { + const socket = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + allowHalfOpen: true, + }, common.mustCall(() => { + let message = ''; + + socket.on('data', (chunk) => { + message += chunk; + }); + + socket.on('end', common.mustCall(() => { + assert.strictEqual(message, 'Hello'); + + setTimeout(common.mustCall(() => { + assert(socket.writable); + assert(socket.write('Bye')); + socket.end(); + }), 50); + })); + + socket.write('Hello'); + })); + + socket.setEncoding('utf8'); +})); diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-append-fetch.mjs b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-append-fetch.mjs new file mode 100644 index 000000000000..ca8436b22b0a --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-append-fetch.mjs @@ -0,0 +1,54 @@ +// Flags: --no-use-system-ca + + +// This tests appending certificates to existing defaults should work correctly +// with fetch. + +import * as common from '../common/index.mjs'; +import { once } from 'node:events'; +import * as fixtures from '../common/fixtures.mjs'; +import assert from 'node:assert'; + +if (!common.hasCrypto) common.skip('missing crypto'); + +const { includesCert } = await import('../common/tls.js'); +const { default: https } = await import('node:https'); +const { default: tls } = await import('node:tls'); + +const bundledCerts = tls.getCACertificates('bundled'); +const fixtureCert = fixtures.readKey('fake-startcom-root-cert.pem'); +if (includesCert(bundledCerts, fixtureCert)) { + common.skip('fake-startcom-root-cert is already in bundled certificates, skipping test'); +} + +// Test HTTPS connection fails with bundled CA, succeeds after adding custom CA +const server = https.createServer({ + cert: fixtures.readKey('agent8-cert.pem'), + key: fixtures.readKey('agent8-key.pem'), +}, common.mustCall((req, res) => { + res.writeHead(200); + res.end('hello world'); +}, 1)); +server.listen(0); +await once(server, 'listening'); +const url = `https://localhost:${server.address().port}/hello-world`; + +// First attempt should fail without custom CA. +await assert.rejects( + fetch(url), + (err) => { + assert.strictEqual(err.cause.code, 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'); + return true; + }, +); + +// Now enable custom CA certificate. +tls.setDefaultCACertificates([fixtureCert]); + +// Second attempt should succeed. +const response = await fetch(url); +assert.strictEqual(response.status, 200); +const text = await response.text(); +assert.strictEqual(text, 'hello world'); + +server.close(); diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-reset-fetch.mjs b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-reset-fetch.mjs new file mode 100644 index 000000000000..479b415d4c51 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-reset-fetch.mjs @@ -0,0 +1,47 @@ +// Flags: --no-use-system-ca + + +// This tests appending certificates to existing defaults should work correctly +// with fetch. + +import * as common from '../common/index.mjs'; +import { once } from 'node:events'; +import * as fixtures from '../common/fixtures.mjs'; +import assert from 'node:assert'; + +if (!common.hasCrypto) common.skip('missing crypto'); + +const { default: https } = await import('node:https'); +const { default: tls } = await import('node:tls'); + +// Test HTTPS connection fails with bundled CA, succeeds after adding custom CA. +const server = https.createServer({ + cert: fixtures.readKey('agent8-cert.pem'), + key: fixtures.readKey('agent8-key.pem'), +}, common.mustCall((req, res) => { + res.writeHead(200); + res.end('hello world'); +}, 1)); +server.listen(0); +await once(server, 'listening'); + +const fixturesCert = fixtures.readKey('fake-startcom-root-cert.pem'); +tls.setDefaultCACertificates([fixturesCert]); +// First, verify connection works with custom CA. +const response1 = await fetch(`https://localhost:${server.address().port}/custom-ca-test`); +assert.strictEqual(response1.status, 200); +const text1 = await response1.text(); +assert.strictEqual(text1, 'hello world'); + +// Now set empty CA store - connection should fail. +tls.setDefaultCACertificates([]); +// Use IP address to skip session cache. +await assert.rejects( + fetch(`https://127.0.0.1:${server.address().port}/empty-ca-test`), + (err) => { + assert.strictEqual(err.cause.code, 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'); + return true; + }, +); + +server.close(); diff --git a/test/js/node/test/parallel/test-tls-set-secure-context.js b/test/js/node/test/parallel/test-tls-set-secure-context.js new file mode 100644 index 000000000000..3d0f93c59ec4 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-secure-context.js @@ -0,0 +1,97 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +// This test verifies the behavior of the tls setSecureContext() method. +// It also verifies that existing connections are not disrupted when the +// secure context is changed. + +const assert = require('assert'); +const events = require('events'); +const https = require('https'); +const timers = require('timers/promises'); +const fixtures = require('../common/fixtures'); +const credentialOptions = [ + { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), + ca: fixtures.readKey('ca1-cert.pem') + }, + { + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + ca: fixtures.readKey('ca2-cert.pem') + }, +]; +let firstResponse; + +const server = https.createServer(credentialOptions[0], (req, res) => { + const id = +req.headers.id; + + if (id === 1) { + firstResponse = res; + firstResponse.write('multi-'); + return; + } else if (id === 4) { + firstResponse.write('success-'); + } + + res.end('success'); +}); + +server.listen(0, common.mustCall(() => { + const { port } = server.address(); + const firstRequest = makeRequest(port, 1); + + (async function makeRemainingRequests() { + // Wait until the first request is guaranteed to have been handled. + while (!firstResponse) { + await timers.setImmediate(); + } + + assert.strictEqual(await makeRequest(port, 2), 'success'); + + server.setSecureContext(credentialOptions[1]); + firstResponse.write('request-'); + await assert.rejects(makeRequest(port, 3), { + code: 'DEPTH_ZERO_SELF_SIGNED_CERT', + }); + + server.setSecureContext(credentialOptions[0]); + assert.strictEqual(await makeRequest(port, 4), 'success'); + + server.setSecureContext(credentialOptions[1]); + firstResponse.end('fun!'); + await assert.rejects(makeRequest(port, 5), { + code: 'DEPTH_ZERO_SELF_SIGNED_CERT', + }); + + assert.strictEqual(await firstRequest, 'multi-request-success-fun!'); + server.close(); + })().then(common.mustCall()); +})); + +async function makeRequest(port, id) { + const options = { + rejectUnauthorized: true, + ca: credentialOptions[0].ca, + servername: 'agent1', + headers: { id }, + agent: new https.Agent() + }; + + const req = https.get(`https://localhost:${port}`, options); + + let errored = false; + req.on('error', () => errored = true); + req.on('finish', common.mustCallAtLeast(() => assert.strictEqual(errored, false), 0)); + + const [res] = await events.once(req, 'response'); + res.setEncoding('utf8'); + let response = ''; + for await (const chunk of res) response += chunk; + return response; +}