diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 2f4f71b61e51..613e4ab0f2cc 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -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", @@ -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", @@ -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" }); ``` diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index 59681350ffb0..a2111d136785 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -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 diff --git a/src/js/internal/sql/mysql.ts b/src/js/internal/sql/mysql.ts index ef6f33bf788f..3c9b2453a8bc 100644 --- a/src/js/internal/sql/mysql.ts +++ b/src/js/internal/sql/mysql.ts @@ -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, @@ -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 || "", diff --git a/src/js/internal/sql/postgres.ts b/src/js/internal/sql/postgres.ts index af4502cd9ad3..7232aaf6c02e 100644 --- a/src/js/internal/sql/postgres.ts +++ b/src/js/internal/sql/postgres.ts @@ -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, @@ -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 || "", diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index fc484b00c77a..16b8e18546b0 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -10,7 +10,6 @@ declare global { type ArrayType = | "BOOLEAN" | "BYTEA" - | "CHAR" | "NAME" | "TEXT" | "CHAR" @@ -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; @@ -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; + } + } + + 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. + } + + // 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"; @@ -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"); @@ -759,7 +834,6 @@ function parseOptions( } } - tls ||= options.tls || options.ssl; max = options.max; idleTimeout ??= options.idleTimeout; @@ -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) { diff --git a/src/sql/mysql/MySQLConnection.zig b/src/sql/mysql/MySQLConnection.zig index 5a4fc375db92..ac62a73b5a08 100644 --- a/src/sql/mysql/MySQLConnection.zig +++ b/src/sql/mysql/MySQLConnection.zig @@ -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()); } @@ -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) { @@ -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; + + 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) { + .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; } @@ -290,10 +319,16 @@ 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()); @@ -301,13 +336,15 @@ pub fn readAndProcessData(this: *MySQLConnection, data: []const u8) !void { 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) { @@ -906,7 +943,10 @@ 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); @@ -914,6 +954,7 @@ pub fn handlePreparedStatement(this: *MySQLConnection, comptime Context: type, r 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); diff --git a/src/sql/mysql/js/JSMySQLConnection.zig b/src/sql/mysql/js/JSMySQLConnection.zig index a85bc7519f5d..adf6ff9df309 100644 --- a/src/sql/mysql/js/JSMySQLConnection.zig +++ b/src/sql/mysql/js/JSMySQLConnection.zig @@ -222,7 +222,7 @@ fn SocketHandler(comptime ssl: bool) type { this.#connection.setSocket(socket); if (socket == .SocketTCP) { - // This handshake is not TLS handleshake is actually the MySQL handshake + // This handshake is not TLS handshake is actually the MySQL handshake // When a connection is upgraded to TLS, the onOpen callback is called again and at this moment we dont wanna to change the status to handshaking this.#connection.status = .handshaking; this.ref(); // keep a ref for the socket @@ -235,13 +235,45 @@ fn SocketHandler(comptime ssl: bool) type { fn onHandshake_( this: *JSMySQLConnection, - _: anytype, + s: SocketType, success: i32, ssl_error: uws.us_bun_verify_error_t, ) void { + // Handshake verification logic is handled inside this.#connection.doHandshake + // We just need to handle the result and report specific errors if it fails. const handshakeWasSuccessful = this.#connection.doHandshake(success, ssl_error) catch |err| return this.failFmt(err, "Failed to send handshake response", .{}); + if (!handshakeWasSuccessful) { + // If the socket handshake succeeded (error_no == 0) but doHandshake returned false, + // it implies a logic validation failure (like hostname mismatch in verify-full). + if (ssl_error.error_no == 0 and this.#connection.getSSLMode() == .verify_full) { + const ssl_ptr: *bun.BoringSSL.c.SSL = @ptrCast(s.getNativeHandle()); + if (bun.BoringSSL.c.SSL_get_servername(ssl_ptr, 0)) |servername| { + const hostname = servername[0..bun.len(servername)]; + if (!bun.BoringSSL.checkServerIdentity(ssl_ptr, hostname)) { + this.failFmt(error.AuthenticationFailed, "Hostname/IP does not match certificate's altnames: Host: {s} is not in the cert's list.", .{hostname}); + return; + } + } else { + this.fail("Unable to verify server identity: server name missing", error.AuthenticationFailed); + return; + } + } + + if (ssl_error.error_no == 0) { + this.fail("SSL/TLS verification failed", error.AuthenticationFailed); + return; + } + + // Fallback to standard SSL error reporting this.failWithJSValue(ssl_error.toJS(this.#globalObject) catch return); + return; + } + + if (!this.#connection.isProcessingData() and this.#connection.hasBufferedData()) { + this.#connection.readAndProcessData("") catch |err| { + this.onError(null, err); + }; } } @@ -267,6 +299,13 @@ fn SocketHandler(comptime ssl: bool) type { } pub fn onData(this: *JSMySQLConnection, _: SocketType, data: []const u8) void { + if (this.#connection.isProcessingData()) { + this.#connection.bufferData(data) catch |err| { + this.onError(null, err); + }; + return; + } + this.ref(); defer this.deref(); const vm = this.#vm; @@ -544,10 +583,7 @@ pub fn doFlush(this: *@This(), _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JS pub fn doClose(this: *@This(), globalObject: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue { _ = globalObject; - this.stopTimers(); - - defer this.updateReferenceType(); - this.#connection.cleanQueueAndClose(null, this.getQueriesArray()); + this.close(); return .js_undefined; } diff --git a/src/sql/postgres/PostgresSQLConnection.zig b/src/sql/postgres/PostgresSQLConnection.zig index 0cdbf71600d4..e4f4263dac01 100644 --- a/src/sql/postgres/PostgresSQLConnection.zig +++ b/src/sql/postgres/PostgresSQLConnection.zig @@ -169,11 +169,13 @@ pub fn setOnClose(_: *PostgresSQLConnection, thisValue: jsc.JSValue, globalObjec } pub fn setupTLS(this: *PostgresSQLConnection) void { - debug("setupTLS", .{}); + debug("setupTLS: upgrading socket...", .{}); const new_socket = this.socket.SocketTCP.socket.connected.upgrade(this.tls_ctx.?, this.tls_config.server_name) orelse { + debug("setupTLS: upgrade failed", .{}); this.fail("Failed to upgrade to TLS", error.TLSUpgradeFailed); return; }; + debug("setupTLS: upgrade success", .{}); this.socket = .{ .SocketTLS = .{ .socket = .{ @@ -181,9 +183,8 @@ pub fn setupTLS(this: *PostgresSQLConnection) void { }, }, }; - - this.start(); } + fn setupMaxLifetimeTimerIfNecessary(this: *PostgresSQLConnection) void { if (this.max_lifetime_interval_ms == 0) return; if (this.max_lifetime_timer.state == .ACTIVE) return; @@ -409,6 +410,15 @@ pub fn onOpen(this: *PostgresSQLConnection, socket: uws.AnySocket) void { this.poll_ref.ref(this.vm); this.updateHasPendingActivity(); + // Protect against re-entrant onData calls during writes in onOpen (Windows/IOCP) + this.flags.is_processing_data = true; + defer { + this.flags.is_processing_data = false; + if (this.read_buffer.remaining().len > 0) { + this.onData(""); + } + } + if (this.tls_status == .message_sent or this.tls_status == .pending) { this.startTLS(socket); return; @@ -418,31 +428,46 @@ pub fn onOpen(this: *PostgresSQLConnection, socket: uws.AnySocket) void { } pub fn onHandshake(this: *PostgresSQLConnection, success: i32, ssl_error: uws.us_bun_verify_error_t) void { - debug("onHandshake: {d} {d}", .{ success, ssl_error.error_no }); + 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; if (handshake_success) { - if (this.tls_config.reject_unauthorized != 0) { - // only reject the connection if reject_unauthorized == true - switch (this.ssl_mode) { - // https://github.com/porsager/postgres/blob/6ec85a432b17661ccacbdf7f765c651e88969d36/src/connection.js#L272-L279 + switch (this.ssl_mode) { + // https://github.com/porsager/postgres/blob/6ec85a432b17661ccacbdf7f765c651e88969d36/src/connection.js#L272-L279 - .verify_ca, .verify_full => { + .verify_ca => { + if (ssl_error.error_no != 0) { + this.failWithJSValue(ssl_error.toJS(this.globalObject) catch return); + return; + } + }, + .verify_full => { + if (ssl_error.error_no != 0) { + this.failWithJSValue(ssl_error.toJS(this.globalObject) catch return); + return; + } + + 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.failFmt("ERR_TLS_CERT_ALTNAME_INVALID", "Hostname/IP does not match certificate's altnames: Host: {s} is not in the cert's list.", .{hostname}); + return; + } + } else { + this.fail("Unable to verify server identity: server name missing", error.TLSUpgradeFailed); + return; + } + }, + // require is the same as prefer in terms of "if handshake succeeded, we are good" + // unless reject_unauthorized is strictly set in tls config + .require, .prefer, .disable => { + if (this.tls_config.reject_unauthorized != 0) { if (ssl_error.error_no != 0) { this.failWithJSValue(ssl_error.toJS(this.globalObject) catch return); return; } - - 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.failWithJSValue(ssl_error.toJS(this.globalObject) catch return); - } - } - }, - // require is the same as prefer - .require, .prefer, .disable => {}, - } + } + }, } } else { // if we are here is because server rejected us, and the error_no is the cause of this @@ -489,10 +514,62 @@ fn drainInternal(this: *PostgresSQLConnection) void { } pub fn onData(this: *PostgresSQLConnection, data: []const u8) void { + if (this.flags.is_processing_data) { + this.read_buffer.write(bun.default_allocator, data) catch @panic("failed to buffer data"); + return; + } + this.ref(); this.flags.is_processing_data = true; const vm = this.vm; + debug("onData: len={d} tls_status={s}", .{ data.len, @tagName(this.tls_status) }); + if (data.len > 0) debug("onData: first_byte='{c}' (0x{x})", .{ data[0], data[0] }); + + if (this.tls_status == .message_sent) { + defer { + this.flags.is_processing_data = false; + if (this.read_buffer.remaining().len > 0) { + this.onData(""); + } + this.deref(); + } + + var input = data; + if (input.len == 0 and this.read_buffer.remaining().len > 0) { + input = this.read_buffer.remaining(); + // consume the byte we are about to check + this.read_buffer.head += 1; + this.last_message_start = this.read_buffer.head; + } + + if (input.len == 0) return; + + switch (input[0]) { + 'S' => { + debug("onData: Server accepted SSL. Upgrading...", .{}); + this.tls_status = .none; + this.setupTLS(); + return; + }, + 'N' => { + debug("onData: Server rejected SSL.", .{}); + if (this.ssl_mode != .prefer) { + this.fail("The server does not support SSL connections", error.TLSUpgradeFailed); + return; + } + this.tls_status = .none; + this.start(); + return; + }, + else => { + debug("onData: Unexpected response during SSL handshake.", .{}); + this.fail("Failed to upgrade to SSL", error.TLSUpgradeFailed); + return; + }, + } + } + this.disableConnectionTimeout(); defer { if (this.status == .connected and !this.hasQueryRunning() and this.write_buffer.remaining().len == 0) { @@ -516,6 +593,7 @@ pub fn onData(this: *PostgresSQLConnection, data: []const u8) void { // reset the head to the last message so remaining reflects the right amount of bytes this.read_buffer.head = this.last_message_start; + var processed_via_stack = false; if (this.read_buffer.remaining().len == 0) { var consumed: usize = 0; var offset: usize = 0; @@ -534,33 +612,38 @@ pub fn onData(this: *PostgresSQLConnection, data: []const u8) void { 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"); - } else { - bun.handleErrorReturnTrace(err, @errorReturnTrace()); + return; + } + + bun.handleErrorReturnTrace(err, @errorReturnTrace()); + this.fail("Failed to read data", err); + }; + processed_via_stack = true; + } + + if (!processed_via_stack) { + // read buffer is not empty, so we need to write the data to the buffer and then read it + this.read_buffer.write(bun.default_allocator, data) catch @panic("failed to write to read buffer"); + } + if (this.read_buffer.remaining().len > 0) { + PostgresRequest.onData(this, Reader, this.bufferedReader()) catch |err| { + if (err != error.ShortRead) { + bun.handleErrorReturnTrace(err, @errorReturnTrace()); this.fail("Failed to read data", err); + return; } + + if (comptime bun.Environment.allow_assert) { + debug("read_buffer: not empty and received short read: last_message_start: {d}, head: {d}, len: {d}", .{ + this.last_message_start, + this.read_buffer.head, + this.read_buffer.byte_list.len, + }); + } + return; }; - // no need to reset anything, its already empty - return; } - // read buffer is not empty, so we need to write the data to the buffer and then read it - this.read_buffer.write(bun.default_allocator, data) catch @panic("failed to write to read buffer"); - PostgresRequest.onData(this, Reader, this.bufferedReader()) catch |err| { - if (err != error.ShortRead) { - bun.handleErrorReturnTrace(err, @errorReturnTrace()); - this.fail("Failed to read data", err); - return; - } - - if (comptime bun.Environment.allow_assert) { - debug("read_buffer: not empty and received short read: last_message_start: {d}, head: {d}, len: {d}", .{ - this.last_message_start, - this.read_buffer.head, - this.read_buffer.byte_list.len, - }); - } - return; - }; debug("clean read_buffer", .{}); // success, we read everything! let's reset the last message start and the head @@ -1606,7 +1689,7 @@ pub fn on(this: *PostgresSQLConnection, comptime MessageType: @Type(.enum_litera }, .SASLFinal => |final| { if (this.authentication_state != .SASL) { - debug("SASLFinal - Unexpected SASLContinue for authentiation state: {s}", .{@tagName(std.meta.activeTag(this.authentication_state))}); + debug("SASLFinal - Unexpected SASLContinue for authentication state: {s}", .{@tagName(std.meta.activeTag(this.authentication_state))}); return error.UnexpectedMessage; } var sasl = &this.authentication_state.SASL; diff --git a/test/js/sql/ssl-postgres-behavior-verification.test.ts b/test/js/sql/ssl-postgres-behavior-verification.test.ts new file mode 100644 index 000000000000..3b1b28242578 --- /dev/null +++ b/test/js/sql/ssl-postgres-behavior-verification.test.ts @@ -0,0 +1,145 @@ +import { SQL } from "bun"; +import { describe, expect, test } from "bun:test"; +import { describeWithContainer, isDockerEnabled } from "harness"; + +if (!isDockerEnabled()) { + test.skip("skipping TLS SQL compatibility tests - Docker is not available", () => {}); +} else { + describeWithContainer( + "PostgreSQL TLS Compatibility", // https://github.com/porsager/postgres/blob/6ec85a432b17661ccacbdf7f765c651e88969d36/src/connection.js#L272-L279 + { + image: "postgres_tls", + }, + container => { + // We test with prepared statements on and off to ensure the connection logic + // remains consistent regardless of the query execution mode. + for (const prepare of [true, false]) { + describe(`prepared: ${prepare}`, () => { + const getBaseOptions = (): Bun.SQL.Options => ({ + url: `postgres://postgres@${container.host}:${container.port}/bun_sql_test`, + adapter: "postgres", + max: 1, + prepare, + }); + + test("ssl: 'prefer' connects successfully with snakeoil cert", async () => { + await container.ready; + // 'prefer' is the default behaviour for postgres.js and libpq. + // It should attempt SSL, see the self-signed cert, and proceed without strict verification + // unless strict mode is explicitly requested. + await using sql = new SQL({ + ...getBaseOptions(), + ssl: "prefer", + }); + + const [{ one }] = await sql`SELECT 1 as one`; + expect(one).toBe(1); + }); + + test("ssl: 'require' connects successfully with snakeoil cert (loose default)", async () => { + await container.ready; + // The user requested compatibility with postgres.js behaviour: + // "even on require if reject_unauthorized is not set then we should still connect." + // This implies rejectUnauthorized defaults to false in this context. + await using sql = new SQL({ + ...getBaseOptions(), + ssl: "require", + }); + + const [{ one }] = await sql`SELECT 1 as one`; + expect(one).toBe(1); + }); + + test("ssl: 'require' with rejectUnauthorized: false connects successfully", async () => { + await container.ready; + await using sql = new SQL({ + ...getBaseOptions(), + ssl: "require", + tls: { + rejectUnauthorized: false, + }, + }); + + const [{ one }] = await sql`SELECT 1 as one`; + expect(one).toBe(1); + }); + + test("ssl: 'require' with rejectUnauthorized: true throws on snakeoil cert", async () => { + await container.ready; + // When explicitly enforcing strict verification, a self-signed cert should fail. + await using sql = new SQL({ + ...getBaseOptions(), + ssl: "require", + tls: { + rejectUnauthorized: true, + }, + }); + + let error; + try { + await sql`SELECT 1`; + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + // Depending on where the error is caught (TLS layer or Postgres layer), + // it should be an instance of Error or SQL.Error. + // We check that connection failed specifically. + expect(error).toBeInstanceOf(Error); + }); + + test("ssl: 'verify-ca' throws without CA provided", async () => { + await container.ready; + // 'verify-ca' implies rejectUnauthorized: true and requires a trusted CA. + // Since we haven't provided the root CA for the snakeoil cert, this must fail. + await using sql = new SQL({ + ...getBaseOptions(), + ssl: "verify-ca", + }); + + let error; + try { + await sql`SELECT 1`; + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + }); + + test("ssl: 'verify-full' throws on host mismatch/untrusted cert", async () => { + await container.ready; + // 'verify-full' checks both the CA and the hostname. + await using sql = new SQL({ + ...getBaseOptions(), + ssl: "verify-full", + }); + + let error; + try { + await sql`SELECT 1`; + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + }); + + test("tls: true alias works like ssl: 'require' (loose)", async () => { + await container.ready; + // Setting tls: true in Bun is often synonymous with enabling SSL. + // It should mimic the 'require' loose behaviour. + await using sql = new SQL({ + ...getBaseOptions(), + tls: true, + }); + + const [{ one }] = await sql`SELECT 1 as one`; + expect(one).toBe(1); + }); + }); + } + }, + ); +} diff --git a/test/js/sql/ssl-postgres-handshake.test.ts b/test/js/sql/ssl-postgres-handshake.test.ts new file mode 100644 index 000000000000..cbbf6f843b73 --- /dev/null +++ b/test/js/sql/ssl-postgres-handshake.test.ts @@ -0,0 +1,262 @@ +import { SQL } from "bun"; +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; + +// Postgres Wire Protocol Constants +const SSL_REQUEST_CODE = 80877103; // 0x04D2162F +const PROTOCOL_V3_CODE = 196608; // 0x00030000 + +describe("PostgreSQL SSL Handshake (Mock Server)", () => { + const HOST = "127.0.0.1"; + let PORT: number; + + let server: import("bun").Server; + let events: string[] = []; + + // Map to buffer incoming data per connection to handle TCP fragmentation + const connections = new Map(); + + // Mock Server Setup + beforeAll(() => { + server = Bun.listen({ + hostname: HOST, + port: 0, + socket: { + open(socket) { + // Initialise buffer for new connection + connections.set(socket, Buffer.alloc(0)); + }, + close(socket) { + connections.delete(socket); + }, + error(socket) { + connections.delete(socket); + }, + data(socket, data) { + // Append new data to the existing buffer for this socket + let buffer = connections.get(socket) || Buffer.alloc(0); + buffer = Buffer.concat([buffer, data]); + + // Loop to process all complete messages in the buffer + while (true) { + // Need at least 4 bytes to determine message length/type + // Note: Startup/SSLRequest has length at offset 0. + // Regular messages have type at 0 and length at 1. + if (buffer.length < 4) break; + + const firstByte = buffer[0]; + let msgLength = 0; + let totalNeeded = 0; + let isStartup = false; + + // Packet Types that start with 0 length (Startup, SSLRequest) + // Note: Standard Postgres packets start with a Type char (e.g. 'P', 'Q'). + // 0 is not a valid type char, but is the first byte of the length (Int32BE) + // for Startup/SSLRequest packets (which are < 16MB). + if (firstByte === 0) { + isStartup = true; + msgLength = buffer.readInt32BE(0); + totalNeeded = msgLength; + } else { + // Regular Message: Type (1 byte) + Length (4 bytes) + Body + // Note: The length field includes the 4 bytes of the length itself. + if (buffer.length < 5) break; + msgLength = buffer.readInt32BE(1); + totalNeeded = 1 + msgLength; + } + + // If we don't have the full message yet, wait for more data + if (buffer.length < totalNeeded) break; + + // Extract the complete message frame + const frame = buffer.subarray(0, totalNeeded); + buffer = buffer.subarray(totalNeeded); + + // Process the frame + if (isStartup) { + if (frame.length >= 8) { + const code = frame.readInt32BE(4); + + if (code === SSL_REQUEST_CODE) { + events.push("SSLRequest"); + socket.write(new TextEncoder().encode("N")); + continue; + } + if (code === PROTOCOL_V3_CODE) { + events.push("StartupMessage"); + // 1. AuthenticationOK (R, len 8, status 0) + const authOk = new Uint8Array([82, 0, 0, 0, 8, 0, 0, 0, 0]); + // 2. ReadyForQuery (Z, len 5, status I) + const ready = new Uint8Array([90, 0, 0, 0, 5, 73]); + socket.write(authOk); + socket.write(ready); + continue; + } + } + } else { + // Regular packets + const type = frame[0]; + + // Handle Extended Query Protocol (Default in Bun) + // 'P' (80) = Parse. Bun sends Parse/Bind/Describe/Execute/Sync in a pipeline. + // We detect the start of this pipeline and send a "Success" response chain. + if (type === 80) { + events.push("Query"); + + // Construct Response Sequence: + // 1. ParseComplete ('1') + const parseComplete = new Uint8Array([49, 0, 0, 0, 4]); + // 2. BindComplete ('2') + const bindComplete = new Uint8Array([50, 0, 0, 0, 4]); + // 3. NoData ('n') - Response to Describe (claiming no rows to keep mock simple) + const noData = new Uint8Array([110, 0, 0, 0, 4]); + + // 4. CommandComplete ('C') - "SELECT 1" + const tag = new TextEncoder().encode("SELECT 1"); + const len = 4 + tag.length + 1; + const cmdComplete = new Uint8Array(1 + len); + cmdComplete[0] = 67; // 'C' + const view = new DataView(cmdComplete.buffer); + view.setInt32(1, len, false); // BigEndian + cmdComplete.set(tag, 5); + + // 5. ReadyForQuery ('Z') + const ready = new Uint8Array([90, 0, 0, 0, 5, 73]); + + // Write all responses + socket.write(parseComplete); + socket.write(bindComplete); + socket.write(noData); + socket.write(cmdComplete); + socket.write(ready); + continue; + } + + // Handle Pipeline Messages: Bind(66), Describe(68), Execute(69), Flush(72), Sync(83) + // The 'P' handler above acts as a simplified mock that responds to the whole pipeline. + // We silence these subsequent messages so they don't appear as "Unknown". + if (type === 66 || type === 68 || type === 69 || type === 72 || type === 83) { + continue; + } + + // Handle Legacy Simple Query ('Q') - kept for completeness + if (type === 81) { + events.push("Query"); + const tag = new TextEncoder().encode("SELECT 1"); + const len = 4 + tag.length + 1; + const cmdComplete = new Uint8Array(1 + len); + cmdComplete[0] = 67; // 'C' + const view = new DataView(cmdComplete.buffer); + view.setInt32(1, len, false); + cmdComplete.set(tag, 5); + + const ready = new Uint8Array([90, 0, 0, 0, 5, 73]); + socket.write(cmdComplete); + socket.write(ready); + continue; + } + + // Terminate ('X') - ignore + if (type === 88) continue; + + // Log unknown packets + events.push(`Unknown:${type}`); + } + } + + // Update buffer with remaining data + connections.set(socket, buffer); + }, + }, + }); + + // Capture the port assigned by the OS + PORT = server.port; + }); + + afterAll(() => { + server.stop(); + }); + + beforeEach(() => { + events = []; + }); + + // Helper to instantiate SQL and run one query + async function connect(config: any) { + let sql: SQL | undefined; + try { + sql = new SQL({ + ...config, + url: `postgres://postgres:postgres@${HOST}:${PORT}/postgres`, + max: 1, + idleTimeout: 1, + connectionTimeout: 1000, + }); + + await sql`SELECT 1`; + return { success: true, error: null }; + } catch (e: any) { + return { success: false, error: e }; + } finally { + if (sql) await sql.close(); + } + } + + // Tests + + test("Default (No Options) -> Prefer (SSLRequest -> Fallback -> Startup)", async () => { + const { success, error } = await connect({}); + expect(success).toBe(true); + expect(events).toEqual(["SSLRequest", "StartupMessage", "Query"]); + }); + + test("SSL: disable -> Only StartupMessage", async () => { + const { success } = await connect({ ssl: "disable" }); + expect(success).toBe(true); + expect(events).toEqual(["StartupMessage", "Query"]); + }); + + test("TLS: false -> Only StartupMessage", async () => { + const { success } = await connect({ tls: false }); + expect(success).toBe(true); + expect(events).toEqual(["StartupMessage", "Query"]); + }); + + test("SSL: disable, TLS: true -> Only StartupMessage (SSL takes precedence)", async () => { + const { success } = await connect({ ssl: "disable", tls: true }); + expect(success).toBe(true); + expect(events).toEqual(["StartupMessage", "Query"]); + }); + + test("SSL: prefer -> SSLRequest -> StartupMessage", async () => { + const { success } = await connect({ ssl: "prefer" }); + expect(success).toBe(true); + expect(events).toEqual(["SSLRequest", "StartupMessage", "Query"]); + }); + + test("SSL: require -> Fails on 'N' response", async () => { + const { success, error } = await connect({ ssl: "require" }); + expect(success).toBe(false); + expect(events).toEqual(["SSLRequest"]); + expect(error.message).toContain("The server does not support SSL connections"); + }); + + test("TLS: true -> Acts as Prefer (Defaults)", async () => { + const { success } = await connect({ tls: true }); + expect(success).toBe(true); + expect(events).toEqual(["SSLRequest", "StartupMessage", "Query"]); + }); + + test("SSL: require, TLS: false -> Fails config validation", async () => { + const { success, error } = await connect({ ssl: "require", tls: false }); + expect(success).toBe(false); + expect(events).toEqual([]); + expect(error.message).toContain("conflicts with currently set ssl mode"); + }); + + test("SSL: verify-ca (No CA) -> Fails before handshake", async () => { + const { success } = await connect({ ssl: "verify-ca" }); + expect(success).toBe(false); + expect(events).not.toContain("StartupMessage"); + }); +});