Skip to content

postgres: validate binary datum length and range before decoding - #33576

Open
robobun wants to merge 7 commits into
mainfrom
farm/739b9cc3/pg-binary-datum-validation
Open

postgres: validate binary datum length and range before decoding#33576
robobun wants to merge 7 commits into
mainfrom
farm/739b9cc3/pg-binary-datum-validation

Conversation

@robobun

@robobun robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

What

Bun requests the binary result format for types it can binary-decode, so whatever is on the other end of the socket (the server, PgBouncer, RDS Proxy, a MitM) authors the datum bytes in a DataRow. Several binary decode arms in src/sql_jsc/postgres/DataCell.rs trusted those bytes and turned a wire-level violation into an ordinary-looking JS value instead of rejecting it. A lying peer could inject data that no query produced, indistinguishable from a real column value.

The int4 arm already rejected a wrong length (ERR_POSTGRES_UNSUPPORTED_INTEGER_SIZE); the rest were inconsistent. This makes the binary arms validate the datum against the declared type before decoding, the same way.

Cases fixed

Input (declared binary, format=1) Before After
float8 datum len 4 accepted as NaN reject ERR_POSTGRES_INVALID_BINARY_DATA
float4 datum len != 4 accepted via parse_binary_int4 reject ERR_POSTGRES_INVALID_BINARY_DATA
timestamp/timestamptz datum len 4 accepted as Invalid Date reject ERR_POSTGRES_INVALID_BINARY_DATA
bool len 0 accepted as false reject ERR_POSTGRES_INVALID_BINARY_DATA
bool value 2 accepted as false reject ERR_POSTGRES_INVALID_BINARY_DATA
int4[] element length prefix != 4 accepted, wrong Int32Array reject ERR_POSTGRES_INVALID_BINARY_DATA
time = 2^63-1 us garbage string from C formatter reject ERR_POSTGRES_INVALID_TIME_FORMAT
time = -1 us garbage string from C formatter reject ERR_POSTGRES_INVALID_TIME_FORMAT
numeric ndigits=0 + trailing bytes accepted as "0" reject ERR_POSTGRES_UNSUPPORTED_NUMERIC_FORMAT
numeric dscale < 0 accepted reject ERR_POSTGRES_UNSUPPORTED_NUMERIC_FORMAT
uuid sent with binary format code raw 16 bytes as a JS string reject ERR_POSTGRES_UNKNOWN_FORMAT_CODE

For the last case: a binary format code on a type Bun has no binary decoder for was silently reinterpreted as text, exposing the raw bytes (with embedded NULs / invalid UTF-8) as a string. A compliant server never sends format=1 for such a type, so put_impl now rejects it.

Cause

The binary decode arms used if binary && bytes.len() == N { decode } else { text-parse }, so a binary datum of the wrong length fell through to the text parser. bool only checked bytes[0] == 1. from_bytes_typed_array walked a fixed element stride without reading the per-element length prefix. parse_binary_numeric returned early on ndigits == 0 and read dscale as a signed i16 without validating it or the total length. time passed the raw microsecond count to Postgres__formatTime with no range check.

Fix

src/sql_jsc/postgres/DataCell.rs: each binary arm validates width (float8=8, float4=4, timestamp/timestamptz=8, bool=1 with value in {0,1}), parse_binary_numeric rejects ndigits < 0, dscale < 0, and any trailing bytes (len != 8 + ndigits*2), from_bytes_typed_array checks each element's length prefix equals the element size, time/timetz range-check microseconds to [0, 24h], and put_impl rejects a binary format code on a type that is not in is_binary_format_supported.

Verification

test/js/sql/postgres-binary-datum-validation.test.ts drives a scripted Postgres v3 wire server (via test/js/sql/wire-frames.ts, no Postgres needed) that declares format=1 per column and sends each malformed datum. All 10 malformed cases pass only with the fix (fail-before confirmed by stashing src/); 4 well-formed datums still decode. The real-Postgres binary NUMERIC and TIMESTAMP suites and the binary int4[] bounds suite still pass.

A RowDescription can declare format=1 (binary) for a column, after which
the server (or a compromised pooler/proxy) authors the datum bytes. Several
binary decode arms in DataCell trusted those bytes and silently turned a
wire-level violation into a plausible JS value instead of rejecting it:

- float8/float4/timestamp/timestamptz with a wrong-width datum fell through
  to the text parser, yielding NaN or Invalid Date
- bool accepted a 0-byte datum (false) or a value outside {0, 1}
- binary int4[]/float4[] ignored each element's length prefix, so a prefix
  other than the element size was decoded as if it matched
- time/timetz fed an out-of-range or negative microsecond count straight to
  the C formatter, producing garbage strings
- numeric accepted trailing bytes after an ndigits=0 header and a negative
  (high-bit) dscale
- a binary format code on a type with no binary decoder (e.g. uuid) was
  silently reinterpreted as text, exposing the raw bytes as a JS string

Each arm now validates the datum against the declared type and errors with
the matching ERR_POSTGRES_* code, the way the int4 arm already did.
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 373e863a-8ac0-4814-a015-2f5af5e7b6b9

📥 Commits

Reviewing files that changed from the base of the PR and between c2e9b10 and 93f2dfa.

📒 Files selected for processing (3)
  • src/sql/postgres/types/Tag.rs
  • src/sql_jsc/postgres/DataCell.rs
  • test/js/sql/postgres-binary-datum-validation.test.ts

Walkthrough

This PR adds stricter binary-format validation to PostgreSQL datum decoding in DataCell.rs, including exact length checks for FLOAT8/FLOAT4/BOOL/TIMESTAMP, microsecond range checks for TIME/TIMETZ, typed-array element length validation, numeric header validation, and format-code enforcement in put_impl. A new test suite validates malformed and well-formed binary payloads via a mock server.

Changes

Postgres binary datum validation hardening

Layer / File(s) Summary
Scalar type length/value validation
src/sql_jsc/postgres/DataCell.rs
FLOAT8, FLOAT4, BOOL, and TIMESTAMP/TIMESTAMPTZ binary decoding now enforce exact byte lengths and value bounds, returning InvalidBinaryData on mismatch instead of falling back to text parsing or lenient decoding.
Time/timezone microsecond range validation
src/sql_jsc/postgres/DataCell.rs
Adds USECS_PER_DAY constant and validates TIME/TIMETZ microsecond values fall within [0, 24h], returning InvalidTimeFormat for out-of-range values.
Typed-array element length validation
src/sql_jsc/postgres/DataCell.rs
Float4/int4 typed-array decoding now reads and byte-swaps each element's length prefix, validating it matches the expected element size before computing offsets, erroring with InvalidBinaryData on mismatch.
Numeric header and format-code validation
src/sql_jsc/postgres/DataCell.rs
parse_binary_numeric rejects negative ndigits/dscale and requires exact buffer length; put_impl rejects binary-marked fields whose type lacks binary decoding support with UnknownFormatCode.
Binary datum validation tests
test/js/sql/postgres-binary-datum-validation.test.ts
Adds a mock-server-based test suite covering malformed binary payloads (bad lengths, bool values, time ranges, numeric headers, unknown format codes) and positive control tests for well-formed values.

Possibly related PRs

  • oven-sh/bun#30164: Both PRs add stricter validation for Postgres binary typed-array parsing, checking element lengths/bounds before decoding int4[]/float4[] elements.
  • oven-sh/bun#31211: Both PRs directly modify the parse_binary_numeric path in DataCell.rs, one adding stricter validation and the other rewriting digit-group emission.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: stricter PostgreSQL binary datum validation before decoding.
Description check ✅ Passed The description covers what changed and how it was verified, with enough detail despite using different headings.
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.

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

@github-actions github-actions Bot added the claude label Jul 7, 2026
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:05 AM PT - Jul 7th, 2026

@robobun, your commit 93f2dfa has some failures in Build #69624 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33576

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

bun-33576 --bun

@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/DataCell.rs`:
- Around line 1304-1309: The binary-format guard in DataCell::from_pg_result
only checks FieldDescription::binary, so prepared-statement results can still
fall through because self.binary is the flag that enables binary decoding there.
Update the unsupported-binary check in the DataCell decoding path to reject when
either the row description or the active result path indicates binary, and keep
using tag.is_binary_format_supported() as the decoder capability check.

In `@test/js/sql/postgres-binary-datum-validation.test.ts`:
- Around line 165-198: The binary datum validation suite runs I/O-bound cases
sequentially even though each test creates its own server and Postgres
connection via runMockQuery, so the malformed table-driven cases and the
standalone well-formed parsing checks should be made concurrent. Update the
existing test.each and test blocks in postgres-binary-datum-validation.test.ts
to use test.concurrent.each and test.concurrent for the malformed set and the
individual bool/float8/time/numeric tests, keeping the same assertions and
helpers but enabling parallel execution safely.
🪄 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: 39af7989-d433-4898-9d34-45432915d13c

📥 Commits

Reviewing files that changed from the base of the PR and between a12475e and c2e9b10.

📒 Files selected for processing (2)
  • src/sql_jsc/postgres/DataCell.rs
  • test/js/sql/postgres-binary-datum-validation.test.ts

Comment thread src/sql_jsc/postgres/DataCell.rs Outdated
Comment thread test/js/sql/postgres-binary-datum-validation.test.ts Outdated
Comment thread src/sql_jsc/postgres/DataCell.rs Outdated
Comment thread test/js/sql/postgres-binary-datum-validation.test.ts
The pre-existing is_empty() -> null early-return ran before the binary
length check, so a 0-byte binary timestamp/timestamptz/time/timetz datum
still surfaced as null instead of ERR_POSTGRES_INVALID_BINARY_DATA. Move
the is_empty() -> null shortcut into the text branch of both arms so the
binary width check runs first.

Also add a float4 2-byte malformed case and timestamp/time 0-byte cases
to the fault-injection suite.
Comment thread src/sql_jsc/postgres/DataCell.rs Outdated
robobun and others added 2 commits July 7, 2026 03:22
A simple-protocol FETCH from a BINARY CURSOR sends format code 1 for
every column (Postgres FE/BE protocol, Simple Query). For text/varchar/
bpchar/name/char the binary *send() output is byte-identical to text, so
decoding those via the text path is already correct; the guard now skips
them. Types whose binary format differs (uuid, date, jsonb, ...) are
still rejected.

Verified against real Postgres: DECLARE BINARY CURSOR; FETCH now decodes
text/int4 columns as before. Added a text-with-binary-format positive
case to the fault-injection suite; the uuid-with-binary-format rejection
still holds.
Comment thread src/sql/postgres/types/Tag.rs
json_send() and xml_send() emit raw text bytes with no header, so their
binary output equals their text output and a BINARY CURSOR FETCH over a
json or xml column must decode via the text path, not be rejected. jsonb
stays excluded (jsonb_send prepends a version byte).

Verified against real Postgres.
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: 281 jobs passed on build 69624. The one hard failure is :darwin: 26 aarch64 - test-bun dying on buildkite-agent artifact download timed out after 120s before any test ran (same agent also timed out on the earlier build 69482). The flaky-tagged retries are test-tls-reuse-host-from-socket.js (Windows TLS) and napi.test.ts (Windows N-API), neither touches the Postgres SQL path this diff changes.

The diff is green where it actually ran: test/js/sql/postgres-binary-datum-validation.test.ts and the neighboring postgres-binary-* / sql-postgres-* suites pass on every lane that downloaded its binary. Binary size unchanged. Ready for review.

Jarred-Sumner pushed a commit that referenced this pull request Jul 21, 2026
…C decode (#34429)

## Repro

A mock Postgres server sends a binary NUMERIC column (OID 1700,
format=1) whose base-10000 digit word is `10000` (wire allows any u16;
valid range is 0..9999):

```
panic: assertion failed: N == 0 || val < 10u64.saturating_pow(N as u32)
  itoa_padded<4>                     src/bun_core/fmt.rs:3047
  parse_binary_numeric               src/sql_jsc/postgres/DataCell.rs
```

On release builds the debug_assert is stripped and the high digits are
silently dropped: digit `10000` decodes to `""`, `32768` decodes to
`"2768"`, `65535` decodes to `"5535"`. The query succeeds and the app
receives a wrong numeric value.

## Cause

`parse_binary_numeric` reads each u16 digit word from the wire and
passes it straight to `itoa_padded::<4>`, which requires `val < 10^4`.
The integer-part loop (line 1058) and the fractional-part loop (line
1101) both do this. Postgres' own `numeric_recv` checks `d < 0 || d >=
NBASE` on every received digit and errors with `invalid digit in
external "numeric" value`.

## Fix

Range-check `digit >= 10000` at both read sites and return
`InvalidBuffer`, same posture as the existing sign check a few lines up.
The caller already maps every error from this function to
`ERR_POSTGRES_UNSUPPORTED_NUMERIC_FORMAT`.

## Verification

`test/js/sql/postgres-binary-numeric-digit-range.test.ts` drives a
scripted wire server (via `test/js/sql/wire-frames.ts`, no Postgres
needed) that sends digit words 10000/10001/32768/65535 in the integer
part and 10000 in the fractional part. All reject with
`ERR_POSTGRES_UNSUPPORTED_NUMERIC_FORMAT`; digit 9999 and a two-word
valid value still decode. Fail-before on stock 1.4.0 shows the silent
corruption (`32768` -> `"2768"`). The real-Postgres
`postgres-binary-numeric.test.ts` suite (13 cases) still passes.

Related: #33576 validates numeric header fields but not the digit words.
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.

1 participant