Skip to content

node:https: make req.socket a tls.TLSSocket with getPeerCertificate() - #37255

Open
robobun wants to merge 4 commits into
mainfrom
farm/f61464fe/https-req-socket-tlssocket
Open

node:https: make req.socket a tls.TLSSocket with getPeerCertificate()#37255
robobun wants to merge 4 commits into
mainfrom
farm/f61464fe/https-req-socket-tlssocket

Conversation

@robobun

@robobun robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16834 (#37251, which this PR was opened for, was closed as a duplicate of it)

Repro

const server = https.createServer({ key, cert, ca, requestCert: true, rejectUnauthorized: false }, (req, res) => {
  const s = req.socket;
  console.log(s.constructor.name, s instanceof tls.TLSSocket, typeof s.getPeerCertificate);
});

Node prints TLSSocket true function; Bun printed Socket false undefined, so an mTLS server had no way to read the client certificate from the request handler (req.socket.getPeerCertificate() threw TypeError: s.getPeerCertificate is not a function).

Cause

node:https request sockets are NodeHTTPServerSocket instances (backed by the native NodeHTTP handle, class Socket extends net.Socket). The handle exposed the verification verdict (peerCertVerified, authorizationError) but not the certificate itself, and the JS class never chained through tls.TLSSocket.prototype, so both the methods and the instanceof contract were missing. Only tls.createServer produced a real TLSSocket.

Fix

  • Native: add getPeerCertificate(abbreviated), getCipher() and getTLSVersion() to the NodeHTTP server socket handle (JSNodeHTTPServerSocketPrototype.cpp), rendered by the same Rust/BoringSSL code the tls socket binding uses (tls_socket_functions.rs, refactored into shared helpers taking the SSL*).
  • JS: encrypted NodeHTTPServerSocket instances get a prototype that keeps every NodeHTTPServerSocket member as own properties but whose chain goes through TLSSocket.prototype before net.Socket.prototype. instanceof tls.TLSSocket and instanceof net.Socket are both true, getPeerCertificate/getCipher/getProtocol read the NodeHTTP handle, and inherited generic TLSSocket methods (getPeerX509Certificate, the setServername server guard, ...) work unchanged. Plain HTTP sockets are untouched.

Verification

New test test/js/node/http/node-https-req-socket-tls.test.ts ("https req.socket is a TLSSocket that exposes the client certificate") covers instanceof, constructor.name, authorized, leaf and detailed getPeerCertificate().subject.CN, getPeerX509Certificate().subject, getCipher().name and getProtocol(). It fails on current bun with the TypeError above and passes with this change. The issue's repro script now prints the same line as Node v26:

{"ctor":"TLSSocket","isTLSSocket":true,"getPeerCertificate":"function","authorized":true,"peerCN":"agent10.example.com"}

Also ran the full node-tls-server, node-tls-connect, node-http, node-http-connect, node-http-with-ws, node-http-server-timeouts and node-https-checkServerIdentity suites; the only failures are pre-existing on main (external-network proxy test, an SNI bind-hostname test, and a debug-build 5s-timeout flake in node-http-connect, all reproduced on an unmodified build).

Note: the issue also mentions that the certificate object omits modulus/exponent for RSA keys on the tls.createServer path; that is a separate gap in the shared X509 rendering and is not addressed here.

Relationship to #35535

#35535 (opened earlier) also makes encrypted server sockets pass instanceof tls.TLSSocket, so the JS prototype splice here duplicates that part of it. The rest of this PR is not in #35535:

The two branches conflict in one place, the prototype construction after Object.defineProperty(NodeHTTPServerSocket, "name", ...) in _http_server.ts. This PR should land after #35535: at that point the splice here gets dropped, the three methods move into #35535's descriptor object, and the native accessors, isServer, the connectionListener change and the test stay as they are.


[review] gate passed · iteration 1 · 4 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/http/node-https-req-socket-tls.test.ts
bun test v1.4.0 (501d89309)

test/js/node/http/node-https-req-socket-tls.test.ts:
26 |         ctor: s.constructor.name,
27 |         isTLSSocket: s instanceof TLSSocket,
28 |         isNetSocket: s instanceof net.Socket,
29 |         isServer: s.isServer,
30 |         authorized: s.authorized,
31 |         peerCN: s.getPeerCertificate()?.subject?.CN,
                       ^
TypeError: s.getPeerCertificate is not a function. (In 's.getPeerCertificate()', 's.getPeerCertificate' is undefined)
      at <anonymous> (/workspace/bun/test/js/node/http/node-https-req-socket-tls.test.ts:31:19)
      at emit (node:events:157:22)
      at onNodeHTTPRequest (node:_http_server:784:24)
(fail) https req.socket is a TLSSocket that exposes the client certificate [945.36ms]
(pass) tls.createServer connection sockets expose the client certificate the same way [252.33ms]

 1 pass
 1 fail
 1 expect() calls
Ran 2 tests across 1 file. [4.36s]
error: script "bd" exited with code 1
__F:1:S:0

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (4f97cf867)

test/js/node/http/node-https-req-socket-tls.test.ts:
53 |   });
54 |   clientRequest.on("error", reject);
55 |   clientRequest.end();
56 |   try {
57 |     const got = await promise;
58 |     expect(got).toEqual({
                     ^
error: expect(received).toEqual(expected)

  {
    "authorized": true,
-   "cipherName": Any<String>,
+   "cipherName": "TLS_AES_128_GCM_SHA256",
    "ctor": "TLSSocket",
    "detailedCN": "agent10.example.com",
    "isNetSocket": true,
-   "isServer": true,
+   "isServer": false,
    "isTLSSocket": true,
    "peerCN": "agent10.example.com",
-   "protocol": StringMatching /^TLSv/,
-   "x509Subject": StringContaining "CN=agent10.example.com",
+   "protocol": "TLSv1.3",
+   "x509Subject": 
+ "C=US
+ ST=CA
+ L=SF
+ O=The Node.js Foundation
+ OU=Node.js
+ CN=agent10.example.com"
+ ,
  }

- Expected  - 4
+ Received  + 11

      at <anonymous> (/workspace/bun/test/js/node/http/node-https-req-socket-tls.test.ts:58:17)
(fail) https req.socket is a TLSSocket that exposes the client certificate [27.80ms]
(pass) tls.createServer connection sockets expose the client certificate the same way [6.58ms]

 1 pass
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/http/node-https-req-socket-tls.test.ts
bun test v1.4.0 (501d89309)

test/js/node/http/node-https-req-socket-tls.test.ts:
(pass) https req.socket is a TLSSocket that exposes the client certificate [1041.87ms]
(pass) tls.createServer connection sockets expose the client certificate the same way [234.80ms]

 2 pass
 0 fail
 2 expect() calls
Ran 2 tests across 1 file. [4.39s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 699ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/24] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[2/24] gen cpp.rs (cppbind)
[3/24] gen JS modules (bundle-modules)
Preprocess modules (9067ms)
Bundle modules (43ms)
Postprocesss modules (273ms)
Bundle Functions (735ms)
Generate Code (30ms)

[10.17s] Bundled "src/js" for production
  2612 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[3/11] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

^[[1m^[[92m   Compiling^[[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
^[[1m^[[92m   Compiling^[[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
^[[1m^[[92m   Compiling^[[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
^[[1m^[[92m   Compiling^[[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
^[[1m^[[92m   Compiling^[[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
^[[1m^[[9
... (truncated)
diff hotspot
src/js/node/_http_server.ts                        |  51 ++++++++-
 .../node/JSNodeHTTPServerSocketPrototype.cpp       |  53 +++++++++
 src/runtime/socket/tls_socket_functions.rs         |  76 ++++++++++++-
 .../js/node/http/node-https-req-socket-tls.test.ts | 122 +++++++++++++++++++++
 4 files changed, 295 insertions(+), 7 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                                      reads  edits  tests
src/js/node/_http_server.ts                                   6      5      0
…c/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp      2      4      0
src/runtime/socket/tls_socket_functions.rs                    8      9      0
test/js/node/http/node-https-req-socket-tls.test.ts           0      2      0

root cause · written by the author bot

The root cause was that node:https built request sockets as plain net.Socket instances, never switching them to the TLSSocket prototype even when the connection was encrypted, so TLS methods like getPeerCertificate were unavailable to handlers. The fix swaps the socket's prototype to a TLS-aware server socket prototype when the connection is encrypted and wires the underlying TLS handle through to the native peer certificate, cipher, and protocol accessors, making req.socket an instanceof tls.TLSSocket with a working getPeerCertificate. It also sets isServer to true as an own property on en…

The socket node:https hands to request handlers was a plain Socket: no
getPeerCertificate(), getCipher() or getProtocol(), and instanceof
tls.TLSSocket was false, so mTLS servers could not read the client
identity from req.socket.

Expose getPeerCertificate/getCipher/getTLSVersion on the native NodeHTTP
server socket handle (sharing the BoringSSL rendering with the tls socket
binding), and swap encrypted NodeHTTPServerSocket instances onto a
prototype that keeps every NodeHTTPServerSocket member but chains through
TLSSocket.prototype, so instanceof and the inherited TLSSocket methods
(getPeerX509Certificate, setServername guards, ...) behave like Node.

Fixes #37251
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 26 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 84082231-932b-4798-a319-1fdb8b9d00f3

📥 Commits

Reviewing files that changed from the base of the PR and between 9008ae7 and 501d893.

📒 Files selected for processing (4)
  • src/js/node/_http_server.ts
  • src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
  • src/runtime/socket/tls_socket_functions.rs
  • test/js/node/http/node-https-req-socket-tls.test.ts

Comment @coderabbitai help to get the list of available commands.

node-tls-server.test.ts has an environment-sensitive SNI test (binds and
connects via the localhost hostname) that fails in some containers
regardless of this change; a dedicated file keeps this fix's coverage
isolated from it.
@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:47 AM PT - Aug 9th, 2026

@robobun, your commit 501d89309ef0826d7fa5cbf176437a25d2fea982 passed in Build #90901! 🎉


🧪   To try this PR locally:

bunx bun-pr 37255

That installs a local version of the PR into your bun-37255 executable, so you can run:

bun-37255 --bun

Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
Comment thread src/runtime/socket/tls_socket_functions.rs Outdated
Comment thread src/runtime/socket/tls_socket_functions.rs
Comment thread src/runtime/socket/tls_socket_functions.rs
Comment thread src/runtime/socket/tls_socket_functions.rs
Comment thread src/runtime/socket/tls_socket_functions.rs Outdated
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
Comment thread src/runtime/socket/tls_socket_functions.rs
Comment thread src/runtime/socket/tls_socket_functions.rs
Comment thread src/runtime/socket/tls_socket_functions.rs Outdated
Comment thread src/js/node/_http_server.ts Outdated
- .unwrap_or(JSValue::ZERO) is banned by the empty-jsvalue-laundering
  source lint; route through HostReturn::or_pending_exception instead.
- net.Socket's constructor stamps an own isServer = false that shadows
  the prototype, so set the own property in the encrypted branch and
  drop the dead prototype descriptor; cover isServer in the test.
Comment thread src/js/node/_http_server.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both findings from the earlier pass are addressed in 501d893 (or_pending_exception() and the own-property isServer), and this run found nothing new. Given the scope — new C++↔Rust FFI surface for TLS peer-cert introspection plus a runtime Object.setPrototypeOf swap on every encrypted node:https connection — a maintainer sign-off on the prototype-swap design and the partial TLSSocket method coverage would still be worthwhile.

Checked: us_socket_get_native_handle dispatches on s->ssl internally, so the single-arg extern decl matches the C signature; tls_socket_functions.rs is mod-included once (socket_body.rs only), so the new #[no_mangle] externs do not collide; the refactored peer_certificate_to_js/cipher_to_js/tls_version_to_js are behavior-preserving extractions of the existing bodies.

Extended reasoning...

Overview

Four files: _http_server.ts (encrypted NodeHTTPServerSocket gets isServer = true and its prototype swapped to a lazily-built object that chains through TLSSocket.prototype, with three overridden methods reading the native NodeHTTP handle; connectionListener's native-socket guard is folded into isNodeHTTPServerSocket), JSNodeHTTPServerSocketPrototype.cpp (three new host functions + a sslHandleFor helper calling us_socket_get_native_handle), tls_socket_functions.rs (three #[unsafe(no_mangle)] extern "C" entry points and the bodies of get_peer_certificate/get_cipher/get_tls_version extracted into *_to_js helpers taking the raw SSL*), and a new mTLS test file.

Security risks

The change exposes TLS session introspection (peer certificate, cipher, protocol) through a new FFI path. It reuses the existing X509/cipher rendering code verbatim, and sslHandleFor guards on is_ssl && socket before dereferencing, with the Rust side null-checking again. I did not find a way for user input to reach the new FFI with an invalid pointer. No auth/verification logic is altered — this is read-only introspection of state BoringSSL already computed.

Level of scrutiny

High. This is TLS-adjacent Node compat with a new C++↔Rust FFI boundary and a per-connection Object.setPrototypeOf (which deoptimizes inline caches on the swapped instance). The runtime prototype-swap approach — vs. a static subclass or defining the methods directly on NodeHTTPServerSocket.prototype behind an encrypted guard — is an architectural choice a maintainer should weigh. The prototype chains through TLSSocket.prototype, so every non-overridden TLSSocket method (getSession, exportKeyingMaterial, getFinished, renegotiate, …) is now callable on req.socket and will read a null _handle; that partial-coverage boundary is worth a human look.

Other factors

Both of my earlier inline findings (source-lint on unwrap_or(JSValue::ZERO); dead isServer on the prototype) were fixed in 501d893 and the test now asserts isServer: true. I confirmed tls_socket_functions.rs is only included once (the in-file comment about dual inclusion is stale but pre-existing), so the new #[no_mangle] symbols are unique. The Rust refactor is a clean extraction — the pre-existing callers wrap the new helpers with Ok(...) and behavior is unchanged. Test coverage is solid for the primary fix (instanceof, getPeerCertificate, getPeerX509Certificate, getCipher, getProtocol, authorized) and includes a tls.createServer control.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Overlap check against #35535 (cirospaciari, opened 2026-07-25), which also makes encrypted node:https server sockets pass instanceof tls.TLSSocket. Result: this PR is partly covered, so it stays open and is now scoped as a follow-up to #35535. Details, verified against the two diffs and against main at da3851e:

Duplicated by #35535:

Not in #35535:

Sequencing: git merge-tree of the two branches conflicts in exactly one place, the prototype construction after Object.defineProperty(NodeHTTPServerSocket, "name", ...) in _http_server.ts (the other two hunks are #35535 against current main). This PR should land after #35535; the rebase drops the splice here, adds the three methods to #35535's descriptor object, and keeps the native accessors, isServer, the connectionListener change and the test unchanged. Description updated accordingly, and it now references #16834, the canonical issue (#37251 was closed as its duplicate).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

node:https Request socket is not a TLSSocket

1 participant