Skip to content

sql(postgres): reject out-of-range JS numbers bound to int4 parameters - #34708

Closed
robobun wants to merge 4 commits into
mainfrom
farm/24849c15/postgres-int4-overflow
Closed

robobun wants to merge 4 commits into
mainfrom
farm/24849c15/postgres-int4-overflow

Conversation

@robobun

@robobun robobun commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator

What

Binding a JS number outside the i32 range (or NaN) to an int4 parameter silently saturated to INT32_MIN/INT32_MAX (or 0) and stored the wrong value. A real PostgreSQL server rejects the same literal with integer out of range (SQLSTATE 22003), so every other client path surfaces a loud error while Bun quietly persisted the saturated value.

await sql`INSERT INTO t (n) VALUES (${4294967297})`; // int4 column
// before: silently stored 2147483647
// after:  rejects with ERR_POSTGRES_OVERFLOW

Cause

write_bind encoded int4 parameter values via value.coerce::<i32>(), whose number fast path is a saturating f64 as i32 cast (and maps NaN to 0):

https://github.com/oven-sh/bun/blob/98f664962f/src/sql_jsc/postgres/PostgresRequest.rs#L204-L213

so 2147483648 became 2147483647, 4294967297 became 2147483647, -2147483649 became -2147483648, and NaN became 0, all without an error.

Fix

  • Reject NaN up front, then coerce to i64 and i32::try_from the result, returning AnyPostgresError::Overflow when it does not fit. In-range values (including the i32 boundaries and truncation of fractional parts) behave exactly as before.
  • write_bind streams directly into the connection's write_buffer, so erroring out mid-serialize would have left a partial B... frontend message in the buffer that the auto-flusher later shipped to the socket, poisoning the pooled connection for the next query. Every call site that reaches write_bind now snapshots the write buffer first and truncates back on Err (also covers the pre-existing jsonb / throwing-coercion error paths).

Verification

test/js/sql/postgres-int4-param-overflow.test.ts runs a mock server that declares $1 as oid 23 and asserts that out-of-range and non-finite values reject with ERR_POSTGRES_OVERFLOW (and that no saturated 2147483647/-2147483648 reaches the wire), that INT32_MAX / INT32_MIN / 0 still round-trip, and that a follow-up query on the same max: 1 pooled connection still works after an overflow rejection.


[review] gate passed · iteration 3 · 4 files touched

fails on main (without fix)
ASAN without fix: 8 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/sql/postgres-int4-param-overflow.test.ts"
bun test v1.4.1 (65362b53b)

test/js/sql/postgres-int4-param-overflow.test.ts:
110 |   try {
111 |     await bind(value);
112 |   } catch (e) {
113 |     error = e;
114 |   }
115 |   expect(error).toBeDefined();
                      ^
error: expect(received).toBeDefined()

Received: undefined

      at <anonymous> (/workspace/bun/test/js/sql/postgres-int4-param-overflow.test.ts:115:17)
(fail) binding 2^31 to an int4 parameter rejects instead of saturating [402.72ms]
110 |   try {
111 |     await bind(value);
112 |   } catch (e) {
113 |     error = e;
114 |   }
115 |   expect(error).toBeDefined();
                      ^
error: expect(received).toBeDefined()

Received: undefined

      at <anonymous> (/workspace/bun/test/js/sql/postgres-int4-param-overflow.test.ts:115:17)
(fail) binding 2^32 + 1 to an int4 parameter rejects instead of saturating [154.43ms]
110 |   try {
111 |     await bind(value);
112 |   } catch (e) {
113 |     error = e;
114 |   }
115 |   expect(error).toBeDefined()
... (truncated)

release without fix: 8 FAILED
bun test v1.4.1-canary.1 (65362b53b)

test/js/sql/postgres-int4-param-overflow.test.ts:
110 |   try {
111 |     await bind(value);
112 |   } catch (e) {
113 |     error = e;
114 |   }
115 |   expect(error).toBeDefined();
                      ^
error: expect(received).toBeDefined()

Received: undefined

      at <anonymous> (/workspace/bun/test/js/sql/postgres-int4-param-overflow.test.ts:115:17)
(fail) binding 2^31 to an int4 parameter rejects instead of saturating [7.48ms]
110 |   try {
111 |     await bind(value);
112 |   } catch (e) {
113 |     error = e;
114 |   }
115 |   expect(error).toBeDefined();
                      ^
error: expect(received).toBeDefined()

Received: undefined

      at <anonymous> (/workspace/bun/test/js/sql/postgres-int4-param-overflow.test.ts:115:17)
(fail) binding 2^32 + 1 to an int4 parameter rejects instead of saturating [2.35ms]
110 |   try {
111 |     await bind(value);
112 |   } catch (e) {
113 |     error = e;
114 |   }
115 |   expect(error).toBeDefined();
                      ^
error: expect(received).toBeDefined()

Received: undefined

      at <anonymous> (/workspace/bun/test/js/sql/postgres-int4-param-overflow.test.ts:115:17)
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/sql/postgres-int4-param-overflow.test.ts"
bun test v1.4.1 (65362b53b)

test/js/sql/postgres-int4-param-overflow.test.ts:
(pass) binding 2^31 to an int4 parameter rejects instead of saturating [365.73ms]
(pass) binding 2^32 + 1 to an int4 parameter rejects instead of saturating [136.99ms]
(pass) binding -(2^31 + 1) to an int4 parameter rejects instead of saturating [32.74ms]
(pass) binding Number.MAX_SAFE_INTEGER to an int4 parameter rejects instead of saturating [26.75ms]
(pass) binding Infinity to an int4 parameter rejects instead of saturating [36.49ms]
(pass) binding -Infinity to an int4 parameter rejects instead of saturating [25.92ms]
(pass) binding NaN to an int4 parameter rejects instead of saturating [25.35ms]
(pass) binding INT32_MAX / INT32_MIN to an int4 parameter still works [73.62ms]
(pass) binding 0 to an int4 parameter still works [29.98ms]
(pass) a follow-up query on the same connection still works after an int4 overflow rejection [48.51ms]

 10 pass
 0 fail
 26 expect() calls
Ran 10 tests across 1 file. [3.71s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     d4372bffbe
  features     baseline

23 deps, 131 codegen, 1172 objects in 678ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.1-canary.1 (65362b53b)

Checked 26 installs across 63 packages (no changes) [19.00ms]
[2/1244] gen bindgenv2
[3/1244] gen ErrorCode+*.h
[4/1244] install /workspace/bun/packages/bun-error
bun install v1.4.1-canary.1 (65362b53b)

Checked 1 install across 2 packages (no changes) [2.00ms]
[5/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[6/1217] fetch zlib
[zlib] up to date
[7/1217] fetch tinycc
[tinycc] up to date
[8/1216] gen .bind.ts → GeneratedBindings.cpp
[9/1216] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[10/1216] install /workspace/bun/src/node-fallbacks
bun install v1.4.1-canary.1 (65362b53b)

Checked 111 installs across 104 packages (no changes) [21.00ms]
[
... (truncated)
diff hotspot
src/sql_jsc/postgres/PostgresRequest.rs          |  15 ++-
 src/sql_jsc/postgres/PostgresSQLConnection.rs    |  19 +++
 src/sql_jsc/postgres/PostgresSQLQuery.rs         |   4 +
 test/js/sql/postgres-int4-param-overflow.test.ts | 165 +++++++++++++++++++++++
 4 files changed, 196 insertions(+), 7 deletions(-)

gate history · 3 passed · 0 rejected · iteration 3

evidence per changed file
file                                              reads  edits  tests
src/sql_jsc/postgres/PostgresRequest.rs               6      4      0
src/sql_jsc/postgres/PostgresSQLConnection.rs         9      6      0
src/sql_jsc/postgres/PostgresSQLQuery.rs              8      3      0
test/js/sql/postgres-int4-param-overflow.test.ts      2      9      0

@robobun

robobun commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:59 PM PT - Aug 27th, 2026

❌ @robobun, your commit d4372bf has 1 failures in Build #107235 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34708

That installs a local version of the PR into your bun-34708 executable, so you can run:

bun-34708 --bun

@coderabbitai

coderabbitai Bot commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Postgres int4 parameters now reject non-finite and out-of-range values. Prepared-statement write paths checkpoint and restore the protocol buffer on failure, with tests covering boundaries, overflow cases, and pooled connection reuse.

Postgres int4 safety

Layer / File(s) Summary
Validate int4 bindings
src/sql_jsc/postgres/PostgresRequest.rs, test/js/sql/postgres-int4-param-overflow.test.ts
Int4 serialization performs checked conversion and rejects invalid values; mock-server tests cover overflow, boundaries, zero, and wire-level results.
Rollback failed protocol writes
src/sql_jsc/postgres/PostgresSQLConnection.rs
Write-buffer checkpoints and rollback handling are added to prepared-statement serialization paths.
Integrate rollback into query execution
src/sql_jsc/postgres/PostgresSQLQuery.rs, test/js/sql/postgres-int4-param-overflow.test.ts
Query execution restores buffered writes before existing cleanup, and pooled connections are tested after an overflow rejection.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: rejecting out-of-range JavaScript numbers bound to PostgreSQL int4 parameters.
Description check ✅ Passed The description explains the problem, cause, fix, and verification results. It provides concrete examples and test evidence. Although the headings differ from the repository template, all required inf…
Full details: Description check

Explanation

The description explains the problem, cause, fix, and verification results. It provides concrete examples and test evidence. Although the headings differ from the repository template, all required information is present.


Comment @coderabbitai help to get the list of available commands.

Comment thread src/sql_jsc/postgres/PostgresRequest.rs
Comment thread src/sql_jsc/postgres/PostgresRequest.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/sql_jsc/postgres/PostgresSQLQuery.rs`:
- Around line 719-727: Add write-buffer rollback guards to the named
Parse/Describe plus Sync path in PostgresSQLQuery and the matching phase-1 write
in PostgresSQLConnection::advance(). Mark the buffer before each operation, and
roll back to that mark whenever PostgresRequest::write_query(), the named
writes, or the follow-up SYNC write fails, preventing partial frames from
remaining queued.

In `@test/js/sql/postgres-int4-param-overflow.test.ts`:
- Around line 117-119: Update the wire assertion in the overflow test to require
lastBoundInt4 to be exactly undefined, replacing both negative saturation checks
while preserving the existing bind/reset behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fce9cb47-55a4-4895-961c-a34ad56c0dda

📥 Commits

Reviewing files that changed from the base of the PR and between 98f6649 and 6c1d761.

📒 Files selected for processing (4)
  • src/sql_jsc/postgres/PostgresRequest.rs
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • src/sql_jsc/postgres/PostgresSQLQuery.rs
  • test/js/sql/postgres-int4-param-overflow.test.ts

Comment thread src/sql_jsc/postgres/PostgresSQLQuery.rs
Comment thread test/js/sql/postgres-int4-param-overflow.test.ts Outdated
Comment thread src/sql_jsc/postgres/PostgresRequest.rs
@robobun

robobun commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI build 107235 (after rebase onto main) is complete: 180 of 181 jobs passed. The new test test/js/sql/postgres-int4-param-overflow.test.ts passed on every lane.

The one red lane is test/js/web/url/url.test.ts on darwin x64 (an ICU / Unicode 16 IDNA table assertion), unrelated to this diff and reported separately.

Ready for review.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Verify this behavior is what Postgres.js and other clients do.

@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Verified against a real PostgreSQL server, inserting into an int4 column via $1:

input postgres.js 3.4.7 node-pg 8.16.3 Bun 1.3.14 (main) Bun (this PR)
2147483648 22003 out of range 22003 out of range 22003 out of range 22003 out of range
4294967297 22003 out of range 22003 out of range 22003 out of range 22003 out of range
Infinity 22P02 invalid syntax 22P02 invalid syntax 22003 out of range 22003 out of range
NaN 22P02 invalid syntax 22P02 invalid syntax 22003 out of range 22003 out of range
{valueOf:()=>4294967297} 22003 out of range 22P02 invalid syntax inserts 1 ERR_POSTGRES_OVERFLOW
{valueOf:()=>NaN} 22P02 invalid syntax 22P02 invalid syntax inserts 0 ERR_POSTGRES_OVERFLOW
2147483647 inserts inserts inserts inserts

postgres.js and node-pg send every parameter as text and let the server reject.

Bun behaves a bit differently because of client-side signature detection (tag_jsc::from_js): a primitive number outside the i32 range is hinted to the server as int8 / float8, so the server already rejects with 22003 and this PR does not change that path. The row that actually exercises write_bind's int4 arm with an out-of-range value is the one where the client cannot infer a numeric type (an object, which hints oid 0) and the server infers int4 from the column. On main that arm's coerce::<i32> saturates / ToInt32-wraps, so {valueOf:()=>4294967297} stores 1 and {valueOf:()=>NaN} stores 0. With this PR both reject.

The repro in the issue hits the same arm by having the mock server return ParameterDescription([23]) regardless of Bun's type hint, which a real server does whenever the client hints oid 0.

So: outcome matches postgres.js / node-pg (reject, never silently store the wrong integer). The difference is that in the server-inferred-int4 case Bun rejects client-side with ERR_POSTGRES_OVERFLOW instead of round-tripping text for the server to reject with 22003 / 22P02. Happy to switch that to falling through to text format instead if you would rather the error come from the server.

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Cross-reference: #39452 (Date and object parameters with prepare: false) routes prepare: false objects onto the JSON arm of write_bind, so a JSON.stringify failure there now leaves a partial Bind in the write buffer on that path as well, the same as prepare: true does today. The rollback in this PR covers it. #39452 does not duplicate the rollback and is meant to land after this PR (or #34732).

write_bind encoded int4 parameter values with coerce::<i32>, 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.
… error

Address review feedback on the int4 overflow fix:

- NaN was still silently bound as 0 because coerce::<i64> 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.
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.
@robobun
robobun force-pushed the farm/24849c15/postgres-int4-overflow branch from 434f71d to a24fd4b Compare August 28, 2026 03:28
Comment thread src/sql_jsc/postgres/PostgresRequest.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #41976 and #34732.

With #41976 the int4 binary encoder runs only for a number that int4 holds exactly. 2 ** 31, NaN or Infinity bound to an int4-typed parameter goes in text format and the server rejects it (22003 / 22P02), as it does for postgres.js and node-pg, so nothing saturates. Against a real server that arm does not see such a number anyway: Bun declares float8 in Parse for a non-int32 number and the server reports float8 back, so ${2 ** 32 + 1}::int4 is a server-side 22003 today. The write-buffer rollback after a failed Bind, the other half of this PR, is #34732.

@robobun robobun closed this Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants