Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 @@
// 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 @@
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 @@

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,

Check warning on line 2185 in src/js/node/_http_server.ts

View check run for this annotation

Claude / Claude Code Review

isServer:true on the prototype is shadowed by net.Socket own property

The prototype-level `isServer: true` is dead code: `net.Socket`'s constructor unconditionally sets `this.isServer = false` as an own instance property (net.ts:1585) during `super({...})`, and own properties shadow the prototype, so `req.socket.isServer` stays `false` after the swap. Set `this.isServer = true` as an own property in the `if (encrypted)` branch instead — that also makes the inherited `setServername` guard throw `ERR_TLS_SNI_FROM_SERVER` as the PR description claims.
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 @@
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)

Check failure on line 430 in src/runtime/socket/tls_socket_functions.rs

View check run for this annotation

Claude / Claude Code Review

unwrap_or(JSValue::ZERO) is banned by the empty-jsvalue-laundering source lint

`.unwrap_or(JSValue::ZERO)` is banned by the repo-wide source lint `test/internal/source-lints/empty-jsvalue-laundering.test.ts`, whose regex `/\.unwrap_or\(\s*JSValue::ZERO\s*\)/g` matches this line and fails `expect(offenders).toEqual([])` in CI. The sanctioned form for host-function return position is `HostReturn::or_pending_exception()` — add `use bun_jsc::HostReturn as _;` and change this to `peer_certificate_to_js(ssl, abbreviated, global).or_pending_exception()`.
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 @@
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 @@
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 @@
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
65 changes: 65 additions & 0 deletions test/js/node/tls/node-tls-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,71 @@ it("keeps req.socket.authorized false for an unverified client after the server
}
});

it("https req.socket is a TLSSocket that exposes the client certificate", async () => {
// 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().
const fixtures = join(import.meta.dir, "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();
}
});

it("createServer({pfx, requestCert}) verifies client certificates against the pfx-embedded CA", async () => {
// agent1.pfx bundles agent1's key/cert plus ca1; a server built from it must
// be able to verify a client certificate signed by that embedded CA.
Expand Down
Loading