diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 05dee27b3a6..ab5c18e3419 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. 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 + }; + 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..a71bdfaf146 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,266 @@ 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(serverTlsExtra: Record, 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, + ...serverTlsExtra, + // 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() {}, + // 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, + ...(clientCert ? { key: clientCert.key, cert: clientCert.cert } : {}), + ca: CA_CRT, + servername: "localhost", + rejectUnauthorized: false, + }); + client.on("error", reject); + client.on("close", () => reject(new Error("client closed before server handshake"))); + + 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({ 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({ 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); + }); + + 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() { + reject(new Error("server socket closed before client handshake")); + }, + error(_socket, err) { + reject(err); + }, + }, + }); + 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() { + reject(new Error("client socket closed before handshake")); + }, + 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(); + } + }); +});