Skip to content

sql: reject a Postgres message length below 4 as a protocol error - #32769

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/a79224a3/postgres-wire-length
Jun 26, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
farm/a79224a3/postgres-wire-length

Conversation

@robobun

@robobun robobun commented Jun 26, 2026 •

Copy link
Copy Markdown
Collaborator

A Postgres backend (or a proxy / pooler in between) that sends a message whose 4 byte length field declares a value below 4 makes Bun's SQL client abort the whole process in any build with debug assertions enabled (debug, release-asan, release-assertions):

panic: assertion failed: length >= 4
src/sql/postgres/protocol/ReadyForQuery.rs:22

In release builds (debug assertions off) the debug_assert! compiles out and the malformed length is silently accepted instead.

Reproduction

A mock server that completes the handshake and then sends a ReadyForQuery (Z) whose length field says 3:

const srv = Bun.listen({
  hostname: "127.0.0.1", port: 0,
  socket: {
    open(s) { s.data = { n: 0 }; },
    data(s) {
      if (++s.data.n !== 1) return;
      const authOk = Buffer.from([0x52, 0,0,0,8, 0,0,0,0]); // 'R' AuthenticationOk
      const zBad   = Buffer.from([0x5a, 0,0,0,3, 0x49]);    // 'Z' ReadyForQuery, length = 3 (< 4)
      s.write(Buffer.concat([authOk, zBad]));
    },
    error() {}, close() {}, drain() {},
  },
});
const sql = new Bun.SQL({ hostname: "127.0.0.1", port: srv.port, database: "d", username: "u", password: "p", tls: false, max: 1 });
await sql`select 1`;

Cause

Every PostgreSQL v3 message carries a signed Int32 length that includes its own 4 bytes, so a value below 4 (or a negative one, i.e. the sign bit set on the wire) is malformed and makes the stream unframeable: there is no way to skip to the next message. libpq treats msgLength < 4 as a sync loss for every message type, and because its comparison is on the signed type it rejects both halves.

The value is server controlled, but five decoders validated it with debug_assert!: ReadyForQuery, ParameterStatus, CommandComplete, NotificationResponse, and NegotiateProtocolVersion. Three other sites already returned AnyPostgresError::InvalidMessageLength for the same condition (ErrorResponse::decode_internal, Authentication, and the unknown-message arm in PostgresRequest::on_data).

Fix

Move the check into NewReaderWrap::length(), the one helper every decoder reads the length through. A value below 4, or one with the sign bit set (PostgresInt32 is u32, so the negative half of the protocol's signed Int32 shows up as values above i32::MAX), now returns AnyPostgresError::InvalidMessageLength, which surfaces to JS as ERR_POSTGRES_INVALID_MESSAGE_LENGTH on the failed connection. Fallout, all in this PR:

  • The five debug_assert!(length >= 4) became tautologies and are removed.
  • The two pre-existing copies of the same check (ErrorResponse::decode_internal and the unknown-message arm) became dead and are removed.
  • With its check gone, ErrorResponse::decode_internal became identical to decode_notice_internal, so the latter is deleted and NoticeResponse uses the shared decoder.

One deliberate behavior change: a NoticeResponse declaring a length below 4 previously decoded as an empty notice; it is now rejected like every other message, matching libpq. A NoticeResponse with an empty body (length exactly 4) is still accepted. The decoders that had no check at all (DataRow, RowDescription, CopyData, ParameterDescription) are covered by the shared check now too.

NegotiateProtocolVersion currently has no dispatch arm (a v byte falls through to the unknown-message path), so its assert was unreachable; it is removed for consistency but cannot be exercised by a test.

Out of scope: a server that declares an in-range length and never sends the bytes still parks the connection in the wait-for-more-data path until a timeout. That needs a max-message-size cap and is independent of length-field validity.

Net -23 lines in src/.

Verification

test/js/sql/postgres-invalid-message-length.test.ts drives each reachable decoder with a frame whose length field lies (3, 0, and -1), plus the length-exactly-4 boundary as the positive control.

  • Unfixed debug build: the file aborts with the panic above before reporting a single result.
  • Unfixed release build (USE_SYSTEM_BUN=1): 7 of 10 cases fail because the malformed length is silently accepted and the connection times out.
  • With the fix (bun bd test): all 10 pass, along with the 37 tests in the neighboring postgres wire suites (wire-frames, postgres-binary-array-bounds, sql-connect-error-reporting, sql-close-pending-connection, sql-onconnect-onclose-throw).

Every PostgreSQL v3 backend message carries an Int32 length that includes
its own 4 bytes, so a smaller value is malformed and makes the stream
unframeable. The value is server-controlled, but five decoders
(ReadyForQuery, ParameterStatus, CommandComplete, NotificationResponse,
NegotiateProtocolVersion) validated it with debug_assert!, which aborts
the whole process in any build with debug assertions enabled (debug,
release-asan, release-assertions). Builds without them silently accepted
the malformed length instead.

Move the check into NewReaderWrap::length(), the one place every decoder
reads the field, and surface it as AnyPostgresError::InvalidMessageLength
(ERR_POSTGRES_INVALID_MESSAGE_LENGTH). That makes the five debug asserts
and the two pre-existing copies of the same check (in ErrorResponse and
PostgresRequest's unknown-message arm) redundant, so they are removed.
ErrorResponse::decode_internal and decode_notice_internal become
identical as a result and are merged; a NoticeResponse declaring a length
below 4 is now rejected like every other message, matching libpq, which
treats it as a sync loss for all message types.
@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

More reviews will be available in 15 minutes and 27 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

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 credits.

🚦 How do rate 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 see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ac398e77-b9af-448c-af07-a5a294f259da

📥 Commits

Reviewing files that changed from the base of the PR and between 0589548 and c453621.

📒 Files selected for processing (12)
  • src/sql/postgres/protocol/CommandComplete.rs
  • src/sql/postgres/protocol/ErrorResponse.rs
  • src/sql/postgres/protocol/NegotiateProtocolVersion.rs
  • src/sql/postgres/protocol/NewReader.rs
  • src/sql/postgres/protocol/NoticeResponse.rs
  • src/sql/postgres/protocol/NotificationResponse.rs
  • src/sql/postgres/protocol/ParameterStatus.rs
  • src/sql/postgres/protocol/ReadyForQuery.rs
  • src/sql_jsc/postgres/PostgresRequest.rs
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • test/js/sql/postgres-invalid-message-length.test.ts
  • test/js/sql/wire-frames.ts

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

@robobun

robobun commented Jun 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:25 AM PT - Jun 26th, 2026

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


🧪   To try this PR locally:

bunx bun-pr 32769

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

bun-32769 --bun

@robobun

robobun commented Jun 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with test/js/sql/postgres-invalid-message-length.test.ts. On the unfixed debug build the test process aborts with panic: assertion failed: length >= 4 before reporting a single result; on the unfixed release build 7 of 10 cases fail because the malformed length is silently accepted. With this change all 10 pass (bun bd test), along with the 37 tests in the neighboring postgres wire suites.

The diff is done and both rounds of inline review feedback are addressed. All 23 debian x64-asan test lanes passed on both of the last two full builds (65026 and 65041); those are the lanes where the removed debug_assert! fires, and postgres-invalid-message-length.test.ts passed 10 of 10 on every job it ran on, including the failed ones. The only failures across three builds are two infrastructure conditions, each seen twice, and neither is related to this change:

  • :darwin: 26 aarch64 - test-bun: the darwin-aarch64-26-5-1-1 agent fails with buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun' before running a single test (builds 65020 and 65041, on different commits). The job is not auto retried.
  • The mysql:8.4 mysql_plain service container never passes its Docker health check (application not healthy after 1m0s, from test/docker/index.ts). It failed sql-mysql.helpers.test.ts on :alpine: 3.23 aarch64 in build 65026, and sql-mysql.test.ts on :alpine: 3.23 x64-baseline in build 65041, after 3 to 4 attempts each.

I have already spent one ci: retrigger, so I am leaving CI alone rather than pushing more empty commits.

@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.

LGTM — moving the < 4 check into the shared length() helper is the right chokepoint, and the fallout deletions are all provably dead. The one inline note is a cosmetic nit.

Extended reasoning...

Overview

This PR centralizes Postgres v3 message-length validation in NewReaderWrap::length(), the single helper every decoder reads the length field through. A declared length below 4 (which is unframeable per the protocol — the length includes its own 4 bytes) now returns AnyPostgresError::InvalidMessageLength instead of either tripping a debug_assert! (debug/asan builds → process abort) or being silently accepted (release builds). The fallout is mechanical: five tautological debug_assert!(length >= 4) calls removed, two now-redundant call-site checks deleted, and decode_notice_internal collapsed into decode_internal since they became identical. Net -23 lines in src/. A new fault-injection test file drives each reachable decoder with a lying length field plus the length-exactly-4 boundary as a positive control; pgRaw in the shared test helpers gains an optional declaredLength parameter with a backward-compatible default.

Security risks

None introduced — this is strictly a hardening change. Previously a malicious/buggy pooler, proxy, or MITM on the connection path could crash any debug-assertion build by sending a single malformed length byte (server-controlled DoS), or in release builds cause the malformed length to be silently trusted. Now it surfaces as ERR_POSTGRES_INVALID_MESSAGE_LENGTH on the affected connection only, matching libpq's sync-loss treatment. The one deliberate behavior change (NoticeResponse with length < 4 is now rejected rather than decoded as empty) is correct: an unframeable length is fatal regardless of message type, and the length-exactly-4 boundary remains accepted (covered by a test).

Level of scrutiny

Low-to-moderate. The fix is a 4-line addition at a single chokepoint; everything else is deletion of code that the new invariant makes provably unreachable. length() already propagated errors via ? at every call site, so the new error path needs no caller changes. The saturating_sub(4) → - 4 change inside length() itself is safe because the guard immediately precedes it. No CODEOWNERS paths are touched.

Other factors

robobun independently reproduced the panic on the unfixed debug build and the silent-accept failures on release, and confirmed all 9 new tests plus 37 neighboring wire-protocol tests pass with the fix. The bug-hunter's only finding is a cosmetic nit (two leftover saturating_sub(4) at call sites that are now equivalent to - 4) with zero runtime effect — not a blocker.

Comment thread src/sql/postgres/protocol/ErrorResponse.rs Outdated
…ast 4

The saturating subtraction at these two call sites could only saturate on
a length below 4, which length() now rejects before returning.

@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.

LGTM — thanks for addressing the nit in 1fa2595; the inline note on the signed high-bit case is pre-existing and non-blocking.

Extended reasoning...

Overview

Centralizes the Postgres v3 "message length must be ≥ 4" check in NewReaderWrap::length(), the one helper every decoder reads the length through. Five debug_assert!(length >= 4) calls (which aborted the process in debug/asan builds on server-controlled input, and compiled out in release) become tautologies and are removed; two duplicate runtime checks are removed; decode_notice_internal is deleted because it converged with decode_internal. Net -23 lines in src/. A new fault-injection test file drives every reachable decoder with a lying length field plus the length=4 boundary as a positive control, and pgRaw() in the shared test helper grows a declaredLength override. The one-line change in PostgresSQLConnection.rs swaps the deleted notice decoder for the shared one.

Security risks

The change is strictly hardening: it converts a debug_assert! on server-controlled input (process abort) and silent acceptance (release) into a per-connection ERR_POSTGRES_INVALID_MESSAGE_LENGTH. No new parsing surface is added — only an early-reject. The deliberate behavior change for NoticeResponse (length < 4 now rejected instead of decoded as empty) is correct: an unframeable length is fatal regardless of message type, and this matches libpq. The remaining inline observation (high-bit-set length passes the unsigned < 4 check) is pre-existing, byte-for-byte unchanged by this PR, and doesn't open a new vector — ensure_capacity is a pure length comparison with no allocation.

Level of scrutiny

Wire-protocol parsing warrants care, but the diff is mechanical: one 4-line guard added at a natural choke point, and downstream removals of code that the guard makes provably dead. A conforming server never sends length < 4, so well-formed traffic is unaffected. The 9 new tests exercise each decoder and the boundary, and the PR description reports the neighboring 37 wire-suite tests still pass.

Other factors

My earlier nit (saturating_sub(4) left at two call sites) was addressed in 1fa2595 and the thread is resolved. No CODEOWNERS cover these paths. No remaining references to the deleted decode_notice_internal. The author's choice to leave saturating_sub(4) in untouched files (DataRow/RowDescription/ParameterDescription) as out-of-scope is reasonable for a focused bugfix.

Comment thread src/sql/postgres/protocol/NewReader.rs
The protocol defines the length as a signed Int32, so a wire value in
0x80000000..=0xFFFFFFFF is negative and just as unframeable as one below
4. libpq's msgLength < 4 comparison is on the signed type and rejects
both halves; PostgresInt32 is u32 here, so the high half needs its own
clause. A server declaring a length of -1 now fails the connection with
InvalidMessageLength instead of stalling on the wait-for-more-bytes path.

@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.

LGTM — both earlier review rounds are addressed, and the centralized length() check with the new fault-injection coverage looks correct.

Extended reasoning...

Overview

This PR centralizes Postgres v3 message-length validation into NewReaderWrap::length(), the single helper every backend-message decoder reads the length through. A declared length below 4 (or with the sign bit set) now returns AnyPostgresError::InvalidMessageLength instead of tripping a debug_assert! (debug builds) or being silently accepted (release builds). The fallout is purely subtractive: five debug_assert!(length >= 4) calls become tautologies and are removed, two pre-existing copies of the same runtime check (in ErrorResponse::decode_internal and the unknown-message arm of on_data) become dead and are removed, and decode_notice_internal collapses into the now-identical decode_internal. Net -23 lines in src/ across 10 source files, plus a new 127-line fault-injection test and a small extension to the shared pgRaw helper.

Security risks

None introduced — this is a hardening change that makes the wire parser strictly more restrictive against server-controlled input. The length field comes from whatever sits on the connection path (server, pooler, proxy, tunnel), and the change converts an assertion on untrusted input into a proper protocol error that fails the one connection. The added expected > i32::MAX as u32 clause closes the signed-vs-unsigned gap with libpq's reference check. No new code paths accept input that was previously rejected; the only behavior change in the accept direction is none.

Level of scrutiny

Moderate-low. While this touches the Postgres wire-protocol reader, the actual edits are mechanical: a 6-line guard added at one chokepoint, and a series of 1–2-line deletions of checks that guard now subsumes. The one deliberate semantic change — a NoticeResponse declaring length < 4 is now rejected rather than decoded as empty — is correct (an unframeable length desyncs the stream regardless of message type, and libpq rejects it the same way) and is explicitly tested alongside the length-exactly-4 positive boundary. I confirmed decode_notice_internal has no remaining callers after its removal.

Other factors

I reviewed two earlier revisions of this PR; both inline threads are resolved with follow-up commits (1fa2595 replaced the dead saturating_sub(4) calls with plain subtraction, 35bce47 added the sign-bit rejection and a -1 test case). The bug-hunting system found nothing on the current head. The new test file exercises every reachable decoder via a table-driven matrix plus the in-flight-query path for CommandComplete, and the PR description documents that all 9 cases pass on the fixed build and fail/abort on the unfixed one. No CODEOWNERS cover the touched paths.

@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.

LGTM — both earlier nits addressed; the centralized length() check is a strict hardening with no effect on valid frames.

Extended reasoning...

Overview

Centralizes Postgres v3 message-length validation in NewReaderWrap::length(): a declared length < 4 or > i32::MAX (sign bit set on the wire) now returns AnyPostgresError::InvalidMessageLength instead of either debug_assert!-panicking (debug builds) or being silently accepted (release builds). Five now-tautological debug_assert!s and two duplicate call-site checks are removed; decode_notice_internal collapsed into decode_internal; NoticeResponse dispatch updated accordingly. Net −23 lines in src/. A new fault-injection test file drives each reachable decoder with malformed length fields (3, 0, −1) plus a length-exactly-4 positive control, and pgRaw() in wire-frames.ts gains an optional declaredLength parameter (backward-compatible default).

Security risks

None introduced. The change is strictly defensive: server-controlled input that previously aborted the process (debug) or was trusted (release) now surfaces as ERR_POSTGRES_INVALID_MESSAGE_LENGTH on that connection. For every valid frame a real Postgres server emits (length ∈ [4, i32::MAX]), length() behaves identically to before — same ensure_capacity((expected - 4) as usize) call, same return value. The one deliberate behavior change (a NoticeResponse declaring length < 4 now fails instead of decoding as empty) aligns with libpq and the protocol spec, since such a frame is unframeable.

Level of scrutiny

Moderate. length() sits on the hot path of every Postgres backend message, but the diff to it is a single guarded early-return on provably-invalid values, with the rest of the function unchanged for valid input. The decoder edits are mechanical removals of dead asserts/checks. No CODEOWNERS cover these paths.

Other factors

I previously left two inline comments on this PR (dead saturating_sub guards; signed-vs-unsigned parity with libpq); both were addressed in 1fa2595 and 35bce47 and marked resolved. The bug-hunting system found no issues on the current revision. The one CI failure (sql-mysql.helpers.test.ts on aarch64) is in MySQL test code this PR does not touch and appears to be the flake the ci: retrigger commit is chasing.

@Jarred-Sumner
Jarred-Sumner merged commit 0fe7567 into main Jun 26, 2026
76 of 78 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/a79224a3/postgres-wire-length branch June 26, 2026 21:30
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