diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index cf07c368767e..97ea47c79b27 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -32,6 +32,7 @@ const parseCACertificates = $newCppFunction("NodeTLS.cpp", "parseCACertificates" const getTLSDefaultCiphers = $newCppFunction("NodeTLS.cpp", "getDefaultCiphers", 0); const setTLSDefaultCiphers = $newCppFunction("NodeTLS.cpp", "setDefaultCiphers", 1); +const getSSLCiphers = $newCppFunction("NodeTLS.cpp", "getSSLCiphers", 0); let _VALID_CIPHERS_SET: Set | undefined; function getValidCiphersSet() { if (!_VALID_CIPHERS_SET) { @@ -298,8 +299,11 @@ const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; const StringPrototypeEndsWith = String.prototype.endsWith; const StringFromCharCode = String.fromCharCode; const StringPrototypeCharCodeAt = String.prototype.charCodeAt; +const StringPrototypeToLowerCase = String.prototype.toLowerCase; const ArrayPrototypeIncludes = Array.prototype.includes; +const ArrayPrototypeSlice = Array.prototype.slice; +const ArrayPrototypeSort = Array.prototype.sort; const ArrayPrototypeJoin = Array.prototype.join; const ArrayPrototypeForEach = Array.prototype.forEach; const ArrayPrototypePush = Array.prototype.push; @@ -616,11 +620,16 @@ function newNativeSecureContext(options, cached = false) { return ctx; } -var InternalSecureContext = class SecureContext { - context; - servername; +// Module-private: only internal paths can opt into the shared memoised SSL_CTX. +const kCachedContext = Symbol("kCachedContext"); - constructor(options, cached = false) { +// Not a `class`: like Node, a call without `new` returns an instance. +function SecureContext(options, cachedMarker?): void { + if (!(this instanceof SecureContext)) { + return new SecureContext(options) as never; + } + { + const cached = cachedMarker === kCachedContext; // When tls.setDefaultCACertificates() has installed an override and no // explicit `ca` was given, use the override as the default CA set so the // process-wide default applies on every construction path (the public @@ -670,22 +679,19 @@ var InternalSecureContext = class SecureContext { this.context = newNativeSecureContext(options, cached); this.servername = options?.servername; } -}; - -function SecureContext(options): void { - return createSecureContext(options) as never; } +$toClass(SecureContext, "SecureContext"); function createSecureContext(options) { - if (options instanceof InternalSecureContext) return options; + if (options instanceof SecureContext) return options; // The setDefaultCACertificates() override is applied inside the - // InternalSecureContext constructor so every construction path honors it. + // SecureContext constructor so every construction path honors it. // The native handle (SSL_CTX) is memoised inside `NativeSecureContext.intern` // by the per-VM `SSLContextCache`, so no JS-side hashing here. The JS wrapper // is built fresh because it carries the per-call `servername`. // The user-facing constructor owns its SSL_CTX exclusively so addCACert // cannot leak across contexts; internal connect/listen paths stay cached. - return new InternalSecureContext(options); + return new SecureContext(options); } // Translate some fields from the handle's C-friendly format into more idiomatic @@ -801,7 +807,7 @@ function TLSSocket(socket?, options?) { } // Internal path: keep the per-digest cache (the user-facing constructors, // createSecureContext() and new tls.SecureContext(), own theirs exclusively). - this[ksecureContext] = options.secureContext || new InternalSecureContext(options, true); + this[ksecureContext] = options.secureContext || new SecureContext(options, kCachedContext); this.authorized = false; this.secureConnecting = true; this._secureEstablished = false; @@ -1005,7 +1011,7 @@ TLSSocket.prototype.setKeyCert = function setKeyCert(context) { // Serve this connection's identity from the given context (Node calls this // from ALPNCallback/SNICallback before the certificate is sent). Accepts a // SecureContext or the same options object createSecureContext takes. - const ctx = context?.context ? context : new InternalSecureContext(context, true); + const ctx = context?.context ? context : new SecureContext(context, kCachedContext); this._handle?.setKeyCert?.(ctx.context); }; @@ -1141,7 +1147,7 @@ let CLIENT_RENEG_LIMIT = 3, CLIENT_RENEG_WINDOW = 600; function buildSharedCreds(server) { - return (server._sharedCreds = new InternalSecureContext( + return (server._sharedCreds = new SecureContext( { ...server[ksharedCredsOptions], pfx: undefined, @@ -1161,7 +1167,7 @@ function buildSharedCreds(server) { minVersion: server.minVersion, maxVersion: server.maxVersion, }, - true, + kCachedContext, )); } @@ -1217,25 +1223,25 @@ function Server(options, secureConnectionListener): void { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1367-L1368 // NODE_TLS_REJECT_UNAUTHORIZED is a client-side switch; a server's default // is unconditionally true. - const serverOptions = options instanceof InternalSecureContext ? undefined : options; + const serverOptions = options instanceof SecureContext ? undefined : options; this._requestCert = serverOptions?.requestCert === true ? true : undefined; this._rejectUnauthorized = serverOptions?.rejectUnauthorized !== false; this.servername = undefined; this.ALPNProtocols = undefined; this._sharedCreds = undefined; - let contexts: Map | null = null; + let contexts: Map | null = null; this.addContext = function (hostname, context) { if (typeof hostname !== "string") { throw new TypeError("hostname must be a string"); } - if (!(context instanceof InternalSecureContext)) { - context = new InternalSecureContext(context, true); + if (!(context instanceof SecureContext)) { + context = new SecureContext(context, kCachedContext); } const handle = this._handle; if (handle) { - // Pass the native SSL_CTX wrapper, not the JS InternalSecureContext — + // Pass the native SSL_CTX wrapper, not the JS SecureContext — // the native side detects it via SecureContext.fromJS and up_refs. addServerName(handle, hostname, context.context); } else { @@ -1247,7 +1253,7 @@ function Server(options, secureConnectionListener): void { this.setSecureContext = function (options) { const serverTLSOptions = options; const next: Record = { __proto__: null }; - if (options instanceof InternalSecureContext) { + if (options instanceof SecureContext) { options = options.context; } if (options) { @@ -1315,7 +1321,7 @@ function Server(options, secureConnectionListener): void { // The process-wide default-CA override (tls.setDefaultCACertificates) // applies here too when no explicit `ca` was given: this path hands raw // {key, cert, ca} to the native listener and never goes through - // InternalSecureContext, so without this an mTLS server would verify + // SecureContext, so without this an mTLS server would verify // client certificates against the bundled roots instead of the // overridden defaults. if (_defaultCACertificatesOverride !== undefined && ca == null) { @@ -1413,9 +1419,9 @@ function Server(options, secureConnectionListener): void { this.minVersion = next.minVersion; this.maxVersion = next.maxVersion; } - this._sharedCreds = serverTLSOptions instanceof InternalSecureContext ? serverTLSOptions : null; + this._sharedCreds = serverTLSOptions instanceof SecureContext ? serverTLSOptions : null; this[ksharedCredsOptions] = - serverTLSOptions == null || serverTLSOptions instanceof InternalSecureContext + serverTLSOptions == null || serverTLSOptions instanceof SecureContext ? serverTLSOptions : { ...serverTLSOptions }; }; @@ -1634,8 +1640,22 @@ function connect(...args) { return tlssock.connect(normal); } +// Node: the supported cipher names, lower-cased, de-duplicated, sorted, cached. +let cachedCipherList: string[] | undefined; function getCiphers() { - return getDefaultCiphers().split(":"); + if (cachedCipherList === undefined) { + const names: string[] = getSSLCiphers(); + for (let i = 0; i < names.length; i++) { + names[i] = StringPrototypeToLowerCase.$call(names[i]); + } + ArrayPrototypeSort.$call(names); + const list: string[] = []; + for (let i = 0; i < names.length; i++) { + if (i === 0 || names[i] !== names[i - 1]) ArrayPrototypePush.$call(list, names[i]); + } + cachedCipherList = list; + } + return ArrayPrototypeSlice.$call(cachedCipherList); } // Convert protocols array into valid OpenSSL protocols list diff --git a/src/jsc/bindings/NodeTLS.cpp b/src/jsc/bindings/NodeTLS.cpp index ef8377ee9878..ff679279bda5 100644 --- a/src/jsc/bindings/NodeTLS.cpp +++ b/src/jsc/bindings/NodeTLS.cpp @@ -313,4 +313,40 @@ JSC_DEFINE_HOST_FUNCTION(setDefaultCiphers, (JSC::JSGlobalObject * globalObject, return Bun__setTLSDefaultCiphers(globalObject, callFrame); } +// tls.getCiphers(). SSL_CTX_get_ciphers omits the TLS 1.3 suites, so append them like Node does. +JSC_DEFINE_HOST_FUNCTION(getSSLCiphers, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + ncrypto::MarkPopErrorOnReturn mark_pop_error_on_return; + + ncrypto::DeleteFnPtr ctx(SSL_CTX_new(TLS_method())); + if (!ctx) { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + + JSC::MarkedArgumentBuffer results; + STACK_OF(SSL_CIPHER)* ciphers = SSL_CTX_get_ciphers(ctx.get()); + size_t count = sk_SSL_CIPHER_num(ciphers); + for (size_t i = 0; i < count; i++) { + results.append(JSC::jsString(vm, WTF::String::fromLatin1(SSL_CIPHER_get_name(sk_SSL_CIPHER_value(ciphers, i))))); + } + static constexpr ASCIILiteral tls13Ciphers[] = { + "TLS_AES_128_GCM_SHA256"_s, + "TLS_AES_256_GCM_SHA384"_s, + "TLS_CHACHA20_POLY1305_SHA256"_s, + }; + for (auto name : tls13Ciphers) { + results.append(JSC::jsString(vm, WTF::String(name))); + } + if (results.hasOverflowed()) { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + auto* array = JSC::constructArray(globalObject, static_cast(nullptr), results); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, JSValue::encode(array)); +} + } // namespace Bun diff --git a/src/jsc/bindings/NodeTLS.h b/src/jsc/bindings/NodeTLS.h index a5a829044477..92ef851e2978 100644 --- a/src/jsc/bindings/NodeTLS.h +++ b/src/jsc/bindings/NodeTLS.h @@ -10,5 +10,6 @@ JSC_DECLARE_HOST_FUNCTION(getSystemCACertificates); JSC_DECLARE_HOST_FUNCTION(parseCACertificates); JSC_DECLARE_HOST_FUNCTION(getDefaultCiphers); JSC_DECLARE_HOST_FUNCTION(setDefaultCiphers); +JSC_DECLARE_HOST_FUNCTION(getSSLCiphers); } diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 1bcd27a1a078..432b6119ddf7 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -2788,7 +2788,8 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionToClass, (JSC::JSGlobalObject * globalObject, JSObject* prototype = prototypeBase ? JSC::constructEmptyObject(globalObject, prototypeBase) : JSC::constructEmptyObject(globalObject); RETURN_IF_EXCEPTION(scope, encodedJSValue()); - prototype->structure()->setMayBePrototype(true); + // Not structure()->setMayBePrototype(): that structure is the shared cached one for every `{}`. + prototype->didBecomePrototype(vm); prototype->putDirect(vm, vm.propertyNames->constructor, target, PropertyAttribute::DontEnum | 0); target->setPrototypeDirect(vm, base); diff --git a/test/js/node/tls/node-tls-create-secure-context-args.test.ts b/test/js/node/tls/node-tls-create-secure-context-args.test.ts index e7d49bf753a4..750cdc26dd83 100644 --- a/test/js/node/tls/node-tls-create-secure-context-args.test.ts +++ b/test/js/node/tls/node-tls-create-secure-context-args.test.ts @@ -124,4 +124,59 @@ describe("tls.createSecureContext pfx argument", () => { expect(() => tls.createSecureContext({ pfx: view, passphrase: "sample" })).not.toThrow(); } }); + + // Option-normalising code branches on `x instanceof tls.SecureContext`, so + // the export must be the class createSecureContext() instantiates. + it("tls.SecureContext is the class of the objects createSecureContext returns", () => { + expect(typeof tls.SecureContext).toBe("function"); + expect(typeof tls.SecureContext.prototype).toBe("object"); + expect(tls.SecureContext.name).toBe("SecureContext"); + + const ctx = tls.createSecureContext({}); + expect(ctx instanceof tls.SecureContext).toBe(true); + expect(Object.getPrototypeOf(ctx)).toBe(tls.SecureContext.prototype); + expect(ctx.constructor).toBe(tls.SecureContext); + expect({} instanceof tls.SecureContext).toBe(false); + + // A user-constructed context is the same kind of object and works as + // `secureContext`. + const own = new tls.SecureContext({ ciphers: "ECDHE-RSA-AES128-GCM-SHA256" }); + expect(own instanceof tls.SecureContext).toBe(true); + expect(typeof own.context.addCACert).toBe("function"); + expect(tls.createSecureContext(own)).toBe(own); + + // Node's SecureContext returns an instance when called without `new`. + // @ts-expect-error the types only admit `new` + const called = tls.SecureContext({}); + expect(called instanceof tls.SecureContext).toBe(true); + expect(typeof called.context.addCACert).toBe("function"); + }); + + // Giving the builtin SecureContext function its prototype must mark only that + // prototype object, not the shared structure every empty `{}` starts from. + // When it leaked, debug builds hit `ASSERTION FAILED: !newStructure->mayBePrototype()` + // in JSON.parse once node:tls had loaded. + it("loading node:tls leaves plain object structures alone", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + require("node:tls"); + const warm = new Object(); + warm.zz1 = 1; + let parsed; + for (let i = 0; i < 3; i++) parsed = JSON.parse('{"zz1":1}'); + console.log(JSON.stringify(parsed)); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe('{"zz1":1}\n'); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); }); diff --git a/test/js/node/tls/node-tls-internals.test.ts b/test/js/node/tls/node-tls-internals.test.ts index 2b7bec6ddadb..c30ae4368d3d 100644 --- a/test/js/node/tls/node-tls-internals.test.ts +++ b/test/js/node/tls/node-tls-internals.test.ts @@ -3,7 +3,7 @@ import { createTest } from "node-harness"; import { X509Certificate } from "node:crypto"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { getCACertificates, rootCertificates } from "tls"; +import tls, { getCACertificates, rootCertificates } from "tls"; const { describe, expect } = createTest(import.meta.path); describe("NodeTLS.cpp", () => { @@ -82,4 +82,38 @@ describe("NodeTLS.cpp", () => { expect(cert.issuer).toBe(cert.subject); } }); + + // Node documents tls.getCiphers() as the supported cipher names, lower-cased + // and sorted. It is not the DEFAULT_CIPHERS policy string split on ":". + test("getCiphers lists the supported ciphers, lower-cased and sorted", () => { + const ciphers = tls.getCiphers(); + expect(ciphers).toEqual([...ciphers].sort()); + expect(new Set(ciphers).size).toBe(ciphers.length); + for (const name of ciphers) { + expect(name).toBe(name.toLowerCase()); + expect(name).not.toStartWith("!"); + } + expect(ciphers).toContain("aes256-sha"); + expect(ciphers).toContain("ecdhe-rsa-aes128-gcm-sha256"); + expect(ciphers).toContain("tls_aes_128_gcm_sha256"); + expect(ciphers).toContain("tls_aes_256_gcm_sha384"); + expect(ciphers).toContain("tls_chacha20_poly1305_sha256"); + expect(ciphers).not.toContain("high"); + expect(ciphers).not.toContain("!anull"); + + // The list is the supported set, so DEFAULT_CIPHERS does not change it. + const before = tls.DEFAULT_CIPHERS; + try { + tls.DEFAULT_CIPHERS = "ECDHE-RSA-AES128-GCM-SHA256"; + expect(tls.getCiphers()).toEqual(ciphers); + } finally { + tls.DEFAULT_CIPHERS = before; + } + + // Each call returns a fresh array, so a caller cannot change the cached list. + const copy = [...ciphers]; + expect(tls.getCiphers()).not.toBe(tls.getCiphers()); + tls.getCiphers().length = 0; + expect(tls.getCiphers()).toEqual(copy); + }); });