Skip to content

sql(mysql): validate response sequence ids to prevent cross-query result delivery - #34048

Open
robobun wants to merge 4 commits into
mainfrom
claude/farm/4f9db4eb/mysql-seq-id-validate
Open

sql(mysql): validate response sequence ids to prevent cross-query result delivery#34048
robobun wants to merge 4 commits into
mainfrom
claude/farm/4f9db4eb/mysql-seq-id-validate

Conversation

@robobun

@robobun robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

Two concurrent simple queries A and B on a max: 1 pool (B queues client-side). The server answers A with its result set plus an unsolicited trailing result set in the same TCP segment, carrying continuation sequence ids (5..8). A real answer to B would restart at seq 1 after B's COM_QUERY (seq 0).

process_packets tracked the incoming sequence id (self.sequence_id = header.sequence_id.wrapping_add(1)) but never validated it, and the connection's expected sequence id was never reset when a new command was written. A's terminator advances the queue, B's COM_QUERY is sent, and the trailing ghost bytes in the same read buffer are routed to B. B resolves with rows the server never produced for it, with no error raised:

A=ok:[{"a":"Arow"}]  B=ok:[{"g":"GHOST"}]

This is server-triggerable (one hostile write, or any protocol-corrupting proxy / server-side desync) on an otherwise idle default connection. The same shape applies to residual bytes after an ERR packet, after an OK, after a completed prepared-statement response.

Fix

src/sql_jsc/mysql/MySQLConnection.rs:

  • In process_packets, when status == Connected, reject the connection with PacketsOutOfOrder if header.sequence_id != self.sequence_id. libmysql fails with CR_NET_PACKETS_OUT_OF_ORDER here; node-mysql2 warns on the same mismatch.
  • Reset self.sequence_id = 1 at every ready-for-next-command transition (auth OK, result-set last terminator, result-set ERR, prepared-statement done, prepared-statement ERR, cached-failure replay), since every command packet is written at seq 0 via writer.start(0).

New error variant PacketsOutOfOrder surfaces as ERR_MYSQL_PACKETS_OUT_OF_ORDER.

Verification

test/js/sql/sql-mysql-sequence-desync.test.ts (mock server via the shared wire-frames.ts builders, no Docker):

  • residual result set after A's terminator: A resolves with its real row, B rejects with ERR_MYSQL_PACKETS_OUT_OF_ORDER (on main, B resolves with [{g:"GHOST"}]).
  • residual result set after A's ERR packet: A rejects with the server error, B rejects with ERR_MYSQL_PACKETS_OUT_OF_ORDER (on main, B resolves with [{g:"GHOST"}]).
  • baseline: two well-formed sequential result sets, each restarting at seq 1, both resolve with their own rows.

Against a real MariaDB 11.8 on the fixed debug (ASAN) build: concurrent simple queries, prepared queries (COM_STMT_PREPARE then COM_STMT_EXECUTE), a 1000-row result set (sequence id wraps), multi-statement SELECT 1; SELECT 2 (SERVER_MORE_RESULTS_EXISTS, sequence continues across the sub-results), and error-then-success all pass. sql-mysql.helpers.test.ts 14/14, sql-mysql.transactions.test.ts 12/12 (minus two error-message wording diffs that also fail on main against MariaDB).


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

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/sql/sql-mysql-sequence-desync.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (b0d8643cc)

test/js/sql/sql-mysql-sequence-desync.test.ts:
91 | 
92 |     // A settles with its real row regardless of the fix.
93 |     expect(r.A).toEqual({ state: "ok", value: [{ a: "Arow" }] });
94 |     // B MUST NOT resolve with the ghost rows. The connection must fail on the
95 |     // out-of-order packet, rejecting B and every later query.
96 |     expect(r.B).toEqual({ state: "rej", code: "ERR_MYSQL_PACKETS_OUT_OF_ORDER" });
                     ^
error: expect(received).toEqual(expected)

  {
-   "code": "ERR_MYSQL_PACKETS_OUT_OF_ORDER",
-   "state": "rej",
+   "state": "ok",
+   "value": [
+     {
+       "g": "GHOST",
+     },
+   ],
  }

- Expected  - 2
+ Received  + 6

      at <anonymous> (/workspace/bun/
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (ec2f4f614)

test/js/sql/sql-mysql-sequence-desync.test.ts:
(pass) MySQL residual bytes after a completed result set are not delivered to the next queued query [11.65ms]
(pass) MySQL residual bytes after an ERR packet are not delivered to the next queued query [2.42ms]
(pass) MySQL sequential queries on one connection each receive their own rows [3.90ms]
(pass) MySQL sequence-id validation accepts the 255->0 wrap within a result set [3.35ms]

 4 pass
 0 fail
 9 expect() calls
Ran 4 tests across 1 file. [171.00ms]
__F:0:S:0
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/sql-mysql-sequence-desync.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (b0d8643cc)

test/js/sql/sql-mysql-sequence-desync.test.ts:
(pass) MySQL residual bytes after a completed result set are not delivered to the next queued query [607.53ms]
(pass) MySQL residual bytes after an ERR packet are not delivered to the next queued query [117.20ms]
(pass) MySQL sequential queries on one connection each receive their own rows [109.64ms]
(pass) MySQL sequence-id validation accepts the 255->0 wrap within a result set [220.28ms]

 4 pass
 0 fail
 9 expect() calls
Ran 4 tests across 1 file. [3.41s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 705ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

info: checking for self-update (current version: 1.29.0)
  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl
... (truncated)
diff hotspot
src/sql/mysql/protocol/AnyMySQLError.rs           |   1 +
 src/sql_jsc/mysql/MySQLConnection.rs              |  18 +-
 src/sql_jsc/mysql/protocol/any_mysql_error_jsc.rs |   1 +
 test/js/sql/sql-mysql-sequence-desync.test.ts     | 280 ++++++++++++++++++++++
 4 files changed, 299 insertions(+), 1 deletion(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                               reads  edits  tests
src/sql/mysql/protocol/AnyMySQLError.rs                1      1      0
src/sql_jsc/mysql/MySQLConnection.rs                   7      9      0
src/sql_jsc/mysql/protocol/any_mysql_error_jsc.rs      1      1      0
test/js/sql/sql-mysql-sequence-desync.test.ts          1      8      0

…ult delivery

process_packets tracked the incoming sequence id but never validated it,
and the connection's expected sequence id was never reset when a new
command (seq 0) was written. Residual bytes buffered after a completed
command's terminator were routed to the next queued query, which
resolved with rows the server never produced for it.

Validate header.sequence_id against the expected value in the Connected
state and fail the connection (ERR_MYSQL_PACKETS_OUT_OF_ORDER) on
mismatch, matching libmysql's CR_NET_PACKETS_OUT_OF_ORDER. Reset the
expected id to 1 at every ready-for-next-command transition (auth OK,
result-set terminator, ERR packet, prepared-statement completion).
@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 AM PT - Jul 12th, 2026

@robobun, your commit b0d8643 has 3 failures in Build #72302 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34048

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

bun-34048 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. MySQL: query writes can bypass queued-but-unwritten requests in the native request queue #32005 - This issue describes cross-query result delivery when query writes bypass queued requests in the native request queue; the sequence_id validation added here catches exactly that scenario by rejecting packets with mismatched sequence ids rather than silently misdelivering data

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #32005

🤖 Generated with Claude Code

@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

Not linking #32005: that one is client-side write ordering (do_run writes before enqueue_request, so a later query can hit the wire ahead of a queued one). Both responses in that scenario restart at seq 1 and would pass this validation. #32008 is the fix for that.

This PR addresses the server-triggered case: residual/late bytes carrying continuation sequence ids after a command's terminator.

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 45 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: 5c90f1e6-395b-4291-b407-92ab6bdb7db1

📥 Commits

Reviewing files that changed from the base of the PR and between 647a62c and ec2f4f6.

📒 Files selected for processing (1)
  • test/js/sql/sql-mysql-sequence-desync.test.ts

Walkthrough

Changes

MySQL protocol handling now validates packet sequence IDs, resets sequence state between command exchanges, maps packet-order failures to a JavaScript error code, and adds TCP-level regression tests for desynchronized, correctly sequenced, and sequence-wrapping queued queries.

MySQL sequence validation

Layer / File(s) Summary
Packet-order error contract
src/sql/mysql/protocol/AnyMySQLError.rs, src/sql_jsc/mysql/protocol/any_mysql_error_jsc.rs
Adds the PacketsOutOfOrder protocol error and maps it to ERR_MYSQL_PACKETS_OUT_OF_ORDER.
Connection sequence enforcement
src/sql_jsc/mysql/MySQLConnection.rs
Validates incoming command-phase sequence IDs and resets sequence_id after authentication, command, prepared-statement, and result-set transitions.
Sequence desynchronization tests
test/js/sql/sql-mysql-sequence-desync.test.ts
Tests unsolicited packets after successful and failed queries, correctly sequenced queued queries, and sequence-id wrapping across more than 256 packets.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: validating MySQL response sequence IDs to stop cross-query result leakage.
Description check ✅ Passed The description covers what changed and how it was verified, even though it uses Problem/Fix/Verification headings instead of the template.
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.

@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/mysql/MySQLConnection.rs`:
- Around line 592-601: Extend the MySQL connection test coverage around the
terminal paths at the cached failure, prepared completion, prepared error, and
multi-result completion branches to queue a following command and inject stale
or mismatched packet sequences. Include the 255-to-0 command-transition
wraparound case validated by the sequence check in the connection packet
handling logic, and assert each scenario returns the expected packet-order error
without misrouting the queued command.

In `@test/js/sql/sql-mysql-sequence-desync.test.ts`:
- Around line 43-49: Condense the later narrative comment block describing
concurrent queries, the max:1 pool, and ghost result packets to no more than
three lines, preserving only the protocol invariant that sequence-id validation
must prevent unsolicited trailing packets from being routed to query B. Leave
the accepted opening fault-injection banner unchanged and apply the same cleanup
to the additional block around the referenced location.
🪄 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: 4226ec85-5b2b-40da-8f11-ec6186dbdf89

📥 Commits

Reviewing files that changed from the base of the PR and between 2e2230a and 2b333eb.

📒 Files selected for processing (4)
  • src/sql/mysql/protocol/AnyMySQLError.rs
  • src/sql_jsc/mysql/MySQLConnection.rs
  • src/sql_jsc/mysql/protocol/any_mysql_error_jsc.rs
  • test/js/sql/sql-mysql-sequence-desync.test.ts

Comment thread src/sql_jsc/mysql/MySQLConnection.rs
Comment thread test/js/sql/sql-mysql-sequence-desync.test.ts 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.

I didn't find any bugs, but this changes the MySQL wire-protocol state machine — a strict sequence-id check plus six distributed sequence_id = 1 reset points across auth-OK, result-set OK/ERR, prepared-statement done/ERR, and cached-failure paths. A missed or misplaced reset would reject every well-formed response on that connection, so this warrants a human look.

Checked: the check is gated on status == Connected, so the auth phase (which continues the handshake sequence) is exempt; all command writes go through writer.start(0) so expecting seq 1 on the first response is correct.
Checked: is_last_result == false (SERVER_MORE_RESULTS_EXISTS) skips the reset so multi-result sequences continue uninterrupted.
Checked: legacy-EOF prepared-statement completion — the intermediate EOF calls check_if_prepared_statement_is_done but only resets when both counts are satisfied; the u8 wrap uses wrapping_add on both sides.

Extended reasoning...

Overview

Adds strict validation of the MySQL packet sequence_id in process_packets (only when status == Connected), a new PacketsOutOfOrder error variant with a JS error-code mapping, and self.sequence_id = 1 resets at every ready-for-next-command transition in MySQLConnection.rs. A new fault-injection test file drives a mock TCP server through four scenarios (ghost after OK, ghost after ERR, well-formed baseline, 300-row 255→0 wrap).

Security risks

The bug being fixed is itself a data-integrity/security issue (server-injected trailing bytes delivered to the wrong query). The fix tightens validation and fails closed. No new attack surface is introduced; the only regression risk is over-rejection of legitimate traffic.

Level of scrutiny

High. This is the core packet-dispatch loop of a production database driver. The correctness of the fix depends on every command-terminal path resetting sequence_id before the next response arrives — six separate sites across simple queries, prepared statements (both CLIENT_DEPRECATE_EOF and legacy-EOF modes), error packets, and cached-failure replay. If any real-server flow reaches Connected with a response whose first packet isn't seq 1 (or whose terminal path wasn't reset), every subsequent query on that connection fails. The mock-server tests cover COM_QUERY paths well; the prepared-statement and legacy-EOF resets rely on the container-gated sql-mysql.helpers.test.ts / sql-mysql.transactions.test.ts suites, which the author reports pass against MariaDB 11.8.

Other factors

I traced the reset placement against each terminal branch and against the write side (writer.start(0) in MySQLQuery.rs / MySQLRequest.rs / Query.rs) and it looks consistent. The check correctly excludes the auth phase, and wrapping_add(1) on a u8 matches the wire's 255→0 wrap. CodeRabbit's two threads are resolved. Still, the interaction surface (re-entrant JS in on_query_result/on_error_packet, legacy vs deprecate-EOF, multi-result continuation) is subtle enough that a maintainer familiar with the MySQL driver should confirm no terminal path was missed.

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

I didn't find any bugs, but this adds a validation gate to the MySQL packet-processing loop and six sequence_id = 1 resets across command-transition sites — a missed reset would spuriously fail legitimate traffic with ERR_MYSQL_PACKETS_OUT_OF_ORDER, so it's worth a human pass on completeness.

What was reviewed:

  • All command-phase writes go through writer.start(0) (MySQLRequest.rs, MySQLQuery.rs, Query.rs), so expecting seq 1 after each reset is sound.
  • Validation is gated on ConnectionState::Connected, so auth/TLS-handshake sequence progressions are untouched.
  • Multi-result: is_last_result=false skips the reset, so SERVER_MORE_RESULTS_EXISTS continues the sequence; 255→0 wrap is handled by wrapping_add and covered by the 300-row test.
Extended reasoning...

Overview

This PR adds MySQL protocol sequence-id validation to prevent residual bytes from one command exchange being routed to the next queued query. It touches:

  • src/sql/mysql/protocol/AnyMySQLError.rs — new PacketsOutOfOrder variant (1 line)
  • src/sql_jsc/mysql/protocol/any_mysql_error_jsc.rs — maps the variant to ERR_MYSQL_PACKETS_OUT_OF_ORDER (1 line)
  • src/sql_jsc/mysql/MySQLConnection.rs — a validation check in the process_packets loop plus sequence_id = 1 resets at six ready-for-next-command transitions (auth OK, cached-failure replay, prepared-statement done, prepared-statement ERR, result-set terminator when is_last_result, result-set ERR)
  • test/js/sql/sql-mysql-sequence-desync.test.ts — 280-line new file with a mock TCP MySQL server, four tests (two desync fault-injection cases, a sequential baseline, and a 255→0 wrap baseline)

Security risks

The bug being fixed is itself a data-integrity/security issue: a hostile or desynced server/proxy could cause query B to resolve with rows meant for (or forged after) query A. The fix fails closed — the connection is torn down on mismatch, matching libmysql's CR_NET_PACKETS_OUT_OF_ORDER. No new attack surface is introduced; the risk is the inverse — an incomplete set of reset points would cause false-positive rejections on legitimate traffic.

Level of scrutiny

This is a protocol state-machine change in the MySQL driver's packet-processing hot path. The correctness of the fix depends on the six reset points covering every path where the connection becomes ready for the next seq-0 command. I traced the command-write sites (MySQLRequest.rs:17,30, MySQLQuery.rs:266, Query.rs:32,97) and confirmed they all use writer.start(0), and the validation is correctly scoped to ConnectionState::Connected so auth-phase packets (which use self.sequence_id for continuation writes at lines 854/866/1097) are exempt. But enumerating that no transition was missed — e.g. any future or less-common command path — is exactly the kind of completeness audit a human maintainer of this subsystem should sign off on.

Other factors

  • The PR description reports end-to-end verification against real MariaDB 11.8 (concurrent simple queries, prepared queries, 1000-row wrap, multi-statement SERVER_MORE_RESULTS_EXISTS, error-then-success) and clean runs of sql-mysql.helpers.test.ts and sql-mysql.transactions.test.ts.
  • The gate evidence shows the new tests fail on main (B resolves with [{g:"GHOST"}]) and pass on the PR build in both debug-ASAN and release.
  • CodeRabbit's two comments (narrative-comment length, 255→0 wrap coverage) were addressed in 647a62c and both threads are resolved.
  • No CODEOWNERS entry covers src/sql/ or src/sql_jsc/.
  • The bug-hunting system found no issues.

@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the new test/js/sql/sql-mysql-sequence-desync.test.ts and all existing test/js/sql/ suites pass on every lane across builds #72298 and #72302.

The red lanes are unrelated to this diff (which touches only src/sql/mysql/ and src/sql_jsc/mysql/):

build failing test lane
72298, 72302 test/cli/install/migration/complex-workspace.test.ts linux x64-asan
72298 test/cli/install/bun-install-registry.test.ts windows 11 aarch64
72302 test/cli/install/hoist.test.ts windows 11 aarch64
72298 test/cli/run/multi-run.test.ts macOS 14 x64
72302 test/cli/run/no-orphans.test.ts macOS 14 x64, 26 aarch64
72302 test/js/bun/s3/s3.leak.test.ts linux x64-asan
72302 test/js/bun/websocket/websocket-server.test.ts 26 aarch64
72302 test/js/node/net/net-mongodb-pattern-leak.test.ts 25.04 aarch64
72302 test/js/node/zlib/leak.test.ts windows 2019 x64-baseline
72298, 72302 test/napi/napi.test.ts windows 2019 x64 / x64-baseline / 11 aarch64

The failure set differs between the two runs, and none of the tests above exercise the MySQL wire protocol. Ready for review.

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