diff --git a/src/bun.js/bindings/ErrorCode.cpp b/src/bun.js/bindings/ErrorCode.cpp index 7bb307f0e7..19db7ab4e8 100644 --- a/src/bun.js/bindings/ErrorCode.cpp +++ b/src/bun.js/bindings/ErrorCode.cpp @@ -2304,6 +2304,17 @@ JSC_DEFINE_HOST_FUNCTION(Bun::jsFunctionMakeErrorWithCode, (JSC::JSGlobalObject return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_CERT_ALTNAME_FORMAT, "Invalid subject alternative name string"_s)); case ErrorCode::ERR_TLS_SNI_FROM_SERVER: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_SNI_FROM_SERVER, "Cannot issue SNI from a TLS server-side socket"_s)); + case ErrorCode::ERR_SSL_NO_CIPHER_MATCH: { + auto err = createError(globalObject, ErrorCode::ERR_SSL_NO_CIPHER_MATCH, "No cipher match"_s); + + auto reason = JSC::jsString(vm, WTF::String("no cipher match"_s)); + err->putDirect(vm, Identifier::fromString(vm, "reason"_s), reason); + + auto library = JSC::jsString(vm, WTF::String("SSL routines"_s)); + err->putDirect(vm, Identifier::fromString(vm, "library"_s), library); + + return JSC::JSValue::encode(err); + } case ErrorCode::ERR_INVALID_URI: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_INVALID_URI, "URI malformed"_s)); case ErrorCode::ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED: diff --git a/src/bun.js/bindings/ErrorCode.ts b/src/bun.js/bindings/ErrorCode.ts index 1f833272db..fef2fa7977 100644 --- a/src/bun.js/bindings/ErrorCode.ts +++ b/src/bun.js/bindings/ErrorCode.ts @@ -241,6 +241,7 @@ const errors: ErrorCodeMapping = [ ["ERR_TLS_PSK_SET_IDENTITY_HINT_FAILED", Error], ["ERR_TLS_RENEGOTIATION_DISABLED", Error], ["ERR_TLS_SNI_FROM_SERVER", Error], + ["ERR_SSL_NO_CIPHER_MATCH", Error], ["ERR_UNAVAILABLE_DURING_EXIT", Error], ["ERR_UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET", Error], ["ERR_UNESCAPED_CHARACTERS", TypeError], diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 2f389fc47f..f76fb835ff 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -762,6 +762,7 @@ declare function $ERR_TLS_RENEGOTIATION_DISABLED(): Error; declare function $ERR_UNAVAILABLE_DURING_EXIT(): Error; declare function $ERR_TLS_CERT_ALTNAME_FORMAT(): SyntaxError; declare function $ERR_TLS_SNI_FROM_SERVER(): Error; +declare function $ERR_SSL_NO_CIPHER_MATCH(): Error; declare function $ERR_INVALID_URI(): URIError; declare function $ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED(): TypeError; declare function $ERR_HTTP2_INFO_STATUS_NOT_ALLOWED(): RangeError; diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index f02c0edb93..65f2d1c6b1 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -1,5 +1,11 @@ const { isTypedArray, isArrayBuffer } = require("node:util/types"); +const DEFAULT_CIPHERS = + "DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256"; + +const DEFAULT_CIPHERS_LIST = DEFAULT_CIPHERS.split(":"); +const DEFAULT_CIPHERS_SET = new Set([...DEFAULT_CIPHERS_LIST.map(c => c.toLowerCase()), ...DEFAULT_CIPHERS_LIST]); + function isPemObject(obj: unknown): obj is { pem: unknown } { return $isObject(obj) && "pem" in obj; } @@ -48,6 +54,24 @@ function isValidTLSArray(obj: unknown) { return false; } +function validateCiphers(ciphers: string) { + const requested = ciphers.split(":"); + for (const r of requested) { + if (!DEFAULT_CIPHERS_SET.has(r)) { + throw $ERR_SSL_NO_CIPHER_MATCH(); + } + } +} + const VALID_TLS_ERROR_MESSAGE_TYPES = "string or an instance of Buffer, TypedArray, DataView, or BunFile"; -export { VALID_TLS_ERROR_MESSAGE_TYPES, isValidTLSArray, isValidTLSItem, throwOnInvalidTLSArray }; +export { + DEFAULT_CIPHERS, + DEFAULT_CIPHERS_LIST, + DEFAULT_CIPHERS_SET, + VALID_TLS_ERROR_MESSAGE_TYPES, + isValidTLSArray, + isValidTLSItem, + throwOnInvalidTLSArray, + validateCiphers, +}; diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index be8962060e..92b898092f 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -4,7 +4,7 @@ const net = require("node:net"); const { Duplex } = require("node:stream"); const [addServerName] = $zig("socket.zig", "createNodeTLSBinding"); const { throwNotImplemented } = require("internal/shared"); -const { throwOnInvalidTLSArray } = require("internal/tls"); +const { throwOnInvalidTLSArray, DEFAULT_CIPHERS, validateCiphers } = require("internal/tls"); const { Server: NetServer, Socket: NetSocket } = net; @@ -264,8 +264,10 @@ var InternalSecureContext = class SecureContext { } }; -function SecureContext(options) { - return new InternalSecureContext(options); +function SecureContext(options): void { + // TODO: The `never` exists because TypeScript only lets you construct functions that return void + // but in reality we should just be calling like InternalSecureContext.$call or similar + return new InternalSecureContext(options) as never; } function createSecureContext(options) { @@ -311,6 +313,11 @@ function TLSSocket(socket?, options?) { NetSocket.$call(this, options); + this.ciphers = options.ciphers; + if (this.ciphers) { + validateCiphers(options.ciphers); + } + if (typeof options === "object") { const { ALPNProtocols } = options; if (ALPNProtocols) { @@ -481,6 +488,7 @@ TLSSocket.prototype[buntls] = function (port, host) { session: this[ksession], rejectUnauthorized: this._rejectUnauthorized, requestCert: this._requestCert, + ciphers: this.ciphers, ...this[ksecureContext], }; }; @@ -579,6 +587,16 @@ function Server(options, secureConnectionListener): void { if (typeof rejectUnauthorized !== "undefined") { this._rejectUnauthorized = rejectUnauthorized; } else this._rejectUnauthorized = rejectUnauthorizedDefault; + + if (typeof options.ciphers !== "undefined") { + if (typeof options.ciphers !== "string") { + throw $ERR_INVALID_ARG_TYPE("options.ciphers", "string", options.ciphers); + } + + validateCiphers(options.ciphers); + + // TODO: Pass the ciphers + } } }; @@ -619,8 +637,6 @@ function createServer(options, connectionListener) { } const DEFAULT_ECDH_CURVE = "auto", // https://github.com/Jarred-Sumner/uSockets/blob/fafc241e8664243fc0c51d69684d5d02b9805134/src/crypto/openssl.c#L519-L523 - DEFAULT_CIPHERS = - "DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", DEFAULT_MIN_VERSION = "TLSv1.2", DEFAULT_MAX_VERSION = "TLSv1.3"; @@ -648,10 +664,12 @@ function normalizeConnectArgs(listArgs) { function connect(...args) { let normal = normalizeConnectArgs(args); const options = normal[0]; - const { ALPNProtocols } = options; + const { ALPNProtocols } = options as { ALPNProtocols?: unknown }; + if (ALPNProtocols) { convertALPNProtocols(ALPNProtocols, options); } + return new TLSSocket(options).connect(normal); } diff --git a/src/js/private.d.ts b/src/js/private.d.ts index d5668fd938..13b30627ee 100644 --- a/src/js/private.d.ts +++ b/src/js/private.d.ts @@ -223,3 +223,7 @@ declare function $newZigFunction any>( */ declare function $bindgenFn any>(filename: string, symbol: string): T; // NOTE: $debug, $assert, and $isPromiseFulfilled omitted + +declare module "node:net" { + export function _normalizeArgs(args: any[]): unknown[]; +} diff --git a/test/js/node/test/parallel/test-tls-handshake-error.js b/test/js/node/test/parallel/test-tls-handshake-error.js new file mode 100644 index 0000000000..c57026f6fd --- /dev/null +++ b/test/js/node/test/parallel/test-tls-handshake-error.js @@ -0,0 +1,26 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); + +const fixtures = require('../common/fixtures'); + +const server = tls.createServer({ + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), + rejectUnauthorized: true +}, common.mustNotCall()).listen(0, common.mustCall(function() { + assert.throws(() => { + tls.connect({ + port: this.address().port, + ciphers: 'no-such-cipher' + }, common.mustNotCall()); + }, /no cipher match/i); + + server.close(); +})); \ No newline at end of file diff --git a/test/js/node/test/parallel/test-tls-set-ciphers-error.js b/test/js/node/test/parallel/test-tls-set-ciphers-error.js new file mode 100644 index 0000000000..0df5a9288d --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-ciphers-error.js @@ -0,0 +1,25 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +{ + const options = { + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + ciphers: 'aes256-sha' + }; + assert.throws(() => tls.createServer(options, common.mustNotCall()), + /no[_ ]cipher[_ ]match/i); + options.ciphers = 'FOOBARBAZ'; + assert.throws(() => tls.createServer(options, common.mustNotCall()), + /no[_ ]cipher[_ ]match/i); + options.ciphers = 'TLS_not_a_cipher'; + assert.throws(() => tls.createServer(options, common.mustNotCall()), + /no[_ ]cipher[_ ]match/i); +} diff --git a/test/js/node/test/sequential/test-tls-connect.js b/test/js/node/test/sequential/test-tls-connect.js new file mode 100644 index 0000000000..7b9ea1624c --- /dev/null +++ b/test/js/node/test/sequential/test-tls-connect.js @@ -0,0 +1,61 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const fixtures = require('../common/fixtures'); + +const assert = require('assert'); +const tls = require('tls'); + +// https://github.com/joyent/node/issues/1218 +// uncatchable exception on TLS connection error +{ + const cert = fixtures.readKey('rsa_cert.crt'); + const key = fixtures.readKey('rsa_private.pem'); + + const options = { cert: cert, key: key, port: common.PORT }; + const conn = tls.connect(options, common.mustNotCall()); + + conn.on( + 'error', + common.mustCall((e) => { assert.strictEqual(e.code, 'ECONNREFUSED'); }) + ); +} + +// SSL_accept/SSL_connect error handling +{ + const cert = fixtures.readKey('rsa_cert.crt'); + const key = fixtures.readKey('rsa_private.pem'); + + assert.throws(() => { + tls.connect({ + cert: cert, + key: key, + port: common.PORT, + ciphers: 'rick-128-roll' + }, common.mustNotCall()); + }, /no cipher match/i); +} \ No newline at end of file diff --git a/test/js/node/tls/node-tls-no-cipher-match-error.test.ts b/test/js/node/tls/node-tls-no-cipher-match-error.test.ts new file mode 100644 index 0000000000..31355e97e2 --- /dev/null +++ b/test/js/node/tls/node-tls-no-cipher-match-error.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import * as tls from "node:tls"; + +const fixtures = require("../test/common/fixtures"); + +describe("TLS No Cipher Match Error code matches Node.js", () => { + test("The error should have all the same properties as Node.js", () => { + const options = { + key: fixtures.readKey("agent2-key.pem"), + cert: fixtures.readKey("agent2-cert.pem"), + ciphers: "aes256-sha", + }; + + expect(() => + tls.createServer(options, () => { + throw new Error("should not be called"); + }), + ).toThrow({ + code: "ERR_SSL_NO_CIPHER_MATCH", + message: "No cipher match", + library: "SSL routines", + reason: "no cipher match", + }); + + options.ciphers = "FOOBARBAZ"; + expect(() => + tls.createServer(options, () => { + throw new Error("should not be called"); + }), + ).toThrow({ + code: "ERR_SSL_NO_CIPHER_MATCH", + message: "No cipher match", + library: "SSL routines", + reason: "no cipher match", + }); + + options.ciphers = "TLS_not_a_cipher"; + expect(() => + tls.createServer(options, () => { + throw new Error("should not be called"); + }), + ).toThrow({ + code: "ERR_SSL_NO_CIPHER_MATCH", + message: "No cipher match", + library: "SSL routines", + reason: "no cipher match", + }); + }); +});