diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 7ea18c049190..29a305b2d01d 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -306,7 +306,7 @@ function normalizeServerTls(tls) { // 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 function connectionListener(this: Server, socket) { - if (socket instanceof NodeHTTPServerSocket) return; + if (isNodeHTTPServerSocket(socket)) return; connectionListenerHTTP1(this, socket, { http1Options: { IncomingMessage: this[kIncomingMessage], @@ -1579,6 +1579,12 @@ const NodeHTTPServerSocket = class Socket extends NetSocket { handle.duplex = this; this.encrypted = encrypted; + if (encrypted) { + // Overwrites the own `isServer = false` net.Socket's constructor set, so + // the inherited TLSSocket server-side guards (setServername) apply. + this.isServer = true; + Object.setPrototypeOf(this, getNodeHTTPTLSServerSocketPrototype()); + } this.on("timeout", onNodeHTTPServerSocketTimeout); // Like Node.js's socketOnError: connection errors are routed to the // server's 'clientError' event instead of crashing as unhandled 'error' @@ -2158,6 +2164,49 @@ function _writeHead(statusCode, reason, obj, response) { Object.defineProperty(NodeHTTPServerSocket, "name", { value: "Socket" }); +// A native server socket is either a plain NodeHTTPServerSocket or, when +// encrypted, one whose prototype the constructor swapped to the TLS variant. +function isNodeHTTPServerSocket(socket) { + return ( + socket instanceof NodeHTTPServerSocket || + (NodeHTTPTLSServerSocketPrototype !== undefined && + Object.getPrototypeOf(socket) === NodeHTTPTLSServerSocketPrototype) + ); +} + +// node:https request handlers must see a tls.TLSSocket: this prototype keeps +// every NodeHTTPServerSocket member as own properties but chains through +// TLSSocket.prototype instead of going straight to net.Socket.prototype. +let NodeHTTPTLSServerSocketPrototype; +function getNodeHTTPTLSServerSocketPrototype() { + if (NodeHTTPTLSServerSocketPrototype === undefined) { + const { TLSSocket } = require("node:tls"); + NodeHTTPTLSServerSocketPrototype = Object.create(TLSSocket.prototype, { + ...Object.getOwnPropertyDescriptors(NodeHTTPServerSocket.prototype), + ...Object.getOwnPropertyDescriptors({ + constructor: TLSSocket, + // Unlike the inherited TLSSocket methods (which read this._handle, + // always null here), these read the native NodeHTTP handle. + getPeerCertificate(detailed) { + const handle = this[kHandle]; + if (!handle) return null; + // The native parameter means "abbreviated": the inverse of Node's + // `detailed`, matching TLSSocket.prototype.getPeerCertificate. + const cert = arguments.length < 1 ? handle.getPeerCertificate() : handle.getPeerCertificate(!detailed); + return cert || {}; + }, + getCipher() { + return this[kHandle]?.getCipher(); + }, + getProtocol() { + return this[kHandle]?.getTLSVersion() ?? null; + }, + }), + }); + } + return NodeHTTPTLSServerSocketPrototype; +} + function ServerResponse(req, options): void { if (!(this instanceof ServerResponse)) return new ServerResponse(req, options); OutgoingMessage.$call(this, options); diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp index 59e3be6835e9..b299f86fdc52 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp @@ -13,6 +13,10 @@ extern "C" uint64_t uws_res_get_remote_address_info(void* res, const char** dest extern "C" uint64_t uws_res_get_local_address_info(void* res, const char** dest, int* port, bool* is_ipv6); extern "C" void us_socket_resume(us_socket_t*); extern "C" void us_socket_pause(us_socket_t*); +extern "C" void* us_socket_get_native_handle(us_socket_t*); +extern "C" EncodedJSValue Bun__NodeHTTPServerSocket__getPeerCertificate(void* ssl, JSC::JSGlobalObject* globalObject, bool abbreviated); +extern "C" EncodedJSValue Bun__NodeHTTPServerSocket__getCipher(void* ssl, JSC::JSGlobalObject* globalObject); +extern "C" EncodedJSValue Bun__NodeHTTPServerSocket__getTLSVersion(void* ssl, JSC::JSGlobalObject* globalObject); namespace Bun { @@ -45,6 +49,9 @@ JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterIsSecureEstablished); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterServername); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterAuthorizationError); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterPeerCertVerified); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketGetPeerCertificate); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketGetCipher); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketGetTLSVersion); JSC_DEFINE_CUSTOM_SETTER(noOpSetter, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue value, JSC::PropertyName propertyName)) { @@ -76,6 +83,9 @@ static const JSC::HashTableValue JSNodeHTTPServerSocketPrototypeTableValues[] = { "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 } }, { "peerCertVerified"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterPeerCertVerified, noOpSetter } }, + { "getPeerCertificate"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketGetPeerCertificate, 1 } }, + { "getCipher"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketGetCipher, 0 } }, + { "getTLSVersion"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketGetTLSVersion, 0 } }, }; void JSNodeHTTPServerSocketPrototype::finishCreation(JSC::VM& vm) @@ -289,6 +299,49 @@ JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterPeerCertVerified, (JSC::JSG return JSValue::encode(JSC::jsBoolean(thisObject->isPeerCertificateVerified())); } +// The SSL* of the uWS socket; null for plain HTTP or a closed socket (the +// Rust side then answers like a detached TLS handle). +static void* sslHandleFor(JSNodeHTTPServerSocket* socket) +{ + if (!socket->is_ssl || !socket->socket) { + return nullptr; + } + return us_socket_get_native_handle(socket->socket); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketGetPeerCertificate, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto* thisObject = dynamicDowncast(callFrame->thisValue()); + if (!thisObject) [[unlikely]] { + return JSValue::encode(JSC::jsUndefined()); + } + // Same contract as the tls.TLSSocket handle: no argument means the + // abbreviated (leaf-only) form; the argument is `!detailed`. + bool abbreviated = true; + if (callFrame->argumentCount() > 0) { + abbreviated = callFrame->uncheckedArgument(0).toBoolean(globalObject); + } + return Bun__NodeHTTPServerSocket__getPeerCertificate(sslHandleFor(thisObject), globalObject, abbreviated); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketGetCipher, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto* thisObject = dynamicDowncast(callFrame->thisValue()); + if (!thisObject) [[unlikely]] { + return JSValue::encode(JSC::jsUndefined()); + } + return Bun__NodeHTTPServerSocket__getCipher(sslHandleFor(thisObject), globalObject); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketGetTLSVersion, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto* thisObject = dynamicDowncast(callFrame->thisValue()); + if (!thisObject) [[unlikely]] { + return JSValue::encode(JSC::jsNull()); + } + return Bun__NodeHTTPServerSocket__getTLSVersion(sslHandleFor(thisObject), globalObject); +} + JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterDuplex, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) { auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index 7d7da9fa4bdc..f730aa712c07 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -5,7 +5,8 @@ use bun_boringssl_sys as boringssl; use bun_core::{String as BunString, ZigString, strings}; use bun_jsc::JsClass as _; use bun_jsc::{ - self as jsc, CallFrame, JSGlobalObject, JSValue, JsResult, StringJsc as _, ZigStringJsc as _, + self as jsc, CallFrame, HostReturn as _, JSGlobalObject, JSValue, JsResult, StringJsc as _, + ZigStringJsc as _, }; use crate::api::bun_x509 as X509; @@ -394,16 +395,65 @@ pub(super) fn get_tls_version( let Some(ssl_ptr) = this.socket.get().ssl() else { return Ok(JSValue::NULL); }; + Ok(tls_version_to_js(ssl_ptr, global)) +} + +fn tls_version_to_js(ssl_ptr: *mut boringssl::SSL, global: &JSGlobalObject) -> JSValue { let version = ffi::SSL_get_version(boringssl::SSL::opaque_ref(ssl_ptr)); if version.is_null() { - return Ok(JSValue::NULL); + return JSValue::NULL; } // SAFETY: SSL_get_version returns a static NUL-terminated C string. let slice = unsafe { bun_core::ffi::cstr(version) }.to_bytes(); if slice.is_empty() { - return Ok(JSValue::NULL); + return JSValue::NULL; } - Ok(ZigString::from_utf8(slice).to_js(global)) + ZigString::from_utf8(slice).to_js(global) +} + +// ── C-exported TLS introspection for node:http server sockets +// (called from JSNodeHTTPServerSocketPrototype.cpp) ── + +/// # Safety +/// `ssl` must be null or the live `SSL*` of a TLS socket (the C++ caller +/// passes `us_socket_get_native_handle` of an SSL socket). On failure the +/// exception is pending on `global` and `JSValue::ZERO` is returned. +#[unsafe(no_mangle)] +unsafe extern "C" fn Bun__NodeHTTPServerSocket__getPeerCertificate( + ssl: *mut boringssl::SSL, + global: &JSGlobalObject, + abbreviated: bool, +) -> JSValue { + if ssl.is_null() { + return JSValue::UNDEFINED; + } + peer_certificate_to_js(ssl, abbreviated, global).or_pending_exception() +} + +/// # Safety +/// See [`Bun__NodeHTTPServerSocket__getPeerCertificate`]. +#[unsafe(no_mangle)] +unsafe extern "C" fn Bun__NodeHTTPServerSocket__getCipher( + ssl: *mut boringssl::SSL, + global: &JSGlobalObject, +) -> JSValue { + if ssl.is_null() { + return JSValue::UNDEFINED; + } + cipher_to_js(ssl, global) +} + +/// # Safety +/// See [`Bun__NodeHTTPServerSocket__getPeerCertificate`]. +#[unsafe(no_mangle)] +unsafe extern "C" fn Bun__NodeHTTPServerSocket__getTLSVersion( + ssl: *mut boringssl::SSL, + global: &JSGlobalObject, +) -> JSValue { + if ssl.is_null() { + return JSValue::NULL; + } + tls_version_to_js(ssl, global) } pub(super) fn set_max_send_fragment( @@ -457,6 +507,16 @@ pub(super) fn get_peer_certificate( let Some(ssl_ptr) = this.socket.get().ssl() else { return Ok(JSValue::UNDEFINED); }; + peer_certificate_to_js(ssl_ptr, abbreviated, global) +} + +/// Node-shaped peer-certificate object for `ssl_ptr`; shared by the socket +/// binding above and the C-exported node:http server socket surface. +fn peer_certificate_to_js( + ssl_ptr: *mut boringssl::SSL, + abbreviated: bool, + global: &JSGlobalObject, +) -> JsResult { let is_server_ssl = ffi::SSL_is_server(boringssl::SSL::opaque_ref(ssl_ptr)) != 0; if abbreviated { @@ -783,6 +843,10 @@ pub(super) fn get_cipher( let Some(ssl_ptr) = this.socket.get().ssl() else { return Ok(JSValue::UNDEFINED); }; + Ok(cipher_to_js(ssl_ptr, global)) +} + +fn cipher_to_js(ssl_ptr: *mut boringssl::SSL, global: &JSGlobalObject) -> JSValue { let cipher = ffi::SSL_get_current_cipher(boringssl::SSL::opaque_ref(ssl_ptr)); let result = JSValue::create_empty_object(global, 0); @@ -790,7 +854,7 @@ pub(super) fn get_cipher( result.put(global, b"name", JSValue::NULL); result.put(global, b"standardName", JSValue::NULL); result.put(global, b"version", JSValue::NULL); - return Ok(result); + return result; } let cipher = ffi::SSL_CIPHER::opaque_ref(cipher); @@ -825,7 +889,7 @@ pub(super) fn get_cipher( result.put(global, b"version", ZigString::from_utf8(s).to_js(global)); } - Ok(result) + result } pub(super) fn get_tls_peer_finished_message( diff --git a/test/js/node/http/node-https-req-socket-tls.test.ts b/test/js/node/http/node-https-req-socket-tls.test.ts new file mode 100644 index 000000000000..7dbf66a0bfca --- /dev/null +++ b/test/js/node/http/node-https-req-socket-tls.test.ts @@ -0,0 +1,122 @@ +import { expect, it } from "bun:test"; +import { readFileSync } from "fs"; +import https from "https"; +import net, { AddressInfo } from "net"; +import { once } from "node:events"; +import { join } from "path"; +import tls, { TLSSocket } from "tls"; + +// https://github.com/oven-sh/bun/issues/37251: the socket node:https hands +// the request handler must be a tls.TLSSocket so mTLS servers can read the +// client identity via req.socket.getPeerCertificate(). +it("https req.socket is a TLSSocket that exposes the client certificate", async () => { + const fixtures = join(import.meta.dir, "../tls/fixtures"); + const serverOptions = { + key: readFileSync(join(fixtures, "agent10-key.pem"), "utf8"), + cert: readFileSync(join(fixtures, "agent10-cert.pem"), "utf8"), + ca: readFileSync(join(fixtures, "ca5-cert.pem"), "utf8"), + requestCert: true, + rejectUnauthorized: false, + }; + const { promise, resolve, reject } = Promise.withResolvers>(); + const server = https.createServer(serverOptions, (req, res) => { + try { + const s = req.socket as TLSSocket; + resolve({ + ctor: s.constructor.name, + isTLSSocket: s instanceof TLSSocket, + isNetSocket: s instanceof net.Socket, + isServer: s.isServer, + authorized: s.authorized, + peerCN: s.getPeerCertificate()?.subject?.CN, + detailedCN: s.getPeerCertificate(true)?.subject?.CN, + x509Subject: s.getPeerX509Certificate()?.subject, + cipherName: s.getCipher()?.name, + protocol: s.getProtocol(), + }); + } catch (err) { + reject(err); + } + res.end("ok"); + }); + server.on("error", reject); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const clientRequest = https.request({ + port, + host: "127.0.0.1", + rejectUnauthorized: false, + key: readFileSync(join(fixtures, "ec10-key.pem"), "utf8"), + cert: readFileSync(join(fixtures, "ec10-cert.pem"), "utf8"), + agent: false, + }); + clientRequest.on("error", reject); + clientRequest.end(); + try { + const got = await promise; + expect(got).toEqual({ + ctor: "TLSSocket", + isTLSSocket: true, + isNetSocket: true, + isServer: true, + authorized: true, + peerCN: "agent10.example.com", + detailedCN: "agent10.example.com", + x509Subject: expect.stringContaining("CN=agent10.example.com"), + cipherName: expect.any(String), + protocol: expect.stringMatching(/^TLSv/), + }); + } finally { + clientRequest.destroy(); + server.close(); + } +}); + +// tls.createServer was already fine (the issue is specific to node:https); +// keep it covered so the two server paths stay in sync. +it("tls.createServer connection sockets expose the client certificate the same way", async () => { + const fixtures = join(import.meta.dir, "../tls/fixtures"); + const serverOptions = { + key: readFileSync(join(fixtures, "agent10-key.pem"), "utf8"), + cert: readFileSync(join(fixtures, "agent10-cert.pem"), "utf8"), + ca: readFileSync(join(fixtures, "ca5-cert.pem"), "utf8"), + requestCert: true, + rejectUnauthorized: false, + }; + const { promise, resolve, reject } = Promise.withResolvers>(); + const server = tls.createServer(serverOptions, socket => { + try { + resolve({ + isTLSSocket: socket instanceof TLSSocket, + authorized: socket.authorized, + peerCN: socket.getPeerCertificate()?.subject?.CN, + }); + } catch (err) { + reject(err); + } + socket.end(); + }); + server.on("error", reject); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const client = tls.connect({ + port, + host: "127.0.0.1", + rejectUnauthorized: false, + key: readFileSync(join(fixtures, "ec10-key.pem"), "utf8"), + cert: readFileSync(join(fixtures, "ec10-cert.pem"), "utf8"), + }); + client.on("error", reject); + try { + expect(await promise).toEqual({ + isTLSSocket: true, + authorized: true, + peerCN: "agent10.example.com", + }); + } finally { + client.end(); + server.close(); + } +});