Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
48ff4f6752 fix(node): add authorized property to req.socket for HTTPS servers
Fixes #16254

When using mTLS (mutual TLS) with Node.js-compatible HTTPS servers,
`req.socket.authorized` was always `undefined` instead of a boolean,
preventing proper client certificate authentication.

This change:
- Adds the `authorized` getter to `JSNodeHTTPServerSocketPrototype.cpp`
  to expose it to JavaScript
- Updates `JSNodeHTTPServerSocket::isAuthorized()` to query the TLS
  verification status directly from OpenSSL via `us_socket_verify_error()`
- Updates `HttpContext.h` to set `isAuthorized` based on both handshake
  success AND certificate verification (verify_error.error == 0)
- Adds the `authorized` getter to `NodeHTTPServerSocket` in
  `_http_server.ts` and `FakeSocket` for completeness
- Adds the `authorizationError` property to both socket types

The `authorized` property now correctly returns:
- `true` when `requestCert: true` and the client certificate is valid
- `false` when no client certificate is provided or verification fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:35:41 +00:00
6 changed files with 161 additions and 23 deletions

View File

@@ -133,7 +133,11 @@ private:
return;
}
}
httpResponseData->isAuthorized = success;
// isAuthorized should be true only when both:
// 1. The handshake succeeded
// 2. The peer certificate was verified successfully (verify_error.error == 0)
// When requestCert is false, verify_error.error will be non-zero (no peer cert)
httpResponseData->isAuthorized = success && (verify_error.error == 0);
/* Any connected socket should timeout until it has a request */
((HttpResponse<SSL> *) s)->resetTimeout();

View File

@@ -19,6 +19,7 @@ extern "C" uint64_t uws_res_get_local_address_info(void* res, const char** dest,
extern "C" EncodedJSValue us_socket_buffered_js_write(void* socket, bool is_ssl, bool ended, us_socket_stream_buffer_t* streamBuffer, JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue data, JSC::EncodedJSValue encoding);
extern "C" int us_socket_is_ssl_handshake_finished(int ssl, struct us_socket_t* s);
extern "C" int us_socket_ssl_handshake_callback_has_fired(int ssl, struct us_socket_t* s);
extern "C" struct us_bun_verify_error_t us_socket_verify_error(int ssl, struct us_socket_t* s);
namespace Bun {
@@ -75,28 +76,16 @@ bool JSNodeHTTPServerSocket::isAuthorized() const
if (!is_ssl || !socket)
return false;
// Check if the handshake callback has fired. If so, use the isAuthorized flag
// which reflects the actual certificate verification result.
if (us_socket_ssl_handshake_callback_has_fired(is_ssl, socket)) {
auto* httpResponseData = reinterpret_cast<uWS::HttpResponseData<true>*>(us_socket_ext(is_ssl, socket));
if (!httpResponseData)
return false;
return httpResponseData->isAuthorized;
}
// Check if the TLS handshake has completed
if (!us_socket_is_ssl_handshake_finished(is_ssl, socket))
return false;
// The handshake callback hasn't fired yet, but we're in an HTTP handler,
// which means we received HTTP data. Check if the TLS handshake has actually
// completed using OpenSSL's state (SSL_is_init_finished).
//
// If the handshake is complete but the callback hasn't fired, we're in a race
// condition. The callback will fire shortly and either:
// 1. Set isAuthorized = true (success)
// 2. Close the socket (if rejectUnauthorized and verification failed)
//
// Since we're in an HTTP handler and the socket isn't closed, we can safely
// assume the handshake will succeed. If it fails, the socket will be closed
// and subsequent operations will fail appropriately.
return us_socket_is_ssl_handshake_finished(is_ssl, socket);
// Query the verification status directly from OpenSSL.
// authorized is true only when the peer certificate was verified successfully.
// When requestCert is false, there's no peer certificate, so verify_error.error
// will be non-zero (X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT = 2).
struct us_bun_verify_error_t verify_error = us_socket_verify_error(is_ssl, socket);
return verify_error.error == 0;
}
JSNodeHTTPServerSocket::~JSNodeHTTPServerSocket()

View File

@@ -34,6 +34,7 @@ JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterLocalAddress);
JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterDuplex);
JSC_DECLARE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterDuplex);
JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterIsSecureEstablished);
JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterAuthorized);
JSC_DEFINE_CUSTOM_SETTER(noOpSetter, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue value, JSC::PropertyName propertyName))
{
@@ -57,6 +58,7 @@ static const JSC::HashTableValue JSNodeHTTPServerSocketPrototypeTableValues[] =
{ "write"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketWrite, 2 } },
{ "end"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketEnd, 0 } },
{ "secureEstablished"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterIsSecureEstablished, noOpSetter } },
{ "authorized"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterAuthorized, noOpSetter } },
};
void JSNodeHTTPServerSocketPrototype::finishCreation(JSC::VM& vm)
@@ -120,6 +122,12 @@ JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterIsSecureEstablished, (JSC::
return JSValue::encode(JSC::jsBoolean(thisObject->isAuthorized()));
}
JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterAuthorized, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName))
{
auto* thisObject = jsCast<JSNodeHTTPServerSocket*>(JSC::JSValue::decode(thisValue));
return JSValue::encode(JSC::jsBoolean(thisObject->isAuthorized()));
}
JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterDuplex, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName))
{
auto* thisObject = jsCast<JSNodeHTTPServerSocket*>(JSC::JSValue::decode(thisValue));

View File

@@ -1,5 +1,5 @@
const { kInternalSocketData, serverSymbol } = require("internal/http");
const { kAutoDestroyed } = require("internal/shared");
const { kAutoDestroyed, kHandle } = require("internal/shared");
const { Duplex } = require("internal/stream");
type FakeSocket = InstanceType<typeof FakeSocket>;
@@ -10,6 +10,7 @@ var FakeSocket = class Socket extends Duplex {
connecting = false;
timeout = 0;
isServer = false;
authorizationError: string | undefined;
#address;
_httpMessage: any;
@@ -59,6 +60,12 @@ var FakeSocket = class Socket extends Duplex {
return this.connecting;
}
get authorized(): boolean {
// Try to get authorized from the underlying native handle
const handle = this._httpMessage?.[kHandle];
return handle?.authorized ?? false;
}
_read(_size) {}
get readyState() {

View File

@@ -819,6 +819,7 @@ const NodeHTTPServerSocket = class Socket extends Duplex {
server: Server;
_httpMessage;
_secureEstablished = false;
authorizationError: string | undefined;
#pendingCallback = null;
constructor(server: Server, handle, encrypted) {
super();
@@ -842,6 +843,11 @@ const NodeHTTPServerSocket = class Socket extends Duplex {
this[kBytesWritten] = value;
}
get authorized(): boolean {
const handle = this[kHandle];
return handle?.authorized ?? false;
}
[kEnableStreaming](enable: boolean) {
const handle = this[kHandle];
if (handle) {

View File

@@ -0,0 +1,124 @@
// https://github.com/oven-sh/bun/issues/16254
// req.socket.authorized should be a boolean indicating client certificate verification status
import { expect, test } from "bun:test";
import { readFileSync } from "fs";
import * as https from "https";
import type { AddressInfo } from "net";
import { join } from "path";
const fixturesDir = join(import.meta.dir, "../../js/node/tls/fixtures");
// ca1 is a self-signed CA certificate
const ca1Cert = readFileSync(join(fixturesDir, "ca1-cert.pem"));
// agent1 is signed by ca1
const agent1Key = readFileSync(join(fixturesDir, "agent1-key.pem"));
const agent1Cert = readFileSync(join(fixturesDir, "agent1-cert.pem"));
test("req.socket.authorized should be true when client certificate is valid (mTLS)", async () => {
const { promise, resolve, reject } = Promise.withResolvers<{ authorized: boolean | undefined; response: string }>();
const server = https.createServer(
{
key: agent1Key,
cert: agent1Cert,
ca: ca1Cert,
requestCert: true,
rejectUnauthorized: true,
},
(req, res) => {
const authorized = req.socket.authorized;
res.writeHead(200);
res.end(authorized ? "Authorized" : "Not authorized");
resolve({ authorized, response: authorized ? "Authorized" : "Not authorized" });
},
);
server.on("error", reject);
await new Promise<void>(res => server.listen(0, res));
try {
const port = (server.address() as AddressInfo).port;
const req = https.request(
{
hostname: "localhost",
port,
method: "GET",
path: "/",
key: agent1Key,
cert: agent1Cert,
ca: ca1Cert,
rejectUnauthorized: false, // Don't reject self-signed server cert
},
res => {
let data = "";
res.on("data", chunk => (data += chunk));
res.on("end", () => {
// Response handled via promise above
});
},
);
req.on("error", reject);
req.end();
const result = await promise;
// The main assertion: authorized should be a boolean true, not undefined
expect(typeof result.authorized).toBe("boolean");
expect(result.authorized).toBe(true);
expect(result.response).toBe("Authorized");
} finally {
server.close();
}
});
test("req.socket.authorized should be defined even when requestCert is false", async () => {
const { promise, resolve, reject } = Promise.withResolvers<boolean | undefined>();
const server = https.createServer(
{
key: agent1Key,
cert: agent1Cert,
requestCert: false,
rejectUnauthorized: false,
},
(req, res) => {
resolve(req.socket.authorized);
res.writeHead(200);
res.end("OK");
},
);
server.on("error", reject);
await new Promise<void>(res => server.listen(0, res));
try {
const port = (server.address() as AddressInfo).port;
const req = https.request(
{
hostname: "localhost",
port,
method: "GET",
path: "/",
rejectUnauthorized: false,
},
() => {},
);
req.on("error", reject);
req.end();
const authorized = await promise;
// authorized should be a boolean (false when no client cert requested)
expect(typeof authorized).toBe("boolean");
expect(authorized).toBe(false);
} finally {
server.close();
}
});