Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/js/node/_http_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,13 @@ function normalizeServerTls(tls) {
// 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;
// Encrypted native sockets have their prototype swapped to the TLS variant,
// which chains through TLSSocket.prototype, not NodeHTTPServerSocket.prototype.
Comment thread
robobun marked this conversation as resolved.
Outdated
if (
NodeHTTPTLSServerSocketPrototype !== undefined &&
Object.getPrototypeOf(socket) === NodeHTTPTLSServerSocketPrototype
)
return;
connectionListenerHTTP1(this, socket, {
http1Options: {
IncomingMessage: this[kIncomingMessage],
Expand Down Expand Up @@ -1579,6 +1586,9 @@ const NodeHTTPServerSocket = class Socket extends NetSocket {
handle.duplex = this;

this.encrypted = encrypted;
if (encrypted) {
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'
Expand Down Expand Up @@ -2158,6 +2168,44 @@ function _writeHead(statusCode, reason, obj, response) {

Object.defineProperty(NodeHTTPServerSocket, "name", { value: "Socket" });

// node:https request handlers must see a tls.TLSSocket (`req.socket
// instanceof tls.TLSSocket`, getPeerCertificate(), ...). The TLS variant is a
// prototype that keeps every NodeHTTPServerSocket member (copied as own
// properties) but chains through TLSSocket.prototype instead of going straight
// to net.Socket.prototype. The constructor swaps it onto encrypted instances;
// private fields and brands are per-instance, so the swap does not affect them.
Comment thread
robobun marked this conversation as resolved.
Outdated
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,
isServer: true,
Comment thread
robobun marked this conversation as resolved.
Outdated
// The inherited TLSSocket methods read this._handle, which is always
// null here (the connection is driven by the native NodeHTTP handle);
// these have NodeHTTP-handle-backed native implementations.
Comment thread
robobun marked this conversation as resolved.
Outdated
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.
Comment thread
robobun marked this conversation as resolved.
Outdated
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);
Expand Down
55 changes: 55 additions & 0 deletions src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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))
{
Expand Down Expand Up @@ -76,6 +83,9 @@ static const JSC::HashTableValue JSNodeHTTPServerSocketPrototypeTableValues[] =
{ "servername"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterServername, noOpSetter } },
{ "authorizationError"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterAuthorizationError, noOpSetter } },
{ "peerCertVerified"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterPeerCertVerified, noOpSetter } },
{ "getPeerCertificate"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketGetPeerCertificate, 1 } },
{ "getCipher"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketGetCipher, 0 } },
{ "getTLSVersion"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketGetTLSVersion, 0 } },
};

void JSNodeHTTPServerSocketPrototype::finishCreation(JSC::VM& vm)
Expand Down Expand Up @@ -289,6 +299,51 @@ JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterPeerCertVerified, (JSC::JSG
return JSValue::encode(JSC::jsBoolean(thisObject->isPeerCertificateVerified()));
}

// TLS introspection for the socket node:https hands to request handlers. The
// SSL state lives on the uWS socket; the Rust side renders the same
// Node-shaped objects the tls.TLSSocket handle returns. Null SSL (plain HTTP
// or closed socket) yields undefined/null, matching a detached TLS handle.
Comment thread
robobun marked this conversation as resolved.
Outdated
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<JSNodeHTTPServerSocket>(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`.
Comment thread
robobun marked this conversation as resolved.
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<JSNodeHTTPServerSocket>(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<JSNodeHTTPServerSocket>(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<JSNodeHTTPServerSocket>(JSC::JSValue::decode(thisValue));
Expand Down
75 changes: 70 additions & 5 deletions src/runtime/socket/tls_socket_functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,16 +394,66 @@ 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, called from JSNodeHTTPServerSocketPrototype.cpp
// so the socket node:https hands to request handlers can expose the
// tls.TLSSocket surface (getPeerCertificate / getCipher / getProtocol).
Comment thread
robobun marked this conversation as resolved.
Outdated

/// # 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.
Comment thread
robobun marked this conversation as resolved.
#[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).unwrap_or(JSValue::ZERO)
Comment thread
robobun marked this conversation as resolved.
Outdated
}

/// # Safety
/// See [`Bun__NodeHTTPServerSocket__getPeerCertificate`].
Comment thread
robobun marked this conversation as resolved.
#[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`].
Comment thread
robobun marked this conversation as resolved.
#[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(
Expand Down Expand Up @@ -457,6 +507,17 @@ 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)
}

/// Build the Node-shaped peer-certificate object for `ssl_ptr`. Shared by the
/// `Bun.connect`/`tls.connect` socket binding and the C-exported node:http
/// server socket surface.
Comment thread
robobun marked this conversation as resolved.
Outdated
fn peer_certificate_to_js(
ssl_ptr: *mut boringssl::SSL,
abbreviated: bool,
global: &JSGlobalObject,
) -> JsResult<JSValue> {
let is_server_ssl = ffi::SSL_is_server(boringssl::SSL::opaque_ref(ssl_ptr)) != 0;

if abbreviated {
Expand Down Expand Up @@ -783,14 +844,18 @@ 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);

if cipher.is_null() {
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);

Expand Down Expand Up @@ -825,7 +890,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(
Expand Down
120 changes: 120 additions & 0 deletions test/js/node/http/node-https-req-socket-tls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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<Record<string, unknown>>();
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,
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,
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<Record<string, unknown>>();
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();
}
});
Loading