Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
70 changes: 45 additions & 25 deletions src/js/node/tls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | undefined;
function getValidCiphersSet() {
if (!_VALID_CIPHERS_SET) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
};

Expand Down Expand Up @@ -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,
Expand All @@ -1161,7 +1167,7 @@ function buildSharedCreds(server) {
minVersion: server.minVersion,
maxVersion: server.maxVersion,
},
true,
kCachedContext,
));
}

Expand Down Expand Up @@ -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<string, typeof InternalSecureContext> | null = null;
let contexts: Map<string, SecureContext> | 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 {
Expand All @@ -1247,7 +1253,7 @@ function Server(options, secureConnectionListener): void {
this.setSecureContext = function (options) {
const serverTLSOptions = options;
const next: Record<string, any> = { __proto__: null };
if (options instanceof InternalSecureContext) {
if (options instanceof SecureContext) {
options = options.context;
}
if (options) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 };
};
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions src/jsc/bindings/NodeTLS.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<SSL_CTX, SSL_CTX_free> 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<JSC::ArrayAllocationProfile*>(nullptr), results);
RETURN_IF_EXCEPTION(scope, {});
RELEASE_AND_RETURN(scope, JSValue::encode(array));
}

} // namespace Bun
1 change: 1 addition & 0 deletions src/jsc/bindings/NodeTLS.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);

}
3 changes: 2 additions & 1 deletion src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
55 changes: 55 additions & 0 deletions test/js/node/tls/node-tls-create-secure-context-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
36 changes: 35 additions & 1 deletion test/js/node/tls/node-tls-internals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});