Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4729,6 +4729,11 @@ declare module "bun" {
*/
serverName?: string;

/**
* Alias of {@link serverName} (the `node:tls` spelling). `serverName` wins if both are set.
*/
servername?: string | undefined;

/**
* Sets `OPENSSL_RELEASE_BUFFERS` to 1.
* Reduces overall performance but saves some memory.
Expand Down
23 changes: 20 additions & 3 deletions packages/bun-types/redis.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,26 @@ declare module "bun" {
enableOfflineQueue?: boolean;

/**
* TLS options
*/
tls?: boolean | Bun.TLSOptions;
* TLS options. `true` (or a `rediss://` URL) enables TLS with the default
* options. `serverName` sets the SNI and the name the certificate is
* verified against (by default the URL host).
*/
tls?:
| boolean
| (Bun.TLSOptions & {
/**
* Replaces the built-in check of the server certificate against
* `serverName`, as in `tls.connect()`. It runs once the certificate
* chain is verified, and not at all with `rejectUnauthorized: false`.
* @param hostname The name the certificate is expected to match
* @param cert The server's certificate, with its issuers in `issuerCertificate`
* @returns An error if the server is unauthorized, otherwise undefined. Any
* other truthy value also fails the connection, and a `Promise` is one: the
* function cannot be `async`. Every falsy value approves the certificate, as
* in Node, so `false` does not reject it.
*/
checkServerIdentity?: NonNullable<import("node:tls").ConnectionOptions["checkServerIdentity"]> | undefined;
});
Comment thread
robobun marked this conversation as resolved.

/**
* Whether to enable auto-pipelining
Expand Down
21 changes: 21 additions & 0 deletions packages/bun-types/sql.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,27 @@ declare module "bun" {
onclose?: ((err: Error | null) => void) | undefined;
}

/**
* TLS options for a PostgreSQL or MySQL connection
*/
interface TLSOptions extends Bun.TLSOptions {
/**
* Replaces the built-in check of the server certificate against
* `serverName` (by default the connection's hostname), as in
* `tls.connect()`. It runs once the certificate chain is verified, under
* the `verify-ca` and `verify-full` SSL modes. Like `ca`, setting it
* turns certificate verification on unless an SSL mode or
* `rejectUnauthorized: false` says otherwise.
* @param hostname The name the certificate is expected to match
* @param cert The server's certificate, with its issuers in `issuerCertificate`
* @returns An error if the server is unauthorized, otherwise undefined. Any
* other truthy value also fails the connection, and a `Promise` is one: the
* function cannot be `async`. Every falsy value approves the certificate, as
* in Node, so `false` does not reject it.
*/
checkServerIdentity?: NonNullable<import("node:tls").ConnectionOptions["checkServerIdentity"]> | undefined;
}

interface PostgresOrMySQLOptions {
/**
* Connection URL, for example `postgres://user:pass@localhost:5432/mydb`
Expand Down
6 changes: 6 additions & 0 deletions src/boringssl_sys/boringssl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,12 @@ impl SSL {
}
}

/// Client side: the SNI host name to send. Set it before the handshake; `false` if BoringSSL rejects it.
pub fn set_servername(&mut self, hostname: &core::ffi::CStr) -> bool {
// SAFETY: `self` is a live SSL; BoringSSL copies `hostname`.
unsafe { SSL_set_tlsext_host_name(self, hostname.as_ptr()) == 1 }
}

/// The peer's leaf certificate, borrowed from this SSL's cert chain.
pub fn peer_leaf_certificate(&mut self) -> Option<&mut X509> {
// SAFETY: the chain and its entries are owned by this SSL and outlive
Expand Down
21 changes: 16 additions & 5 deletions src/js/internal/sql/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1874,7 +1874,7 @@ function parseOptions(
let username: string | null | undefined;
let password: string | (() => Bun.MaybePromise<string>) | undefined | null;
let database: string | undefined;
let tls: Bun.TLSOptions | (Bun.BunFile & Bun.TLSOptions) | boolean | undefined;
let tls: Bun.SQL.TLSOptions | (Bun.BunFile & Bun.SQL.TLSOptions) | boolean | undefined;
let query: string = "";
let idleTimeout: number | null | undefined;
let connectionTimeout: number | null | undefined;
Expand Down Expand Up @@ -2148,15 +2148,26 @@ function parseOptions(
}
}

if ($isObject(tls) && sslMode < SSLMode.verify_ca) {
if (tls.rejectUnauthorized === true || (tls.rejectUnauthorized !== false && (tls.ca || tls.caFile))) {
const checkServerIdentity = $isObject(tls) ? tls.checkServerIdentity : undefined;
if ($isObject(tls)) {
const { rejectUnauthorized } = tls;
if (checkServerIdentity !== undefined && !$isCallable(checkServerIdentity)) {
throw $ERR_INVALID_ARG_TYPE("tls.checkServerIdentity", "function", checkServerIdentity);
}
// These options imply certificate verification unless it is explicitly turned off.
if (
sslMode < SSLMode.verify_ca &&
(rejectUnauthorized === true || (rejectUnauthorized !== false && (tls.ca || tls.caFile || checkServerIdentity)))
) {
sslMode = SSLMode.verify_full;
}
}

if (sslMode !== SSLMode.disable && !(tls as Exclude<typeof tls, boolean>)?.serverName) {
const tlsObject = tls as Exclude<typeof tls, boolean>;
if (sslMode !== SSLMode.disable && !tlsObject?.serverName && !tlsObject?.servername) {
if (hostname) {
tls = { ...(tls as Exclude<typeof tls, boolean>), serverName: hostname };
// The spread copies own enumerable properties only: a callback that is a class method would be lost.
tls = { ...tlsObject, checkServerIdentity, serverName: hostname };
} else if (tls) {
tls = true;
}
Expand Down
252 changes: 252 additions & 0 deletions src/jsc/TlsServerIdentity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
//! The peer certificate as JS, and `tls.checkServerIdentity`, for every TLS client whose handshake events run on the JS thread.

use core::ffi::c_int;

use bun_boringssl::c::{SSL, SSL_CTX, X509, X509_STORE, X509_STORE_CTX, struct_stack_st_X509};

use crate::{ErrorCode, JSGlobalObject, JSValue, JsResult};

unsafe extern "C" {
// JSX509Certificate.cpp. Opaque `repr(C)` handles: `&mut`/`&` are ABI-identical to non-null pointers.
safe fn Bun__X509__toJSLegacyEncoding(
cert: &mut X509,
global_object: &JSGlobalObject,
) -> JSValue;

safe fn SSL_is_server(ssl: &SSL) -> c_int;
// The process-wide default root store; up-refs before returning, so
// the caller owns a reference it must release with X509_STORE_free.
Comment thread
robobun marked this conversation as resolved.
fn us_get_shared_default_ca_store() -> *mut X509_STORE;
fn us_ssl_ctx_has_user_ca(ctx: *mut SSL_CTX) -> c_int;
fn X509_STORE_free(store: *mut X509_STORE);
// X509_STORE_CTX lifecycle for issuer lookups; `new` allocates,
// `init` borrows the store, `free` releases. Used to extend the peer
// certificate chain through the local trust store.
Comment thread
robobun marked this conversation as resolved.
fn X509_STORE_CTX_new() -> *mut X509_STORE_CTX;
fn X509_STORE_CTX_init(
ctx: *mut X509_STORE_CTX,
store: *mut X509_STORE,
x509: *mut X509,
chain: *mut struct_stack_st_X509,
) -> c_int;
fn X509_STORE_CTX_free(ctx: *mut X509_STORE_CTX);
// Writes a +1 X509 reference to `*issuer` on success (> 0).
fn X509_STORE_CTX_get1_issuer(
issuer: *mut *mut X509,
ctx: *mut X509_STORE_CTX,
x: *mut X509,
) -> c_int;
// Returns X509_V_OK (0) when `issuer` could have issued `subject`.
fn X509_check_issued(issuer: *mut X509, subject: *mut X509) -> c_int;
}

/// The `tls.getPeerCertificate()`-style object for `cert` (borrowed, not adopted).
pub fn x509_to_legacy_object(cert: &mut X509, global: &JSGlobalObject) -> JsResult<JSValue> {
crate::from_js_host_call(global, || Bun__X509__toJSLegacyEncoding(cert, global))
}

/// `getPeerCertificate(true)`: the peer's certificate, or `undefined` when it sent none.
pub fn peer_certificate_chain(ssl: &mut SSL, global: &JSGlobalObject) -> JsResult<JSValue> {
let ssl_ptr: *mut SSL = ssl;
let mut cert: *mut X509 = core::ptr::null_mut();
if SSL_is_server(ssl) != 0 {
// SSL_get_peer_certificate returns a +1 reference; we must free it.
// SAFETY: `ssl_ptr` is the live SSL behind `ssl`.
cert = unsafe { bun_boringssl::c::SSL_get_peer_certificate(ssl_ptr) };
}
let _guard = scopeguard::guard(cert, |c| {
if !c.is_null() {
// SAFETY: `c` is the +1 X509 reference returned by SSL_get_peer_certificate; we own it.
unsafe { bun_boringssl::c::X509_free(c) };
}
});

// SAFETY: `ssl_ptr` is the live SSL behind `ssl`; the chain and its entries are borrowed from it.
let cert_chain = unsafe { bun_boringssl::c::SSL_get_peer_cert_chain(ssl_ptr) };
let first_cert: *mut X509 = if !cert.is_null() {
cert
} else if !cert_chain.is_null() {
// SAFETY: `cert_chain` is non-null and owned by the SSL.
unsafe { bun_boringssl::c::sk_X509_value(cert_chain, 0) }
} else {
core::ptr::null_mut()
};

if first_cert.is_null() {
return Ok(JSValue::UNDEFINED);
}

// The detailed form returns the whole chain the peer presented, each
// certificate linking to its issuer through `issuerCertificate`, the way
// Node's getPeerCertificate(true) does. SSL_get_peer_cert_chain includes
// the leaf on the client side but not on the server side, where the +1
// peer certificate above is the leaf instead.
Comment thread
robobun marked this conversation as resolved.
let first_obj = x509_to_legacy_object(X509::opaque_mut(first_cert), global)?;
// Link each certificate to its predecessor immediately so every object in
// the chain is reachable from the stack-rooted `first_obj` before the next
// `x509_to_legacy_object` allocation can trigger a GC - a heap-backed Vec<JSValue>
// is not stack-scanned.
Comment thread
robobun marked this conversation as resolved.
let mut prev_obj: JSValue = first_obj;
let mut last_cert: *mut X509 = first_cert;
if !cert_chain.is_null() {
let mut i: usize = if cert.is_null() { 1 } else { 0 };
loop {
// SAFETY: `cert_chain` is non-null and owned by the SSL; out of range yields null.
let next = unsafe { bun_boringssl::c::sk_X509_value(cert_chain, i) };
if next.is_null() {
break;
}
let obj = x509_to_legacy_object(X509::opaque_mut(next), global)?;
prev_obj.put(global, b"issuerCertificate", obj);
prev_obj = obj;
last_cert = next;
i += 1;
}
}

// Extend the chain through the local trust store until a self-issued
// certificate is reached, the way Node's getPeerCertificate(true) walks
// X509_STORE_CTX_get1_issuer to surface the root that completed
// verification even though the peer never sent it.
Comment thread
robobun marked this conversation as resolved.
let mut last_is_self_issued = false;
// SAFETY: the store ctx is created, initialized against the live SSL_CTX's
// store, used only within this scope and freed before returning; every
// issuer returned by get1_issuer is a +1 reference collected in `extras`
// and released after its fields have been copied into JS values and the
// terminal self-issued check has run.
unsafe {
let ssl_ctx = bun_boringssl::c::SSL_get_SSL_CTX(ssl_ptr);
let mut store = bun_boringssl::c::SSL_CTX_get_cert_store(ssl_ctx);
// A context built without an explicit `ca` (and without requestCert,
// which installs the shared roots) carries an empty store and the
// issuer walk would stop at whatever the peer sent. Fall back to the
// process-wide default roots the way Node's per-context store always
// contains the bundled roots. The getter up-refs, so the temporary
// reference is released after the walk.
Comment thread
robobun marked this conversation as resolved.
let mut shared_store: *mut X509_STORE = core::ptr::null_mut();
if store.is_null() || us_ssl_ctx_has_user_ca(ssl_ctx) == 0 {
shared_store = us_get_shared_default_ca_store();
if !shared_store.is_null() {
store = shared_store;
}
}
let store_ctx = X509_STORE_CTX_new();
if !store_ctx.is_null() {
if !store.is_null()
&& X509_STORE_CTX_init(
store_ctx,
store,
core::ptr::null_mut(),
core::ptr::null_mut(),
) == 1
{
let mut extras: Vec<*mut X509> = Vec::new();
// Cap the walk so a cyclic store cannot loop forever.
while extras.len() < 16 && X509_check_issued(last_cert, last_cert) != 0 {
let mut issuer: *mut X509 = core::ptr::null_mut();
if X509_STORE_CTX_get1_issuer(&raw mut issuer, store_ctx, last_cert) <= 0
|| issuer.is_null()
{
break;
}
match x509_to_legacy_object(X509::opaque_mut(issuer), global) {
Ok(obj) => {
prev_obj.put(global, b"issuerCertificate", obj);
prev_obj = obj;
}
Err(e) => {
bun_boringssl::c::X509_free(issuer);
for extra in extras {
bun_boringssl::c::X509_free(extra);
}
X509_STORE_CTX_free(store_ctx);
if !shared_store.is_null() {
X509_STORE_free(shared_store);
}
return Err(e);
}
}
extras.push(issuer);
last_cert = issuer;
}
last_is_self_issued = X509_check_issued(last_cert, last_cert) == 0;
for extra in extras {
bun_boringssl::c::X509_free(extra);
}
}
X509_STORE_CTX_free(store_ctx);
}
if !shared_store.is_null() {
X509_STORE_free(shared_store);
}
}

// A self-issued terminal certificate references itself, like Node.
if last_is_self_issued {
prev_obj.put(global, b"issuerCertificate", prev_obj);
}
Ok(first_obj)
}

/// Runs `callback(hostname, getPeerCertificate(true))` as Node does. `Err` is what the connection fails with.
pub fn check_with_callback(
global: &JSGlobalObject,
callback: JSValue,
ssl: Option<&mut SSL>,
hostname: &[u8],
) -> Result<(), JSValue> {
let js_cert = match ssl {
Some(ssl) => peer_certificate_chain(ssl, global).map_err(|e| global.take_exception(e))?,
None => JSValue::UNDEFINED,
};
if js_cert.is_undefined() {
return Err(global
.err(
ErrorCode::TLS_CERT_ALTNAME_INVALID,
format_args!("The server did not present a certificate"),
)
.to_js());
}
let js_hostname = crate::bun_string_jsc::create_utf8_for_js(global, hostname)
.map_err(|e| global.take_exception(e))?;
let result = {
let _scope = global.bun_vm().enter_event_loop_scope();
callback.call(global, JSValue::UNDEFINED, &[js_hostname, js_cert])
Comment thread
robobun marked this conversation as resolved.
};
let result = result.map_err(|e| global.take_exception(e))?;
// A VM that is stopping calls nobody and answers `undefined`: nobody approved this certificate.
let vm = global.bun_vm();
if !vm.script_allowed() || global.vm().execution_forbidden() || vm.calls_nobody() {
return Err(global
.err(
ErrorCode::TLS_CERT_ALTNAME_INVALID,
format_args!("\"tls.checkServerIdentity\" did not run"),
)
.to_js());
}
verdict_of(global, result)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// What a `tls.checkServerIdentity` function decided, from the value it returned. `Err` is the reason it refused the server.
pub fn verdict_of(global: &JSGlobalObject, returned: JSValue) -> Result<(), JSValue> {
// > Returns <Error> object [...] on failure
// Any object counts: a DOMException or a util.inherits() error is not an ErrorInstance cell.
Comment thread
robobun marked this conversation as resolved.
if returned.is_object() && returned.as_any_promise().is_none() {
return Err(returned);
}
// Like Node, fail on any other truthy value, a Promise included: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1671-L1688
if returned.to_boolean() {
let received = JSGlobalObject::determine_specific_type(global, returned)
.map_err(|e| global.take_exception(e))?;
return Err(global
.err(
ErrorCode::INVALID_RETURN_VALUE,
format_args!(
"Expected undefined or an Error to be returned from the \"tls.checkServerIdentity\" function but got {received}."
),
)
.to_js());
}
// > On success, returns <undefined>
Ok(())
}
2 changes: 2 additions & 0 deletions src/jsc/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,8 @@ pub mod js_property_iterator;
pub mod node_compile_cache;
#[path = "SystemError.rs"]
pub mod system_error;
#[path = "TlsServerIdentity.rs"]
pub mod tls_server_identity;
#[path = "URL.rs"]
pub mod url;
#[path = "VM.rs"]
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/api/BunObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1806,7 +1806,8 @@ fn get_valkey_default_client(global_this: &JSGlobalObject, _: &JSObject) -> JSVa
&global_this.js_thread(vm.root_context()),
&[JSValue::UNDEFINED],
) {
Ok(p) => p,
// No options object, so no `tls.checkServerIdentity` to store.
Ok((p, _)) => p,
Err(jsc::JsError::Thrown) => return JSValue::ZERO,
Err(err) => {
let _ =
Expand Down
Loading
Loading