From 70f3ecf5a5ce1a1de90b0d0791552ee4a19aa8b2 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 19 Jul 2026 13:58:52 +0000 Subject: [PATCH 1/4] sql(postgres): reject out-of-range JS numbers bound to int4 parameters write_bind encoded int4 parameter values with coerce::, which saturates values outside the i32 range to INT32_MIN/INT32_MAX. Binding 2147483648 or 4294967297 to an int4 column silently wrote 2147483647, whereas every other client sends the value as text and receives 'integer out of range' (22003) from the server. Coerce to i64 first and return AnyPostgresError::Overflow when the value does not fit in an i32, so the query rejects with ERR_POSTGRES_OVERFLOW instead of persisting the wrong integer. --- src/sql_jsc/postgres/PostgresRequest.rs | 14 +- .../sql/postgres-int4-param-overflow.test.ts | 128 ++++++++++++++++++ 2 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 test/js/sql/postgres-int4-param-overflow.test.ts diff --git a/src/sql_jsc/postgres/PostgresRequest.rs b/src/sql_jsc/postgres/PostgresRequest.rs index dfed47bee818..b48ed7d184b8 100644 --- a/src/sql_jsc/postgres/PostgresRequest.rs +++ b/src/sql_jsc/postgres/PostgresRequest.rs @@ -199,14 +199,14 @@ pub(crate) fn write_bind( writer.write(bytes)?; l.write_excluding_self()?; } - types::Tag::int4 => { + types::Tag::int4 | types::Tag::int4_array => { + // coerce:: saturates on overflow, which silently stores the + // wrong value. Range-check via i64 so an out-of-range value + // surfaces as an error instead. + let n = value.coerce::(global).map_err(js_error_to_postgres)?; + let n = i32::try_from(n).map_err(|_| AnyPostgresError::Overflow)?; let l = writer.length()?; - writer.int4(value.coerce::(global).map_err(js_error_to_postgres)? as u32)?; - l.write_excluding_self()?; - } - types::Tag::int4_array => { - let l = writer.length()?; - writer.int4(value.coerce::(global).map_err(js_error_to_postgres)? as u32)?; + writer.int4(n as u32)?; l.write_excluding_self()?; } types::Tag::float8 => { diff --git a/test/js/sql/postgres-int4-param-overflow.test.ts b/test/js/sql/postgres-int4-param-overflow.test.ts new file mode 100644 index 000000000000..4768686c7deb --- /dev/null +++ b/test/js/sql/postgres-int4-param-overflow.test.ts @@ -0,0 +1,128 @@ +// Fault-injection test: requires a mock server so we can observe the exact +// int4 bytes Bun writes in the Bind message. DO NOT COPY THIS PATTERN for +// anything a real server can produce; see describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts. +// +// Binding a JS number outside the i32 range to an int4 parameter used to +// silently saturate to INT32_MIN/INT32_MAX (via the saturating f64 -> i32 +// coercion in write_bind) and store the wrong value. A real PostgreSQL server +// rejects the same literal with "integer out of range" (SQLSTATE 22003), so +// every other client surfaces a loud error while Bun quietly persisted the +// saturated value instead. +import { SQL } from "bun"; +import { afterAll, expect, test } from "bun:test"; +import { + listeningServer, + pgAuthenticationOk, + pgBindComplete, + pgCommandComplete, + pgParameterDescription, + pgParseComplete, + pgReadFrontendMessages, + pgReadyForQuery, + pgRowDescription, +} from "./wire-frames"; + +const INT4 = 23; + +// Read the first parameter value out of a Bind body as a 4-byte big-endian +// i32, or null if the value length is not 4 (text format / NULL). +function bindFirstInt4(body: Buffer): number | null { + let o = body.indexOf(0) + 1; // portal name + o = body.indexOf(0, o) + 1; // statement name + const nFmt = body.readInt16BE(o); + o += 2 + 2 * nFmt; // format codes + o += 2; // nParams + const len = body.readInt32BE(o); + o += 4; + return len === 4 ? body.readInt32BE(o) : null; +} + +// Capture the value Bun bound for $1 on the most recent Bind. `undefined` means +// no Bind arrived at all (the client rejected before writing it). +let lastBoundInt4: number | null | undefined; + +const { port, server } = await listeningServer(socket => { + socket.on("error", () => {}); + let pending = Buffer.alloc(0); + let sawStartup = false; + socket.on("data", chunk => { + pending = Buffer.concat([pending, chunk]); + if (!sawStartup) { + if (pending.length < 4) return; + const len = pending.readInt32BE(0); + if (pending.length < len) return; + pending = pending.subarray(len); + sawStartup = true; + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); + } + pending = pgReadFrontendMessages(pending, (type, body) => { + if (type === 0x50 /* 'P' Parse */) { + socket.write(pgParseComplete()); + } else if (type === 0x44 /* 'D' Describe */) { + socket.write( + Buffer.concat([pgParameterDescription([INT4]), pgRowDescription([{ name: "n", typeOid: INT4, format: 1 }])]), + ); + } else if (type === 0x42 /* 'B' Bind */) { + lastBoundInt4 = bindFirstInt4(body); + socket.write(pgBindComplete()); + } else if (type === 0x45 /* 'E' Execute */) { + socket.write(pgCommandComplete("SELECT 0")); + } else if (type === 0x53 /* 'S' Sync */) { + socket.write(pgReadyForQuery()); + } + }); + }); +}); +afterAll(() => new Promise(r => server.close(() => r()))); + +async function bind(n: number | bigint) { + lastBoundInt4 = undefined; + await using sql = new SQL({ + adapter: "postgres", + hostname: "127.0.0.1", + port, + username: "u", + database: "db", + tls: false, + max: 1, + idleTimeout: 5, + connectionTimeout: 5, + }); + await sql`SELECT ${n}::int4 AS n`.values(); +} + +const overflowing = [ + { label: "2^31", value: 2147483648 }, + { label: "2^32 + 1", value: 4294967297 }, + { label: "-(2^31 + 1)", value: -2147483649 }, + { label: "Number.MAX_SAFE_INTEGER", value: Number.MAX_SAFE_INTEGER }, + { label: "Infinity", value: Infinity }, + { label: "-Infinity", value: -Infinity }, +]; + +test.each(overflowing)("binding $label to an int4 parameter rejects instead of saturating", async ({ value }) => { + let error: any; + try { + await bind(value); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error?.code ?? error?.message).toMatch(/ERR_POSTGRES_OVERFLOW|Overflow/); + // The Bind message must not have reached the server carrying a saturated i32. + expect(lastBoundInt4).not.toBe(2147483647); + expect(lastBoundInt4).not.toBe(-2147483648); +}); + +test("binding INT32_MAX / INT32_MIN to an int4 parameter still works", async () => { + await bind(2147483647); + expect(lastBoundInt4).toBe(2147483647); + await bind(-2147483648); + expect(lastBoundInt4).toBe(-2147483648); +}); + +test("binding 0 to an int4 parameter still works", async () => { + await bind(0); + expect(lastBoundInt4).toBe(0); +}); From 1d81acd60487c4e8dba748c62a50ad91acbe4123 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 19 Jul 2026 14:34:25 +0000 Subject: [PATCH 2/4] sql(postgres): reject NaN for int4 and roll back write_buffer on bind error Address review feedback on the int4 overflow fix: - NaN was still silently bound as 0 because coerce:: maps NaN to 0 before the i32::try_from range check. Reject it up front so it surfaces as ERR_POSTGRES_OVERFLOW like the other non-finite values. - write_bind streams into the connection's write_buffer directly, so returning Err mid-serialize left a partial 'B...' frontend message in the buffer which register_auto_flusher would later flush to the socket, poisoning the pooled connection for the next query. Snapshot the write buffer before each bind_and_execute / parse_and_bind_and_execute / prepare_and_query_with_signature call and truncate back on Err. Adds NaN to the overflow test table and a connection-reuse test that runs a second query on the same pooled connection after an overflow rejection. --- src/sql_jsc/postgres/PostgresRequest.rs | 9 ++-- src/sql_jsc/postgres/PostgresSQLConnection.rs | 21 ++++++++++ src/sql_jsc/postgres/PostgresSQLQuery.rs | 4 ++ .../sql/postgres-int4-param-overflow.test.ts | 42 ++++++++++++++++++- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/sql_jsc/postgres/PostgresRequest.rs b/src/sql_jsc/postgres/PostgresRequest.rs index b48ed7d184b8..d2783fd58fe9 100644 --- a/src/sql_jsc/postgres/PostgresRequest.rs +++ b/src/sql_jsc/postgres/PostgresRequest.rs @@ -200,9 +200,12 @@ pub(crate) fn write_bind( l.write_excluding_self()?; } types::Tag::int4 | types::Tag::int4_array => { - // coerce:: saturates on overflow, which silently stores the - // wrong value. Range-check via i64 so an out-of-range value - // surfaces as an error instead. + // coerce:: saturates on overflow (and maps NaN to 0), which + // silently stores the wrong value. Range-check via i64 so an + // out-of-range or non-finite value surfaces as an error instead. + if value.get_number().is_some_and(|n| n.is_nan()) { + return Err(AnyPostgresError::Overflow); + } let n = value.coerce::(global).map_err(js_error_to_postgres)?; let n = i32::try_from(n).map_err(|_| AnyPostgresError::Overflow)?; let l = writer.length()?; diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index 7b572108229f..b88d16c24984 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -1531,6 +1531,19 @@ impl PostgresSQLConnection { || self.current().is_some() } + /// Absolute byte position to pass to `rollback_write_buffer` when a + /// `PostgresRequest::*` serializer errors mid-write, so partial frontend + /// messages are not flushed to the socket. + #[inline] + pub fn write_buffer_mark(&self) -> usize { + self.write_buffer.get().byte_list.len() + } + + #[inline] + pub fn rollback_write_buffer(&self, mark: usize) { + self.write_buffer.with_mut(|b| b.byte_list.truncate(mark)); + } + #[inline] pub(crate) fn note_request_pending(&self) { self.pending_requests.set(self.pending_requests.get() + 1); @@ -1924,6 +1937,7 @@ impl PostgresSQLConnection { debug!("parse, bind and execute unnamed stmt"); let query_str = req.query.to_utf8(); let global = self.global_object; + let mark = self.write_buffer_mark(); if let Err(err) = PostgresRequest::parse_and_bind_and_execute( &global, @@ -1935,6 +1949,7 @@ impl PostgresSQLConnection { self.writer(), ) { + self.rollback_write_buffer(mark); if let Some(err_) = self.global().try_take_exception() { req.on_js_error(err_, self.global()); } else { @@ -1960,6 +1975,7 @@ impl PostgresSQLConnection { } else { debug!("binding and executing stmt"); let global = self.global_object; + let mark = self.write_buffer_mark(); if let Err(err) = PostgresRequest::bind_and_execute( &global, statement, @@ -1967,6 +1983,7 @@ impl PostgresSQLConnection { columns_value, self.writer(), ) { + self.rollback_write_buffer(mark); if let Some(err_) = self.global().try_take_exception() { req.on_js_error(err_, self.global()); } else { @@ -2046,6 +2063,7 @@ impl PostgresSQLConnection { .unwrap_or_default(); debug!("prepareAndQueryWithSignature"); let global = self.global_object; + let mark = self.write_buffer_mark(); if let Err(err) = PostgresRequest::prepare_and_query_with_signature( &global, @@ -2055,6 +2073,7 @@ impl PostgresSQLConnection { &mut statement.signature, ) { + self.rollback_write_buffer(mark); if let Some(err_) = self.global().try_take_exception() { req.on_js_error(err_, self.global()); } else { @@ -2118,6 +2137,7 @@ impl PostgresSQLConnection { .unwrap_or_default(); debug!("parseAndBindAndExecute (unnamed, first execution)"); let global = self.global_object; + let mark = self.write_buffer_mark(); if let Err(err) = PostgresRequest::parse_and_bind_and_execute( &global, @@ -2129,6 +2149,7 @@ impl PostgresSQLConnection { self.writer(), ) { + self.rollback_write_buffer(mark); if let Some(err_) = self.global().try_take_exception() { req.on_js_error(err_, self.global()); } else { diff --git a/src/sql_jsc/postgres/PostgresSQLQuery.rs b/src/sql_jsc/postgres/PostgresSQLQuery.rs index 6df4a1d29b74..96a1fe580272 100644 --- a/src/sql_jsc/postgres/PostgresSQLQuery.rs +++ b/src/sql_jsc/postgres/PostgresSQLQuery.rs @@ -606,6 +606,7 @@ impl PostgresSQLQuery { bun_core::scoped_log!(Postgres, "bindAndExecute"); // bindAndExecute will bind + execute, it will change to running after binding is complete + let mark = connection.write_buffer_mark(); if let Err(err) = PostgresRequest::bind_and_execute( global_object, stmt, @@ -613,6 +614,7 @@ impl PostgresSQLQuery { columns_value, writer, ) { + connection.rollback_write_buffer(mark); this.release_statement(); return Err(throw_write_error( b"failed to bind and execute query", @@ -664,6 +666,7 @@ impl PostgresSQLQuery { if !has_params { bun_core::scoped_log!(Postgres, "prepareAndQueryWithSignature"); // prepareAndQueryWithSignature will write + bind + execute, it will change to running after binding is complete + let mark = connection.write_buffer_mark(); if let Err(err) = PostgresRequest::prepare_and_query_with_signature( global_object, query_str.slice(), @@ -671,6 +674,7 @@ impl PostgresSQLQuery { writer, &mut signature, ) { + connection.rollback_write_buffer(mark); if connection_entry_value.is_some() { let _ = connection .statements diff --git a/test/js/sql/postgres-int4-param-overflow.test.ts b/test/js/sql/postgres-int4-param-overflow.test.ts index 4768686c7deb..810ca1bf4fe5 100644 --- a/test/js/sql/postgres-int4-param-overflow.test.ts +++ b/test/js/sql/postgres-int4-param-overflow.test.ts @@ -26,16 +26,19 @@ import { const INT4 = 23; // Read the first parameter value out of a Bind body as a 4-byte big-endian -// i32, or null if the value length is not 4 (text format / NULL). +// i32, or null if the body is short / the value length is not 4. function bindFirstInt4(body: Buffer): number | null { + if (body.length < 2) return null; let o = body.indexOf(0) + 1; // portal name o = body.indexOf(0, o) + 1; // statement name + if (o <= 0 || o + 2 > body.length) return null; const nFmt = body.readInt16BE(o); o += 2 + 2 * nFmt; // format codes o += 2; // nParams + if (o + 4 > body.length) return null; const len = body.readInt32BE(o); o += 4; - return len === 4 ? body.readInt32BE(o) : null; + return len === 4 && o + 4 <= body.length ? body.readInt32BE(o) : null; } // Capture the value Bun bound for $1 on the most recent Bind. `undefined` means @@ -99,6 +102,7 @@ const overflowing = [ { label: "Number.MAX_SAFE_INTEGER", value: Number.MAX_SAFE_INTEGER }, { label: "Infinity", value: Infinity }, { label: "-Infinity", value: -Infinity }, + { label: "NaN", value: NaN }, ]; test.each(overflowing)("binding $label to an int4 parameter rejects instead of saturating", async ({ value }) => { @@ -126,3 +130,37 @@ test("binding 0 to an int4 parameter still works", async () => { await bind(0); expect(lastBoundInt4).toBe(0); }); + +// write_bind streams into the connection's write_buffer directly; erroring out +// mid-serialize used to leave a partial 'B…' frontend message in the buffer, +// which the auto-flusher then shipped to the server ahead of the next query's +// Parse on the same pooled connection. +test("a follow-up query on the same connection still works after an int4 overflow rejection", async () => { + await using sql = new SQL({ + adapter: "postgres", + hostname: "127.0.0.1", + port, + username: "u", + database: "db", + tls: false, + max: 1, + idleTimeout: 5, + connectionTimeout: 5, + }); + + let error: any; + try { + await sql`SELECT ${2 ** 32 + 1}::int4 AS n`.values(); + } catch (e) { + error = e; + } + expect(error?.code ?? error?.message).toMatch(/ERR_POSTGRES_OVERFLOW|Overflow/); + + // Any partial Bind bytes that were flushed would reach the server before the + // next Parse, and the mock handler would see a leading 0x42 'B' frame whose + // declared length (00 00 00 00) swallows nothing, so the follow-up Parse + // would never be answered and this await would reject or hang. + lastBoundInt4 = undefined; + await sql`SELECT ${7}::int4 AS n`.values(); + expect(lastBoundInt4).toBe(7); +}); From a24fd4b7fdb3a7a3337f8855f3acd7bb19bfe341 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 19 Jul 2026 14:41:32 +0000 Subject: [PATCH 3/4] test: assert no Bind frame reaches the server on int4 overflow The rollback guarantees the partial Bind is truncated before flushing, so lastBoundInt4 stays undefined. A single toBeUndefined() is stronger than the two not.toBe saturation checks it replaces. --- test/js/sql/postgres-int4-param-overflow.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/js/sql/postgres-int4-param-overflow.test.ts b/test/js/sql/postgres-int4-param-overflow.test.ts index 810ca1bf4fe5..e3abafea4cf0 100644 --- a/test/js/sql/postgres-int4-param-overflow.test.ts +++ b/test/js/sql/postgres-int4-param-overflow.test.ts @@ -114,9 +114,8 @@ test.each(overflowing)("binding $label to an int4 parameter rejects instead of s } expect(error).toBeDefined(); expect(error?.code ?? error?.message).toMatch(/ERR_POSTGRES_OVERFLOW|Overflow/); - // The Bind message must not have reached the server carrying a saturated i32. - expect(lastBoundInt4).not.toBe(2147483647); - expect(lastBoundInt4).not.toBe(-2147483648); + // The Bind message must not have reached the server at all. + expect(lastBoundInt4).toBeUndefined(); }); test("binding INT32_MAX / INT32_MIN to an int4 parameter still works", async () => { From d4372bffbeb96f2eb71789205ddbb7f21c24390c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:30:49 +0000 Subject: [PATCH 4/4] sql(postgres): shorten int4 and write_buffer_mark comments --- src/sql_jsc/postgres/PostgresRequest.rs | 4 +--- src/sql_jsc/postgres/PostgresSQLConnection.rs | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/sql_jsc/postgres/PostgresRequest.rs b/src/sql_jsc/postgres/PostgresRequest.rs index d2783fd58fe9..38d4d157246c 100644 --- a/src/sql_jsc/postgres/PostgresRequest.rs +++ b/src/sql_jsc/postgres/PostgresRequest.rs @@ -200,9 +200,7 @@ pub(crate) fn write_bind( l.write_excluding_self()?; } types::Tag::int4 | types::Tag::int4_array => { - // coerce:: saturates on overflow (and maps NaN to 0), which - // silently stores the wrong value. Range-check via i64 so an - // out-of-range or non-finite value surfaces as an error instead. + // coerce:: saturates and maps NaN to 0; reject instead. if value.get_number().is_some_and(|n| n.is_nan()) { return Err(AnyPostgresError::Overflow); } diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index b88d16c24984..798e097846fa 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -1531,9 +1531,7 @@ impl PostgresSQLConnection { || self.current().is_some() } - /// Absolute byte position to pass to `rollback_write_buffer` when a - /// `PostgresRequest::*` serializer errors mid-write, so partial frontend - /// messages are not flushed to the socket. + /// Snapshot for `rollback_write_buffer` when a serializer fails mid-message. #[inline] pub fn write_buffer_mark(&self) -> usize { self.write_buffer.get().byte_list.len()