Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
18 changes: 10 additions & 8 deletions docs/runtime/sql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -622,9 +622,9 @@ const sql = new SQL({
connectionTimeout: 30, // Timeout when establishing new connections

// SSL/TLS options
ssl: "prefer", // or "disable", "require", "verify-ca", "verify-full"
// tls: {
// rejectUnauthorized: true,
ssl: "prefer", // Default. | "disable" | "require" | "verify-ca" | "verify-full"
// tls: { // Setting tls: false disables SSL
// rejectUnauthorized: true, // Default for verify-ca and verify-full
// ca: "path/to/ca.pem",
// key: "path/to/key.pem",
// cert: "path/to/cert.pem",
Expand Down Expand Up @@ -667,9 +667,9 @@ const sql = new SQL({
connectionTimeout: 30, // Timeout when establishing new connections

// SSL/TLS options
tls: true,
// tls: {
// rejectUnauthorized: true,
ssl: "prefer", // Default. | "disable" | "require" | "verify-ca" | "verify-full"
// tls: { // Setting tls: false disables SSL
// rejectUnauthorized: true, // Default for verify-ca and verify-full
// requestCert: true,
// ca: "path/to/ca.pem",
// key: "path/to/key.pem",
Expand Down Expand Up @@ -886,14 +886,16 @@ Bun supports SCRAM-SHA-256 (SASL), MD5, and Clear Text authentication. SASL is r

### SSL Modes Overview

PostgreSQL supports different SSL/TLS modes to control how secure connections are established. These modes determine the behavior when connecting and the level of certificate verification performed.
PostgreSQL supports different SSL/TLS modes to control how secure connections are established. The default mode is `prefer`.

You can disable SSL by setting `ssl: "disable"` or `tls: false`. Note that `tls: false` overrides the default `prefer` mode. However, to prevent accidental security downgrades, `Bun.SQL` will throw an error if you set `tls: false` while explicitly setting a secure `ssl` mode (like `"require"` or `"verify-full"`).

```ts
const sql = new SQL({
hostname: "localhost",
username: "user",
password: "password",
ssl: "disable", // | "prefer" | "require" | "verify-ca" | "verify-full"
ssl: "prefer", // Default. | "disable" | "require" | "verify-ca" | "verify-full"
});
```

Expand Down
12 changes: 6 additions & 6 deletions packages/bun-types/sql.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,17 +323,17 @@ declare module "bun" {
max_lifetime?: number | undefined;

/**
* Whether to use TLS/SSL for the connection
* @default false
* TLS options or boolean toggle. If omitted, TLS is enabled by default
* when sslMode defaults to "prefer".
*/
tls?: Bun.BunFile | TLSOptions | boolean | undefined;

/**
* Whether to use TLS/SSL for the connection (alias for tls)
* @deprecated Prefer {@link tls}
* @default false
* SSL mode string or TLS options. Supports "disable" | "prefer" | "require" | "verify-ca" | "verify-full".
* @deprecated Prefer {@link tls} for TLS options; use {@link ssl} for mode selection.
* @default "prefer"
*/
ssl?: Bun.BunFile | TLSOptions | boolean | undefined;
ssl?: Bun.BunFile | TLSOptions | boolean | "disable" | "prefer" | "require" | "verify-ca" | "verify-full" | undefined;

/**
* Unix domain socket path for connection
Expand Down
5 changes: 4 additions & 1 deletion src/js/internal/sql/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export interface MySQLDotZig {
password: string,
databae: string,
sslmode: SSLMode,
tls: Bun.TLSOptions | boolean | null | Bun.BunFile, // boolean true => empty TLSOptions object `{}`, boolean false or null => nothing
tls: Bun.TLSOptions | boolean | null | Bun.BunFile, // boolean true => empty TLSOptions object `{}`, boolean false => force disable TLS/SSL, null => nothing
query: string,
path: string,
onConnected: (err: Error | null, connection: $ZigGeneratedClasses.MySQLConnection) => void,
Expand Down Expand Up @@ -290,6 +290,9 @@ class PooledMySQLConnection {
// makes no sense from a security point of view, and it only promises
// performance overhead if possible. It is only provided as the default for
// backward compatibility, and is not recommended in secure deployments.
//
// NOTE: Defaulting to 'prefer' is handled in shared.ts/parseOptions.
// We use || disable (0) here to allow the falsy value 0 to pass through.
sslMode || SSLMode.disable,
tls || null,
query || "",
Expand Down
5 changes: 4 additions & 1 deletion src/js/internal/sql/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ export interface PostgresDotZig {
password: string,
databae: string,
sslmode: SSLMode,
tls: Bun.TLSOptions | boolean | null | Bun.BunFile, // boolean true => empty TLSOptions object `{}`, boolean false or null => nothing
tls: Bun.TLSOptions | boolean | null | Bun.BunFile, // boolean true => empty TLSOptions object `{}`, boolean false => force disable TLS/SSL, null => nothing
query: string,
path: string,
onConnected: (err: Error | null, connection: $ZigGeneratedClasses.PostgresSQLConnection) => void,
Expand Down Expand Up @@ -512,6 +512,9 @@ class PooledPostgresConnection {
// makes no sense from a security point of view, and it only promises
// performance overhead if possible. It is only provided as the default for
// backward compatibility, and is not recommended in secure deployments.
//
// NOTE: Defaulting to 'prefer' is handled in shared.ts/parseOptions.
// We use || disable (0) here to allow the falsy value 0 to pass through.
sslMode || SSLMode.disable,
tls || null,
query || "",
Expand Down
92 changes: 77 additions & 15 deletions src/js/internal/sql/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ declare global {
type ArrayType =
| "BOOLEAN"
| "BYTEA"
| "CHAR"
Comment thread
S4N-T0S marked this conversation as resolved.
| "NAME"
| "TEXT"
| "CHAR"
Expand Down Expand Up @@ -584,7 +583,8 @@ function parseOptions(

// The rest of this function is logic specific to postgres/mysql/mariadb (they have the same options object)

let sslMode: SSLMode = sslModeFromConnectionDetails || SSLMode.disable;
// Default to prefer, as standard Postgres clients usually do
let sslMode: SSLMode = sslModeFromConnectionDetails || SSLMode.prefer;

let url = _url;

Expand Down Expand Up @@ -636,6 +636,69 @@ function parseOptions(
query = query.trim();
}

// Handle explicit options.ssl overrides
if (options.ssl !== undefined) {
if (typeof options.ssl === "string") {
sslMode = normalizeSSLMode(options.ssl);
} else if (typeof options.ssl === "boolean") {
sslMode = options.ssl ? SSLMode.require : SSLMode.disable;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

tls = options.tls;

// Support legacy behavior where ssl option is the tls config object
if (!tls && typeof options.ssl === "object" && options.ssl !== null) {
tls = options.ssl as Bun.TLSOptions;
// If passing a config object, imply SSL preference if currently disabled
if (sslMode === SSLMode.disable) {
sslMode = SSLMode.prefer;
}
}

// Handle tls: false interactions
// 1. If the user explicitly sets tls: false, we want to disable SSL.
// 2. However, if they ALSO explicitly set an ssl mode (like 'require'), we should not silently downgrade security.
// 3. If no ssl mode was set, it defaults to 'prefer', which we can safely downgrade to 'disable'.
if (options.tls === false) {
// Avoid silently downgrading an explicit sslmode.
if (sslMode !== SSLMode.prefer && sslMode !== SSLMode.disable) {
throw $ERR_INVALID_ARG_VALUE("tls", false, "conflicts with currently set ssl mode");
}
// Check if ssl option was explicitly passed as something other than disable
if (options.ssl && (options.ssl as any) !== "disable") {
throw $ERR_INVALID_ARG_VALUE("tls", false, "conflicts with currently set ssl mode");
}
sslMode = SSLMode.disable;
tls = false;
}

// If SSL is enabled but no TLS config is provided, default to system defaults (true)
if (sslMode !== SSLMode.disable && !tls) {
tls = true;
}

// Enforce rejectUnauthorized = true for verify modes.
// This ensures the SSL handshake actually performs verification so we can check the result in PostgresSQLConnection.zig.
// We do not strictly require tls.ca here, allowing usage of system CAs.
if (sslMode === SSLMode.verify_ca || sslMode === SSLMode.verify_full) {
if (typeof tls === "object" && tls !== null) {
(tls as Bun.TLSOptions).rejectUnauthorized = true;
}
// If tls is boolean (true), it defaults to rejectUnauthorized: true in the engine.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Compatibility with postgres.js / libpq behavior:
// 'require' and 'prefer' modes do not verify the certificate chain by default.
// They only require that the connection IS encrypted.
if (sslMode === SSLMode.require || sslMode === SSLMode.prefer) {
if (tls === true) {
tls = { rejectUnauthorized: false };
} else if (typeof tls === "object" && tls !== null && (tls as Bun.TLSOptions).rejectUnauthorized === undefined) {
(tls as Bun.TLSOptions).rejectUnauthorized = false;
}
}

switch (adapter) {
case "postgres": {
hostname ||= options.hostname || options.host || env.PG_HOST || env.PGHOST || "localhost";
Expand All @@ -651,6 +714,18 @@ function parseOptions(
}
}

// Inject serverName for SNI and Hostname verification if not already present
if (sslMode !== SSLMode.disable && !tls?.serverName && hostname) {
const isIp = require("node:net").isIP(hostname);
if (!isIp || sslMode === SSLMode.verify_full) {
if (typeof tls === "boolean") {
tls = { serverName: hostname };
} else if (tls) {
tls = { ...tls, serverName: hostname };
}
}
}

switch (adapter) {
case "postgres": {
port ||= Number(options.port || env.PG_PORT || env.PGPORT || "5432");
Expand Down Expand Up @@ -759,7 +834,6 @@ function parseOptions(
}
}

tls ||= options.tls || options.ssl;
max = options.max;

idleTimeout ??= options.idleTimeout;
Expand Down Expand Up @@ -838,18 +912,6 @@ function parseOptions(
}
}

if (sslMode !== SSLMode.disable && !tls?.serverName) {
if (hostname) {
tls = { ...tls, serverName: hostname };
} else if (tls) {
tls = true;
}
}

if (tls && sslMode === SSLMode.disable) {
sslMode = SSLMode.prefer;
}

port = Number(port);

if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
Expand Down
97 changes: 69 additions & 28 deletions src/sql/mysql/MySQLConnection.zig
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ pub fn init(
};
}

pub fn getSSLMode(this: *const @This()) SSLMode {
return this.#ssl_mode;
}
pub fn canPipeline(this: *@This()) bool {
return this.queue.canPipeline(this.getJSConnection());
}
Expand Down Expand Up @@ -115,6 +118,14 @@ pub inline fn enqueueRequest(this: *@This(), request: *JSMySQLQuery) void {
this.queue.add(request);
}

pub fn bufferData(this: *MySQLConnection, data: []const u8) !void {
try this.#read_buffer.write(bun.default_allocator, data);
}

pub fn hasBufferedData(this: *const MySQLConnection) bool {
return this.#read_buffer.remaining().len > 0;
}

pub fn flushQueue(this: *@This()) error{AuthenticationFailed}!void {
this.flushData();
if (!this.#flags.has_backpressure) {
Expand Down Expand Up @@ -229,35 +240,53 @@ pub inline fn isConnected(this: *MySQLConnection) bool {
return this.status == .connected;
}
pub fn doHandshake(this: *MySQLConnection, success: i32, ssl_error: uws.us_bun_verify_error_t) !bool {
debug("onHandshake: {d} {d} {s}", .{ success, ssl_error.error_no, @tagName(this.#ssl_mode) });
// Protect against re-entrant onData calls during handshake writes
const was_processing = this.#flags.is_processing_data;
this.#flags.is_processing_data = true;
defer this.#flags.is_processing_data = was_processing;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
debug("onHandshake: success={d} error={d} mode={s}", .{ success, ssl_error.error_no, @tagName(this.#ssl_mode) });
const handshake_success = if (success == 1) true else false;
this.#sequence_id = this.#sequence_id +% 1;
if (handshake_success) {
this.#tls_status = .ssl_ok;
if (this.#tls_config.reject_unauthorized != 0) {
// follow the same rules as postgres
// https://github.com/porsager/postgres/blob/6ec85a432b17661ccacbdf7f765c651e88969d36/src/connection.js#L272-L279
// only reject the connection if reject_unauthorized == true
switch (this.#ssl_mode) {
.verify_ca, .verify_full => {
if (ssl_error.error_no != 0) {
// https://github.com/porsager/postgres/blob/6ec85a432b17661ccacbdf7f765c651e88969d36/src/connection.js#L272-L279
switch (this.#ssl_mode) {
Comment thread
S4N-T0S marked this conversation as resolved.
.verify_ca => {
if (ssl_error.error_no != 0) {
this.#tls_status = .ssl_failed;
return false;
}
},
.verify_full => {
if (ssl_error.error_no != 0) {
this.#tls_status = .ssl_failed;
return false;
}

const ssl_ptr: *BoringSSL.c.SSL = @ptrCast(this.#socket.getNativeHandle());
if (BoringSSL.c.SSL_get_servername(ssl_ptr, 0)) |servername| {
const hostname = servername[0..bun.len(servername)];
if (!BoringSSL.checkServerIdentity(ssl_ptr, hostname)) {
this.#tls_status = .ssl_failed;
return false;
}

const ssl_ptr: *BoringSSL.c.SSL = @ptrCast(this.#socket.getNativeHandle());
if (BoringSSL.c.SSL_get_servername(ssl_ptr, 0)) |servername| {
const hostname = servername[0..bun.len(servername)];
if (!BoringSSL.checkServerIdentity(ssl_ptr, hostname)) {
this.#tls_status = .ssl_failed;
return false;
}
} else {
this.#tls_status = .ssl_failed;
return false;
}
},
// require is the same as prefer unless reject_unauthorized is set
.require, .prefer, .disable => {
if (this.#tls_config.reject_unauthorized != 0) {
if (ssl_error.error_no != 0) {
this.#tls_status = .ssl_failed;
return false;
}
},
// require is the same as prefer
.require, .prefer, .disable => {},
}
}
},
}

try this.sendHandshakeResponse();
return true;
}
Expand Down Expand Up @@ -290,24 +319,32 @@ pub fn readAndProcessData(this: *MySQLConnection, data: []const u8) !void {
});
}

this.#read_buffer.head = 0;
this.#last_message_start = 0;
this.#read_buffer.byte_list.len = 0;
this.#read_buffer.write(bun.default_allocator, data[offset..]) catch @panic("failed to write to read buffer");
if (this.#read_buffer.remaining().len > 0) {
const remainder = data[offset..];
this.#read_buffer.byte_list.insertSlice(bun.default_allocator, this.#read_buffer.head, remainder) catch @panic("failed to write to read buffer");
} else {
this.#read_buffer.head = 0;
this.#last_message_start = 0;
this.#read_buffer.byte_list.len = 0;
this.#read_buffer.write(bun.default_allocator, data[offset..]) catch @panic("failed to write to read buffer");
}
return;
} else {
if (comptime bun.Environment.allow_assert) {
bun.handleErrorReturnTrace(err, @errorReturnTrace());
}
return err;
}
};
return;
}

{
if (this.#read_buffer.remaining().len == 0) return;
} else {
this.#read_buffer.head = this.#last_message_start;

this.#read_buffer.write(bun.default_allocator, data) catch @panic("failed to write to read buffer");
}

{
this.processPackets(Reader, this.bufferedReader()) catch |err| {
debug("processPackets with buffer: {s}", .{@errorName(err)});
if (err != error.ShortRead) {
Expand Down Expand Up @@ -906,14 +943,18 @@ pub fn handlePreparedStatement(this: *MySQLConnection, comptime Context: type, r
debug("handlePreparedStatement ERROR", .{});
var err = ErrorPacket{};
try err.decode(reader);
defer err.deinit();
var is_error_owned = true;
defer {
if (is_error_owned) err.deinit();
}
const connection = this.getJSConnection();
defer {
this.queue.advance(connection);
}
this.#flags.is_ready_for_query = true;
statement.status = .failed;
statement.error_response = err;
is_error_owned = false;
this.queue.markAsReadyForQuery();
this.queue.markCurrentRequestAsFinished(request);

Expand Down
Loading