From 62f406b4c2340774dfc470d9abb873b1a58c711b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:34:36 +0000 Subject: [PATCH 1/5] Bun.listen: report socket.authorized from the client-cert verify result On a TLS server accepting a connection, socket.authorized was computed purely from whether the TLS handshake completed (success == 1). The native verify callback always continues the handshake and defers the decision, so a client certificate signed by an untrusted CA still reported socket.authorized === true while getAuthorizationError() returned a non-null error at the same time. Fold the BoringSSL verify result into the AUTHORIZED flag for server sockets so the getter matches the verification outcome. The success value handed to the handshake callback is left unchanged: node:tls's server handler treats a false second argument as "TLS session never established". --- src/runtime/socket/socket_body.rs | 11 +- test/js/bun/net/socket.test.ts | 193 ++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 1 deletion(-) diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 05dee27b3a6..3c4bc5580bb 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -1685,8 +1685,17 @@ impl NewSocket { } } + // `socket.authorized` must reflect peer-certificate verification, not + // just handshake completion. For servers, fold the verify result in; + // the callback's `success` arg stays raw (node:tls relies on that). + let authorized_flag = if SSL && handlers.mode.is_server() { + success == 1 && ssl_error.error_no == 0 + } else { + authorized + }; + this.update_flags(|f| { - f.set(Flags::AUTHORIZED, authorized); + f.set(Flags::AUTHORIZED, authorized_flag); f.set(Flags::HOSTNAME_MISMATCH, hostname_mismatch); }); diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index bd9bcf9c2df..45945087b6f 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -4,6 +4,7 @@ import { createSocketPair } from "bun:internal-for-testing"; import { describe, expect, it, jest } from "bun:test"; import { closeSync } from "fs"; import { bunEnv, bunExe, expectMaxObjectTypeCount, getMaxFD, isWindows, tempDir, tls } from "harness"; +import nodeTls from "node:tls"; describe.concurrent("socket", () => { it("should throw when a socket from a file descriptor has a bad file descriptor", async () => { const open = jest.fn(); @@ -1776,3 +1777,195 @@ it.concurrent("setTypeOfService validates its argument instead of asserting", as }); void stderr; }); + +// https://github.com/oven-sh/bun/issues/33754 +// A `Bun.listen` TLS server with `requestCert` must report `socket.authorized` +// based on whether the client certificate actually verified, not merely that +// the TLS handshake completed. Certs below were generated once with openssl: +// a CA, a "localhost" server cert signed by it, and a client cert signed by a +// completely different rogue CA the server never trusts. +describe("Bun.listen TLS client-certificate authorization", () => { + const CA_CRT = `-----BEGIN CERTIFICATE----- +MIIDETCCAfmgAwIBAgIURCg2v+DXlydKS0oySTGIE3z/hy4wDQYJKoZIhvcNAQEL +BQAwGDEWMBQGA1UEAwwNTGVnaXQtVGVzdC1DQTAeFw0yNjA3MDgwNjU2MTRaFw0z +NjA3MDUwNjU2MTRaMBgxFjAUBgNVBAMMDUxlZ2l0LVRlc3QtQ0EwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtvs7EHLwTOT/folLzxbSsCI2BaSC7/AQq +xtLXHd9ZyW942v9XzDahUUqmeLtKQONEb3b0r1aZlQlAgQlGRxPNodbmwg43TFxn +EdrzRrj0++5R+J/PV19+wrHfVVBGEVjC24iN1wz7beqyIr9DiCc8/7FqZZIgMHcd +RJi6tAOToVxyqUZZXYIYAa2VEKRtM6DqepKEyxjqrAgwYh2kyfxFFXETqKwnGnZb +ORD8AMGe6nxvC5GwJvOQAy/k8vwkYENgPfnMuiiWLQeStquswe6ufHRJq7Pu3SCz +j0uswLSNB5QgPm+rAie2Q2FGOcCFTmx2lLXiXAHAxuR7uQ3Mrh33AgMBAAGjUzBR +MB0GA1UdDgQWBBQyNc02kiPUTqWS1JvGP59zZa60PDAfBgNVHSMEGDAWgBQyNc02 +kiPUTqWS1JvGP59zZa60PDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUA +A4IBAQAaf/ZPTCgJez9JkjNnGqTTrZRJf1oB1KKJSefU7lQio+tOMHEwK/6fRk6B +GYTSfXEOTVpRHp1ITTUx8XRc56C/gcEoiQzsrIcxNLEw4c3lvvFFClij3JMASRw4 +d6BaoRHbecckClHFB0xLyYGXXM+Nr3AH25mdoiDQ1fyUY44gUFqay88plkZTOHJ7 +Bn1c1bSzbCH2Le2jtVW9sntw/5BmR/Wu6fzynXERzVZuBaMjh0SKrlfXJrolMn+j +xDilF8VcHP0B/EhvfVBLQbMbGPtH/vcb7yZHA5LR4FDYe4GUAn1rTocxNNGWR6qI +hfy/JaAe+cCaj/lhbnn3ntyu4Hup +-----END CERTIFICATE-----`; + + const SERVER_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDfzMHr8zfqb38y +W4YsFxPg+9atUKeHlgmpdlYHRsDZoP/ytPEVzlaBQdtt01g67SbkY/C+luXHLy7l +vfM2fTlhG86cGs5Wc+bRMSxVo9ev6xxX96dRTRp67JSHU71hEkXV324nDJIM58HO +M0NkK5DJqj4L9Yruu92Fvero0Q7rqLwxclAkmZQVu1TIyRiaAn3kt+d1geOblZJW +Fq0OvyI8Y2oW7w21Ftu184H7CY97T7AfyhyAH5bNGjOBaGrSYQClOlvka/NSVCvw +nZ1yPOgcVc5kHDfkAV7adDfxbizuHqrHdIg15cjxmIdWx5ygm18B4oyrQJePZLk9 +hyw19dV5AgMBAAECggEAICajSUlUpBpGdK6PVPAvy+eCDL4Eg0b7tluoujzg4aLu +giiaZd6RsNoMMvfkufpt1uvAnzDAa+AMZbbnJNHSl2/OO8DiGatm4nClBNyX1M76 +8GxEnjpsbnJkWBigoTxlyfbiNTvqE26L+hqFOPPFRiNt7Hvm7KsShO8muzGlahc7 +w3CLhod0yjDOmXUe0fui4P8EVkESk/yDdRyJ+Kr/CqHa0WwVR6UC0UpBPAylefA0 +JVJUkXnX0fRM/5Hy2L9LGolWyHbPD0OO5J4gFikCKFTd98G2n5kBaWJxeIdor3Fb +TV/a9VXtjf+cnQqNk6mnhciTFgd2mJVYjXwg4ymjvQKBgQDxU9726kJMAal16htE +JTqf6xQh369gj0bSgqHurAgJy8k0rvZtoKxM5/wAvnsOUQ4JFGjyisFBlaMXkfHx +gQyAXES1wdzwxWL779h5Cy8yRyrXf2ATFtfVfONr/GA3FlQxGIzXrmMHOIBaf/65 +i5J0cn6KNP7WQX1C7fYo3G7TKwKBgQDtaBONrTjRwRhLFNm1RAP2Uh72ryciP2xt +8B11nielPj+D+sa2HJDGe5q3T3wOFAzmfj9CmTVLTC5nERJmpV9hpVBuC8ixvOq2 +PQ0ft86lglE2qnJkcj8uGmGm7KDyea7UUSW6xmflNXW/rY8k/IeeocTWzfV+2V3Y +t+fZk7N36wKBgQDqFLF0DZxK/12xe2gBPJ9V2P8JMGB7p95JeN67lHCjl+DN0lxD +0BLw8iCVVC4mn+aeVgbKJF76T7wHs8/bspI+u8EGEEpP3RZ7S5VNK9UWzsM2jl3R +hlnKwb4S05U8OdNmX+rVlliF+388yWR583EWtKwbQPZjOtjWn90im1aASwKBgQDT +fNmeZmetg8Subf6bWeHltrZaryG/gpyHO1YjBybuL4vJeMc4SC44grgLAMXUjMwJ +MQINxAoT7+OOcUjhJATaKbiCsACzRUYZ3j0oukdebb8HYcPR82yRF3NSjo26M+go +v7lKr6CyMXOZs3VHT6dJC3ccnBFMVTsi6oGh88/2zQKBgBQPAsMBGjALPiGrNZwW +CFDYMBz5VKZ8J6GVH7vwn4KQZd4jw/VOLF/NfX+ijn3KniBmIzpakRbCc2DSx9jw +8cNB7ZDonWPCu3bPWAQvfdEZPgt1KEz9u5ylEXYP7PZNJR+Qs3BnJUzE7UJq1WcX +BF2nr2uDK3QfASTFcqKlZmZN +-----END PRIVATE KEY-----`; + + const SERVER_CRT = `-----BEGIN CERTIFICATE----- +MIIC/DCCAeSgAwIBAgIUJ8/XSQwF1luGKteqE6Hljj5MVM4wDQYJKoZIhvcNAQEL +BQAwGDEWMBQGA1UEAwwNTGVnaXQtVGVzdC1DQTAeFw0yNjA3MDgwNjU2MTVaFw0z +NjA3MDUwNjU2MTVaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAN/MwevzN+pvfzJbhiwXE+D71q1Qp4eWCal2VgdG +wNmg//K08RXOVoFB223TWDrtJuRj8L6W5ccvLuW98zZ9OWEbzpwazlZz5tExLFWj +16/rHFf3p1FNGnrslIdTvWESRdXfbicMkgznwc4zQ2QrkMmqPgv1iu673YW96ujR +DuuovDFyUCSZlBW7VMjJGJoCfeS353WB45uVklYWrQ6/IjxjahbvDbUW27XzgfsJ +j3tPsB/KHIAfls0aM4FoatJhAKU6W+Rr81JUK/CdnXI86BxVzmQcN+QBXtp0N/Fu +LO4eqsd0iDXlyPGYh1bHnKCbXwHijKtAl49kuT2HLDX11XkCAwEAAaNCMEAwHQYD +VR0OBBYEFNa6d063ZGCj98LvLSL8I4ENeFOcMB8GA1UdIwQYMBaAFDI1zTaSI9RO +pZLUm8Y/n3NlrrQ8MA0GCSqGSIb3DQEBCwUAA4IBAQChmWJZ6m2xZAF0zDJEE9+C +Q5soyhl34/Jxg2cG+jEmoYBlA4/R3TrtCvbq+NnwOlp85nA++jndnBumRu31eDUH +mTVViNhX0r21EsTXWaUckKw8YbQBgeoFQkMiIhpb1rOBU0ZrwOj19e5Up8xzoEzo +VbyJVjjto67oY8pHev1GGWDMQlAgJhz5nOfU+xgtJfZWH6VNgG96GNG18CmjM3EE +HIyPp44YI9gKb+aQ93VH3tzPhIblIEavGz8iRRqZmI2MwYjAvj7c+iY2M8EeNTmA +zdQ+vTy4ZvC4FbTBzY5tkBlPF/TuOMBpPDcnZUHXvSWtsgPBrEd8SM3eODZNhDMw +-----END CERTIFICATE-----`; + + const ATTACKER_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCUkAH8c0Z0W+/H +ZB3HYgjJAMTJTM92SLDFkK9DigNYTe1XgTCP1oH5jsirAcAObv7G/sfZBeR0Jifr +ieOou+bH6Vv5BC6VBg+GvFTtQyNFwClxDCpUMtXbL91n57MyYdM3F2QzIENzCKJp +pAYqVgdgpXtfU7dsKp5GfHHpEkPa6GR6zYAlGBX79BUIQNjvwOdTSV1s4rg9N83b +QGgebrzPxTnt6ugsVBVDI29FGCz0fpY5AQ2Ymz1JCljoZstq5iU6/hPY/76CbzHk +3msOsWKzAoTaiiQ3PThtV4rvhwcMowNsHuQPpr2pC9iqICaGvLkJveiVi4/G+zbn +flmDCfsZAgMBAAECggEABcQHB7Sznc15o1vr2fKgk6h/eq6L+LO+hJRmcPEYyyMP +HI/LgjGZjsVvXU+ulADTy4ZU3EPhx245q2cJGmHchZK1epcida8sCBckFqc95bWd +BiGqPOVSLnUPmzo/Vqu8Rk2WmnNcmsjRgNcYVClNlP8nKB54Jm8LAIJQ63JFhufh +4Eztk3j8KXJN371G8o/gKqV3/wIoG9B7wFhNsUl3g6oEf1fRRYntYzDBcYZb1j7E +ZzR8symIXz5l5FTM0hgScxNXW4JVNc7PLQpLKSXbsWZql65zyyoDS+TcwFbonrQD +woN9/veudfWLiSzT6cQF7Nq2AwGDG9NluZQowjdzeQKBgQDPBhdM0rNL5LoZ1Uwl +bK7I7k0u8OwyEXmqcaOuJyGmtA/ld2SmG8pjKRavdcM8CWQUnD0mMNBoUM8uf1Wn +psrExYVyqJPFl477ZwJXYPSHkokxPDqNWnW8BL+lVYIShCFF0YqxTPsUnlX+FSsM +LDuHPDUwNhdffokHcCmnVhDNtQKBgQC3tVkQARA7IEnNGPkpSYR7pK6eDj2SAABo +rRd2uTwyhz+N/2ziD2kkra/jKOD1FbfBRoU1mC5R2M0xe6rqaCzVwAXL1DVZLH8w +auP1Iz5Bgsv1wO32aqm/jPY9jF2HuICwA23zZ/fbzhGlXeqThnG5yqbgwvFVcE1k +qVCJEru2VQKBgAx9f5Zg9/RSPnAkkE2JuxngVsPIQVfb+g0wggGV1s/p+TOM+oOT +FajZ58Z6Qmcy4djkfEP7mfdROM7DW/WbeIxapmx+gzveov/D/T4sWVR8oM5Zpea1 +WHkZiD5/ZDOdySwfMlD0Jgnea63CtTAs0wKbvVHFUa+vQLE0MS8pYCYpAoGAWeyb +PApJN6gGeC7RSDSEdUGXeCbgXKdDi/mukp75qIIrygZN9ho1DY83mapY8589443x +htqHUekeCCrQ7w0vZTIppCysMIpnd/vauhQWVVsBG7rkwMpVbT5DCr26ysS1uXL0 +T0GFQkMMwDXIrY4R/TAFn9/M4lWmjK+UjIRu/kECgYEAo4lgcaUTtGhxPXE3A8fd +IudOPh7oZzeoEg2YgfCFaSmAO+RJASs5GU8JrhMK09AwB8uOGen6ELMtnZiYRKpK +tha0aOPTR4W4gBGeUN9iXGvSvPVcjoMIYConUr2G7rMLT/c/bbpkiLwyLkYxedqR +mC+O9upMXi0bT4s+yu4UHzk= +-----END PRIVATE KEY-----`; + + const ATTACKER_CRT = `-----BEGIN CERTIFICATE----- +MIIC/zCCAeegAwIBAgIUML8e9nqsrHuvFN1/5i1U30fTs6swDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRUm9ndWUtQXR0YWNrZXItQ0EwHhcNMjYwNzA4MDY1NjE1 +WhcNMzYwNzA1MDY1NjE1WjATMREwDwYDVQQDDAhhdHRhY2tlcjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAJSQAfxzRnRb78dkHcdiCMkAxMlMz3ZIsMWQ +r0OKA1hN7VeBMI/WgfmOyKsBwA5u/sb+x9kF5HQmJ+uJ46i75sfpW/kELpUGD4a8 +VO1DI0XAKXEMKlQy1dsv3WfnszJh0zcXZDMgQ3MIommkBipWB2Cle19Tt2wqnkZ8 +cekSQ9roZHrNgCUYFfv0FQhA2O/A51NJXWziuD03zdtAaB5uvM/FOe3q6CxUFUMj +b0UYLPR+ljkBDZibPUkKWOhmy2rmJTr+E9j/voJvMeTeaw6xYrMChNqKJDc9OG1X +iu+HBwyjA2we5A+mvakL2KogJoa8uQm96JWLj8b7Nud+WYMJ+xkCAwEAAaNCMEAw +HQYDVR0OBBYEFLIHghpukEjtpVlMBWpuqqynyeypMB8GA1UdIwQYMBaAFHRJlVcz +2ggsHzqULpfY5tqj6bK4MA0GCSqGSIb3DQEBCwUAA4IBAQA0hTvMyg/la6WHtVqQ +raikcRdfgZPJMqULtxNP8h5HN8G2N1J61Yrqs+hZv8tpsgJ69kSAVq2rlGlpe4XZ +BTj5g8+zEVy+/oP/720EQLXwp8xhB9yn/dKEPTpuQHc0yhNU7L4F/EsfCuBdOY09 +UN9EB9KAnNGzKLt5QhZRFz/Ik/Eg9iTLblaHTsyK8XaEncOQGSIP/UyXThJ/xE2g +RL/yKf/oq1zXajZAPCFTlt9OcxZLDS0sHlpybthTuN6rrFQzoIhlGmecp3i6B+oH +H13FrrYXfkdKAdTYPoIw1P2rvMfDcVXf2ELcyN9vjHq6YSoplEam/pXnPVOHRgT5 +NMTg +-----END CERTIFICATE-----`; + + async function runHandshake(clientCert: { key: string; cert: string }) { + const { promise, resolve, reject } = Promise.withResolvers<{ + success: boolean; + authorized: boolean; + callbackError: string | null; + getterError: string | null; + }>(); + + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: { + key: SERVER_KEY, + cert: SERVER_CRT, + ca: CA_CRT, + requestCert: true, + // rejectUnauthorized left at its documented default (true) + }, + socket: { + open() {}, + handshake(socket, success, authorizationError) { + resolve({ + success, + authorized: socket.authorized, + callbackError: authorizationError?.message ?? null, + getterError: socket.getAuthorizationError()?.message ?? null, + }); + }, + data() {}, + close() {}, + error() {}, + }, + }); + + const client = nodeTls.connect({ + host: "127.0.0.1", + port: server.port, + key: clientCert.key, + cert: clientCert.cert, + ca: CA_CRT, + servername: "localhost", + rejectUnauthorized: false, + }); + client.on("error", reject); + + try { + return await promise; + } finally { + client.destroy(); + } + } + + it("reports authorized=false for a client cert signed by an untrusted CA", async () => { + const result = await runHandshake({ key: ATTACKER_KEY, cert: ATTACKER_CRT }); + expect(result.authorized).toBe(false); + expect(result.callbackError).not.toBeNull(); + expect(result.getterError).not.toBeNull(); + }); + + it("reports authorized=true for a client cert signed by the trusted CA", async () => { + // SERVER_CRT/SERVER_KEY chains to CA_CRT, which the server trusts. + const result = await runHandshake({ key: SERVER_KEY, cert: SERVER_CRT }); + expect(result.authorized).toBe(true); + expect(result.callbackError).toBeNull(); + expect(result.getterError).toBeNull(); + }); +}); From e700c4b23a1f3bdd654cdf89e184f1613b520cee Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:23:37 +0000 Subject: [PATCH 2/5] Fold verify result into socket.authorized for all TLS sockets Broaden the fix from server sockets only to every TLS socket: the AUTHORIZED flag now requires both a completed handshake and a zero verify error. This also covers upgradeTLS({ isServer: true }) server sockets (whose Handlers mode is deliberately Client) and Bun.connect clients, which previously reported socket.authorized === true while getAuthorizationError() returned a non-null X509 error. A plain TLS server that never requests a client certificate now reports authorized === false, matching node:tls's TLSSocket#authorized. The handshake callback's success argument is unchanged. Add tests for a plain (no requestCert) server and assert the callback success argument stays true on a verify failure. --- src/runtime/socket/socket_body.rs | 8 +++---- test/js/bun/net/socket.test.ts | 36 +++++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 3c4bc5580bb..ab5c18e3419 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -1686,10 +1686,10 @@ impl NewSocket { } // `socket.authorized` must reflect peer-certificate verification, not - // just handshake completion. For servers, fold the verify result in; - // the callback's `success` arg stays raw (node:tls relies on that). - let authorized_flag = if SSL && handlers.mode.is_server() { - success == 1 && ssl_error.error_no == 0 + // just handshake completion. The callback's `success` arg stays raw: + // node:tls treats a false second argument as "TLS never established". + let authorized_flag = if SSL { + authorized && ssl_error.error_no == 0 } else { authorized }; diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 45945087b6f..59a6ff2655d 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1902,7 +1902,7 @@ H13FrrYXfkdKAdTYPoIw1P2rvMfDcVXf2ELcyN9vjHq6YSoplEam/pXnPVOHRgT5 NMTg -----END CERTIFICATE-----`; - async function runHandshake(clientCert: { key: string; cert: string }) { + async function runHandshake(serverTlsExtra: Record, clientCert?: { key: string; cert: string }) { const { promise, resolve, reject } = Promise.withResolvers<{ success: boolean; authorized: boolean; @@ -1916,8 +1916,7 @@ NMTg tls: { key: SERVER_KEY, cert: SERVER_CRT, - ca: CA_CRT, - requestCert: true, + ...serverTlsExtra, // rejectUnauthorized left at its documented default (true) }, socket: { @@ -1931,16 +1930,21 @@ NMTg }); }, data() {}, - close() {}, - error() {}, + // Surface a server-side teardown before handshake instead of hanging + // until the runner timeout. + close() { + reject(new Error("server socket closed before handshake")); + }, + error(_socket, err) { + reject(err); + }, }, }); const client = nodeTls.connect({ host: "127.0.0.1", port: server.port, - key: clientCert.key, - cert: clientCert.cert, + ...(clientCert ? { key: clientCert.key, cert: clientCert.cert } : {}), ca: CA_CRT, servername: "localhost", rejectUnauthorized: false, @@ -1955,17 +1959,31 @@ NMTg } it("reports authorized=false for a client cert signed by an untrusted CA", async () => { - const result = await runHandshake({ key: ATTACKER_KEY, cert: ATTACKER_CRT }); + const result = await runHandshake({ ca: CA_CRT, requestCert: true }, { key: ATTACKER_KEY, cert: ATTACKER_CRT }); expect(result.authorized).toBe(false); expect(result.callbackError).not.toBeNull(); expect(result.getterError).not.toBeNull(); + // The handshake itself completed; only certificate verification failed. The + // callback's `success` arg must stay true so node:tls's server handler does + // not misread a verify failure as "the TLS session was never established". + expect(result.success).toBe(true); }); it("reports authorized=true for a client cert signed by the trusted CA", async () => { // SERVER_CRT/SERVER_KEY chains to CA_CRT, which the server trusts. - const result = await runHandshake({ key: SERVER_KEY, cert: SERVER_CRT }); + const result = await runHandshake({ ca: CA_CRT, requestCert: true }, { key: SERVER_KEY, cert: SERVER_CRT }); expect(result.authorized).toBe(true); expect(result.callbackError).toBeNull(); expect(result.getterError).toBeNull(); + expect(result.success).toBe(true); + }); + + it("reports authorized=false on a plain server that never requested a client cert", async () => { + // No ca, no requestCert: no client certificate is verified, so the + // connection is not authorized even though the handshake completes. Matches + // Node's tls.TLSSocket#authorized (false unless requestCert + verified). + const result = await runHandshake({}); + expect(result.success).toBe(true); + expect(result.authorized).toBe(false); }); }); From c0fc6746eab0f9629df6666f41a1def4c5f84be0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:46:57 +0000 Subject: [PATCH 3/5] test: lock in the Bun.connect client authorized flip Add a client-side test: a Bun.connect TLS client to a server whose certificate does not chain to any trusted CA (but whose hostname matches) reports socket.authorized === false. This pins the client branch of the verify-result fold so a future narrowing back to server-only sockets does not pass green. --- test/js/bun/net/socket.test.ts | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 59a6ff2655d..ed9a13fa74d 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1986,4 +1986,45 @@ NMTg expect(result.success).toBe(true); expect(result.authorized).toBe(false); }); + + it("reports authorized=false on a Bun.connect client when the server cert does not verify", async () => { + // The client trusts no CA that issued the server's cert, but the hostname + // (CN=localhost) matches, so the sole reason authorized is false is the + // failed chain verification, matching node:tls's client-side authorized. + const { promise, resolve, reject } = Promise.withResolvers<{ authorized: boolean; getterError: string | null }>(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: { key: SERVER_KEY, cert: SERVER_CRT }, + socket: { open() {}, data() {}, close() {}, error() {} }, + }); + const client = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { serverName: "localhost", rejectUnauthorized: false }, + socket: { + open() {}, + handshake(socket) { + resolve({ authorized: socket.authorized, getterError: socket.getAuthorizationError()?.message ?? null }); + }, + data() {}, + close() {}, + error(_s, err) { + reject(err); + }, + connectError(_s, err) { + reject(err); + }, + }, + }); + try { + const result = await promise; + expect(result.authorized).toBe(false); + expect(result.getterError).not.toBeNull(); + // The failure is the chain, not the hostname. + expect(result.getterError).not.toBe("Hostname mismatch"); + } finally { + client.end(); + } + }); }); From 48838ba4ef39c28594a94042d94fbb6ddd8ff7e8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:08:30 +0000 Subject: [PATCH 4/5] ci: retrigger From 02a2433194f61d249baf3e4c031f90f36e0c54da Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:18:50 +0000 Subject: [PATCH 5/5] test: reject on pre-handshake socket close in TLS auth tests Wire the server/client close and error handlers in the new TLS authorization tests to reject the awaited promise, so a clean close before the handshake callback fires surfaces as a failure instead of hanging to the runner timeout. --- test/js/bun/net/socket.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index ed9a13fa74d..a71bdfaf146 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1950,6 +1950,7 @@ NMTg rejectUnauthorized: false, }); client.on("error", reject); + client.on("close", () => reject(new Error("client closed before server handshake"))); try { return await promise; @@ -1996,7 +1997,16 @@ NMTg hostname: "127.0.0.1", port: 0, tls: { key: SERVER_KEY, cert: SERVER_CRT }, - socket: { open() {}, data() {}, close() {}, error() {} }, + socket: { + open() {}, + data() {}, + close() { + reject(new Error("server socket closed before client handshake")); + }, + error(_socket, err) { + reject(err); + }, + }, }); const client = await Bun.connect({ hostname: "127.0.0.1", @@ -2008,7 +2018,9 @@ NMTg resolve({ authorized: socket.authorized, getterError: socket.getAuthorizationError()?.message ?? null }); }, data() {}, - close() {}, + close() { + reject(new Error("client socket closed before handshake")); + }, error(_s, err) { reject(err); },