Skip to content

node:tls: give the exported SecureContext constructor its own SSL_CTX - #33199

Merged
alii merged 3 commits into
claude/node-tls-26-compatfrom
claude/node-tls-26-compat-scan-fixes
Jul 7, 2026
Merged

node:tls: give the exported SecureContext constructor its own SSL_CTX#33199
alii merged 3 commits into
claude/node-tls-26-compatfrom
claude/node-tls-26-compat-scan-fixes

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 1, 2026

Copy link
Copy Markdown
Member

What

new tls.SecureContext(options) (the exported constructor) returned a wrapper around the digest-interned, shared native SSL_CTX. Mutating it therefore mutated every other context with the same configuration digest:

const a = new tls.SecureContext({ ca: ca2 });
const b = new tls.SecureContext({ ca: ca2 }); // independent object, same interned SSL_CTX
a.context.addCACert(ca1);
tls.connect({ secureContext: b, ... }); // b now trusts ca1 too

Against a server whose chain roots in ca1 (not in the default roots), node v26.3.0 fails closed (UNABLE_TO_GET_ISSUER_CERT_LOCALLY) while Bun completed the handshake with authorized=true — an extra CA silently trusted by connections that never asked for it. (Surfaced by a security scan of this branch; reproduced end-to-end against both runtimes before fixing.)

createSecureContext() already builds a private context for exactly this reason ("a user-constructed context owns its SSL_CTX exclusively, so addCACert can never leak across contexts"); the exported constructor was the one user-constructible path that missed the invariant. It now passes cached: false too. Internal paths (tls.connect/Server/fetch) keep the shared cache: their contexts are never exposed for mutation, and that sharing is the cache's purpose.

Regression tests (both in ssl-ctx-cache.test.ts, next to their createSecureContext siblings): (1) two new tls.SecureContext() instances with identical options get distinct native handles — the interned cache handed both the same cell before the fix; (2) the end-to-end scenario above — after a.context.addCACert(ca1), a connection using b still fails with node's exact UNABLE_TO_GET_ISSUER_CERT_LOCALLY (on the unfixed code it completed with authorized=true).

The other scan findings that touch this PR stack's files (triaged, not fixed here)

Each was verified against the sources before deciding:

  • upgradeTLS initialData used after re-entrant JS can free the backing store (socket_body.rs): real, pre-existing on main — and since fixed upstream by socket: fix use-after-free when upgradeTLS is called inside the socket open handler #33388, so nothing is owed here anymore.
  • TLS 1.3 session resumption marks certificate-less clients as verified (openssl.c): the resumption arm pre-exists on main and is a faithful port of Node's own VerifyPeerCertificate (src/crypto/crypto_common.cc), which Node's server path also uses — so Bun matches Node here by construction. Hardening beyond Node would be a deliberate divergence decision.
  • TLS socket reports authorized=true despite failed chain verification (two findings, socket_body.rs on_handshake): the scan itself notes the computation is byte-identical to upstream Bun; it concerns the Bun-native Bun.connect/Bun.listen socket API (node:tls and fetch enforce verification in their own layers). Changing a Bun-native API contract needs a maintainer decision, not a drive-by in a node:tls compat stack.

Notes from the pre-PR review pass

  • The exported constructor now simply delegates to createSecureContext() (one source of truth for the ownership contract), and the three comments that enumerated createSecureContext as the only exclusively-owned constructor were updated so the enumeration cannot rot into a regression.
  • Informational, unchanged: a digest-cached native context with a callable addCACert is still reachable if code deliberately digs out the registered internal symbol (::buntlsnativesecurecontextctor::); that is outside the public API surface.

Rebased onto the updated base branch (which itself was rebased onto current main).

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator
Updated 4:05 PM PT - Jul 7th, 2026

@alii, your commit ee58cd7 is building: #70078

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. TLS Secure Context tls.createSecureContext().context.addCACert is undefined #30485 - Reports that tls.createSecureContext().context.addCACert is undefined; this PR fixes the SecureContext constructor to properly delegate to createSecureContext(), which exposes the .context property with its native methods including addCACert()

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #30485

🤖 Generated with Claude Code

Comment thread test/js/node/tls/ssl-ctx-cache.test.ts
@cirospaciari
cirospaciari force-pushed the claude/node-tls-26-compat-scan-fixes branch from 4a3004f to 9b27850 Compare July 1, 2026 19:39
@cirospaciari
cirospaciari force-pushed the claude/node-tls-26-compat branch from 488cd7d to e601de3 Compare July 6, 2026 17:47
@cirospaciari
cirospaciari force-pushed the claude/node-tls-26-compat-scan-fixes branch from 9b27850 to 94c4614 Compare July 6, 2026 17:49
@cirospaciari
cirospaciari force-pushed the claude/node-tls-26-compat branch from e601de3 to 73206e1 Compare July 6, 2026 18:29
@cirospaciari
cirospaciari force-pushed the claude/node-tls-26-compat-scan-fixes branch from 94c4614 to 0cccf6a Compare July 6, 2026 18:29
@cirospaciari
cirospaciari force-pushed the claude/node-tls-26-compat branch from 73206e1 to 892c2de Compare July 6, 2026 19:14
@cirospaciari
cirospaciari force-pushed the claude/node-tls-26-compat-scan-fixes branch from 0cccf6a to a979f3d Compare July 6, 2026 19:14
@cirospaciari
cirospaciari force-pushed the claude/node-tls-26-compat branch from 892c2de to f6022cc Compare July 7, 2026 01:19
new tls.SecureContext(options) handed back a wrapper around the digest-interned
native SSL_CTX, so addCACert() on one instance silently extended the trust
store of every context sharing that configuration digest (Node keeps contexts
isolated and fails such a connection closed). createSecureContext() already
built a private context for exactly this reason; the exported constructor now
delegates to it, and the comments that enumerated createSecureContext as the
only exclusively-owned constructor are updated so the invariant cannot rot.
@cirospaciari
cirospaciari force-pushed the claude/node-tls-26-compat-scan-fixes branch from a979f3d to b7b606e Compare July 7, 2026 01:19
@alii

alii commented Jul 7, 2026

Copy link
Copy Markdown
Member

I retriggered CI

alii
alii previously requested changes Jul 7, 2026

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The fix itself is correct and should land — one architectural concern and two smaller follow-ups.

Should-fix

This is the third caller-side patch for "don't mutate a shared SSL_CTX" — the invariant belongs in add_ca_cert, not in every JS entry point that must remember cached:false. Interned handles are still user-reachable post-PR via Symbol.for("::buntlsnativesecurecontextctor::") (registered symbol → any user can recreate it), and via TLSSocket[ksecureContext] (enumerable via getOwnPropertySymbols). Poisoning one also extends the trust store of Bun-native Bun.listen/Bun.connect sockets that share the digest cache.

Recommendation: add shared: bool to SecureContext (SecureContext.rs:35-44); set true in create_with_digest/intern, false in create_private. In add_ca_cert (the only proto mutator), check it first and either throw ("cannot mutate a shared SecureContext; use tls.createSecureContext()") or COW. Separately, either delete the Symbol.for("::buntlsnativesecurecontextctor::") exposure or route the native constructor() through create_private.

Consider

The bug this PR fixes exists because the safe behavior is opt-in — flipping cached's default to false (with the 3 internal hot-path sites at tls.ts:921/:1111/:1299 opting in explicitly) would make the next forgotten entry point a perf regression instead of a trust-store leak. Node has no digest cache at all, so cached=false is the Node-matching default. ~5-line follow-up.

Also looked at and ruled out: the instanceof tls.SecureContext gap (real but pre-existing, this PR doesn't change it), and cross-cache isolation between private and interned (the existing test at :233-255 already catches that regression).

Comment thread src/js/node/tls.ts
Comment thread test/js/node/tls/ssl-ctx-cache.test.ts
Adds a `shared` bit to the native SecureContext and has add_ca_cert throw
when set, so a stray user-reachable interned handle (Symbol.for constructor,
TLSSocket internals) can never poison the per-digest SSL_CTX cache. The
user-facing constructors already produce private contexts; this closes the
class at the mutator instead of at every entry point.

Also pins rejectUnauthorized: true in the new e2e test so its outcome is not
env-sensitive, matching the sibling connect calls in the same file.
@alii

alii commented Jul 7, 2026

Copy link
Copy Markdown
Member

Pushed f1c9041 addressing the should-fix (shared guard on add_ca_cert) and the env-sensitive test. The cached default flip is left as a follow-up.

…rivate SSL_CTX

The digest cache is now opt-in: the internal connect/listen paths pass true
explicitly. A future entry point that forgets to opt in becomes a perf
regression, not a shared trust store.
@alii
alii dismissed their stale review July 7, 2026 21:12

Addressed by f1c9041 (shared guard) + ee58cd7 (cached-default flip).

@alii
alii merged commit 100afc9 into claude/node-tls-26-compat Jul 7, 2026
22 of 39 checks passed
@alii
alii deleted the claude/node-tls-26-compat-scan-fixes branch July 7, 2026 21:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants