Skip to content

sql(mysql): bounds-check params in bind() when values array is mutated#29886

Merged
Jarred-Sumner merged 12 commits into
mainfrom
farm/9728add3/mysql-bind-oob
Apr 30, 2026
Merged

sql(mysql): bounds-check params in bind() when values array is mutated#29886
Jarred-Sumner merged 12 commits into
mainfrom
farm/9728add3/mysql-bind-oob

Conversation

@robobun

@robobun robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

Problem

MySQLQuery.bind() allocates params = alloc(Value, execute.param_types.len) — sized to the signature captured when the statement was prepared — and then iterates a fresh QueryBindingIterator over the user-supplied values array with no bounds check:

while (try iter.next()) |js_value| {
    const param = execute.param_types[i];   // OOB read when i >= len
    params[i] = try Value.fromJS(...);       // OOB heap write
    i += 1;
}

Signature.generate() and bind() each call JSArrayIterator.init(), which re-reads array.length. If the user's array grows between the two iterations, bind() writes past the end of its allocation. The array is the user's own object in the sql.unsafe(query, args) path — no defensive copy is taken.

Two concrete triggers (neither is guarded):

  1. Cached statement + side-effecting getter: prime the statement cache with sql.unsafe("select ?", [1]). Then call it again with an array whose index-0 getter pushes onto itself. Signature.generate captures length=1 before the getter fires, yields the same signature hash, and falls through to the cached .prepared statement → bindAndExecutebind() re-reads length=101 and walks off the end.
  2. Async mutation between prepare and execute: start a query (signature generated, COM_STMT_PREPARE sent), mutate the array before the server responds, then bind() sees the new length.

PostgresRequest.writeBind already handles this (if (i >= len) break :brk .text); MySQL did not.

Repro

await sql.unsafe("select ? as x", [1]); // prime cache

const values = [1];
let fired = 0;
Object.defineProperty(values, "0", {
  get() {
    if (fired++ === 0) for (let i = 0; i < 100; i++) values.push(1);
    return 1;
  },
});
await sql.unsafe("select ? as x", values);

Debug build before this change:

panic(main thread): index out of bounds: index 1, len 1
sql.mysql.MySQLQuery.bind
/src/sql/mysql/MySQLQuery.zig:27:42

In release builds this is a silent heap buffer overflow.

Fix

In bind(), return error.WrongNumberOfParametersProvided when the iterator yields more values than the allocated buffer, and likewise when it yields fewer (which would leave trailing slots uninitialized before PreparedStatement.Execute.write iterates them).

Verification

With the fix, the repro rejects cleanly:

{"ok":false,"code":"ERR_MYSQL_WRONG_NUMBER_OF_PARAMETERS_PROVIDED","message":"failed to bind and execute query"}

Added a subprocess-based regression test to test/js/sql/sql-mysql.test.ts (fixture in test/js/sql/sql-mysql-bind-oob.fixture.ts) so a regression shows up as a test failure rather than crashing the whole suite.

MySQLQuery.bind() allocates a params buffer sized to the signature that was
captured when the statement was first prepared, then iterates a fresh
QueryBindingIterator over the user-supplied values array. If the array is
mutated between signature generation and binding (e.g. via an index getter
that pushes, or between prepare and execute), the second iteration can be
longer than the first, causing an out-of-bounds read of execute.param_types
and an out-of-bounds heap write into params. PostgresRequest.writeBind
already guards this case; MySQL did not.

Fail with ERR_MYSQL_WRONG_NUMBER_OF_PARAMETERS_PROVIDED when the iterator
yields a different count than the statement expects, instead of writing past
the allocation (or leaving trailing slots uninitialized in the shrink case).
@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

bind now enforces two-way parameter-count validation and reorders binding to occur before packet header emission; tests added reproduce an iterator-expansion out-of-bounds case and verify rejection and protocol usability after failure.

Changes

Cohort / File(s) Summary
MySQL binding validation
src/sql/mysql/MySQLQuery.zig
bind now errors immediately if the iterator yields more values than execute.param_types.len and errors after iteration if fewer values were provided. bindAndExecute performs binding before writer.start, and clearing of statement.execution_flags.need_to_send_params is deferred until after successful packet completion.
Regression test: fixture
test/js/sql/sql-mysql-bind-oob.fixture.ts
New fixture that connects to MySQL, uses a values array that expands during iteration to trigger a bind out-of-bounds scenario, runs sql.unsafe("select ? as x", values), then runs a follow-up query to confirm protocol usability; prints JSON result and always closes the connection.
Regression test: runner
test/js/sql/sql-mysql-bind-oob.test.ts
New test that spawns the fixture (containerized when configured), filters expected ASAN noise, asserts CONNECTED and that the bind failed with ERR_MYSQL_WRONG_NUMBER_OF_PARAMETERS_PROVIDED, verifies the follow-up query result, and ensures process exit code is 0.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically describes the main change: adding bounds-checking to the bind() function when a values array is mutated.
Description check ✅ Passed The description comprehensively covers the problem, root cause, fix, and verification with code examples and test coverage, exceeding the basic template requirements.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@robobun

robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:03 AM PT - Apr 29th, 2026

@robobun, your commit 1cc6b1d has 4 failures in Build #49072 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29886

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

bun-29886 --bun

Comment thread src/sql/mysql/MySQLQuery.zig
robobun added 2 commits April 29, 2026 01:07
…sync

bindAndExecute previously called writer.start(0) (which appends a 4-byte
placeholder header to the connection's write buffer) before bind(). If
bind() failed — now reachable via the new param-count guard, but already
reachable via Value.fromJS / iterator errors — those bytes were left in
the buffer and prefixed the next query on the pooled connection, causing
a protocol desync (server responds 1047 Unknown command).

bind() never touches the writer, so run it first. Also defer clearing
need_to_send_params until after bind() succeeds so a failed bind doesn't
skip param types on the next execution.

The regression fixture now also asserts the connection remains usable
after the rejected bind.

@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 the current code and only fix it if needed.

Inline comments:
In `@src/sql/mysql/MySQLQuery.zig`:
- Around line 73-76: The code sets statement.execution_flags.need_to_send_params
= false before the execute packet is fully serialized which can leave the
statement in an inconsistent state if writer.start(), execute.write(), or
packet.end() fails; move the mutation of
statement.execution_flags.need_to_send_params so it only happens after the
packet is successfully completed (i.e., after packet.end() returns), or
alternatively keep the current write sequence but wrap the flag change behind a
successful packet serialization by using a try on packet.end() and then set
need_to_send_params = false (or use a defer/rollback to restore the flag on
error); update the block around this.bind(...), writer.start(...),
execute.write(...), and packet.end() to ensure need_to_send_params is only
cleared on successful packet emission.

In `@test/js/sql/sql-mysql.test.ts`:
- Around line 836-841: The test currently asserts raw stderr is empty after
launching the subprocess (proc, stdout, stderr, exitCode), which fails on
ASAN/debug shards; update the assertion to first split stderr into lines and
filter out the known ASAN startup warning (use .split(/\r?\n/).filter(line =>
!line.startsWith("WARNING: ASAN interferes")) or similar), then assert the
filtered result is empty (e.g., join back and expect("") or
expect(filteredLines).toEqual([])), keeping the rest of the await Promise.all
for proc.stdout.text(), proc.stderr.text(), proc.exited unchanged.
🪄 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: 7d732450-5ae4-4b17-a851-4d9c25f4a5f8

📥 Commits

Reviewing files that changed from the base of the PR and between 6925a6a and ade2389.

📒 Files selected for processing (3)
  • src/sql/mysql/MySQLQuery.zig
  • test/js/sql/sql-mysql-bind-oob.fixture.ts
  • test/js/sql/sql-mysql.test.ts

Comment thread src/sql/mysql/MySQLQuery.zig
Comment thread test/js/sql/sql-mysql.test.ts Outdated
robobun and others added 4 commits April 29, 2026 01:46
The test needs a MySQL server but sql-mysql.test.ts is fully
docker-gated; in environments without a docker daemon it runs 0
tests. Move the regression to its own file that uses
describeWithContainer when docker is available and otherwise falls
back to MYSQL_URL / a local 127.0.0.1:3306 instance (distinguishing
'couldn't connect' from 'connected then crashed' via a CONNECTED
marker printed after the priming query).

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/js/sql/sql-mysql-bind-oob.test.ts`:
- Around line 70-73: The current skip logic in the test uses
stdout.startsWith("CONNECTED") to bail out, which can incorrectly skip tests
when an explicit MYSQL_URL was provided; change the condition so the test only
skips when stdout does not start with "CONNECTED" AND process.env.MYSQL_URL is
unset/empty. Locate the check using stdout.startsWith("CONNECTED") (and the url
variable) and update it to require absence of MYSQL_URL before returning so
explicit fixture configuration still fails the test instead of passing silently.
🪄 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: 352c1871-71fe-484e-9ab7-180d0d6e6350

📥 Commits

Reviewing files that changed from the base of the PR and between 6156bc3 and 8fd83e8.

📒 Files selected for processing (2)
  • test/js/sql/sql-mysql-bind-oob.fixture.ts
  • test/js/sql/sql-mysql-bind-oob.test.ts

Comment thread test/js/sql/sql-mysql-bind-oob.test.ts Outdated

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

♻️ Duplicate comments (1)
test/js/sql/sql-mysql-bind-oob.test.ts (1)

68-70: ⚠️ Potential issue | 🟡 Minor

Do not silently skip when MYSQL_URL is explicitly set.

On Line [68]-Line [70], the test can pass even when a user-provided MYSQL_URL is broken or regressed. That hides real failures in configured local runs.

Proposed fix
-      if (!stdout.startsWith("CONNECTED")) {
-        console.warn("sql-mysql-bind-oob: no MySQL reachable at " + url + "; skipping assertions");
-        return;
-      }
+      if (!stdout.startsWith("CONNECTED")) {
+        if (process.env.MYSQL_URL) {
+          throw new Error(
+            "sql-mysql-bind-oob: MYSQL_URL was provided, but fixture never reached CONNECTED\n" +
+              `stdout:\n${stdout}\n` +
+              `stderr:\n${stderr}`,
+          );
+        }
+        console.warn("sql-mysql-bind-oob: no MySQL reachable at " + url + "; skipping assertions");
+        return;
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/js/sql/sql-mysql-bind-oob.test.ts` around lines 68 - 70, The test
currently silently returns when stdout doesn't start with "CONNECTED", hiding
failures when a user-supplied MYSQL_URL is set; change the early-return logic
around stdout and url so that if process.env.MYSQL_URL (the user-provided
MYSQL_URL) is present the test fails loudly (throw an Error or use an assertion)
instead of console.warn/return, and only skip with the existing warning when
MYSQL_URL is not set; update the block that references stdout and url to check
process.env.MYSQL_URL and call assert.fail or throw new Error("MYSQL_URL
provided but MySQL not reachable: " + url) when it is set.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@test/js/sql/sql-mysql-bind-oob.test.ts`:
- Around line 68-70: The test currently silently returns when stdout doesn't
start with "CONNECTED", hiding failures when a user-supplied MYSQL_URL is set;
change the early-return logic around stdout and url so that if
process.env.MYSQL_URL (the user-provided MYSQL_URL) is present the test fails
loudly (throw an Error or use an assertion) instead of console.warn/return, and
only skip with the existing warning when MYSQL_URL is not set; update the block
that references stdout and url to check process.env.MYSQL_URL and call
assert.fail or throw new Error("MYSQL_URL provided but MySQL not reachable: " +
url) when it is set.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c8e0197b-76a9-42b6-a5a6-5245dde762ce

📥 Commits

Reviewing files that changed from the base of the PR and between 8fd83e8 and 325c8ff.

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

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟣 src/sql/mysql/MySQLQuery.zig:73 — Pre-existing, but worth a follow-up: the PR description's trigger #2 (array mutated between prepare and execute) flows through MySQLRequestQueue.advance, and on that path the new error.WrongNumberOfParametersProvided causes the user's await to hang forever instead of rejecting. run()'s errdefer (JSMySQLQuery.zig:277-280) marks the query .fail before re-throwing, then connection.onError → rejectWithJSValue sees status already .fail at line 237 and returns early without invoking onQueryRejectFn. This double-fail() short-circuit predates this PR (it already swallowed Value.fromJS / InvalidQueryBinding errors on the same path) and the fix lives in JSMySQLQuery.zig, not in the files touched here — flagging only because the description cites trigger #2 as covered when in practice it trades the heap overflow for a hung promise.

    Extended reasoning...

    What the bug is

    The PR lists two triggers for the OOB. Trigger #1 (cached statement, synchronous doRun path) is fixed and tested by the new fixture. Trigger #2 — "async mutation between prepare and execute" — takes a different path: the request is enqueued, the server replies to COM_STMT_PREPARE, and checkIfPreparedStatementIsDone calls queue.advance(connection), which re-invokes request.run() to bind and execute. When bind() returns the new error.WrongNumberOfParametersProvided on that path, the user's promise is never settled.

    The root cause is a double state transition. JSMySQLQuery.run() registers:

    errdefer {
        this.#thisValue.downgrade();
        _ = this.#query.fail();   // sets #status = .fail
    }

    at JSMySQLQuery.zig:277-280, then re-throws error.JSError. Back in MySQLRequestQueue.advance (line 109-118) the catch calls connection.onError(request, err)JSMySQLConnection.onError (726-736) → tryTakeException()request.rejectWithJSValue(...). But rejectWithJSValue (JSMySQLQuery.zig:237) does:

    if (!this.#query.fail()) {
        return;
    }

    and MySQLQuery.fail() returns false when #status is already .fail. So rejectWithJSValue returns early before invoking onQueryRejectFn. advance then discards the request from the queue and derefs it; the JS promise is left pending forever.

    Step-by-step proof (trigger #2)

    1. sql.unsafe("select ? as x", values) with a fresh signature → doRunrunrunPreparedQuery sends COM_STMT_PREPARE (statement.status = .parsing), returns OK, request is enqueued. The user's array is captured by reference as binding_value.
    2. User code mutates values.push(...) while the round-trip is in flight.
    3. Server replies prepare-OK → checkIfPreparedStatementIsDone (MySQLConnection.zig:841-851) sets statement.status = .prepared, #is_ready_for_query = true, #waiting_to_prepare = false, then calls queue.advance(connection).
    4. advance peeks the request: isCompleted() false, isBeingPrepared() false (statement is now .prepared), isRunning() false (#status == .pending), so it calls request.run(connection) (line 109).
    5. run() passes the guard at line 270, upgrades #thisValue, registers the errdefer, and calls runQuery → runPreparedQuery. The statement is .prepared and canPipeline() is true, so bindAndExecute runs.
    6. bind() re-iterates the (now longer) array and at i == params.len returns the new error.WrongNumberOfParametersProvided.
    7. runPreparedQuery catches it at MySQLQuery.zig:157, throws a JS exception, returns error.JSError. run()'s catch at 284-289 returns error.JSError. The errdefer fires → this.#query.fail() sets #status = .fail.
    8. Back in advance: connection.onError(request, err)JSMySQLConnection.onErrortryTakeException() finds the pending exception → request.rejectWithJSValue(queries_array, err_).
    9. rejectWithJSValue calls this.#query.fail() → returns false (already .fail) → !falseearly return without calling onQueryRejectFn.
    10. advance discards the request (this.#requests.discard(1); request.deref();). The user's await sql.unsafe(...) never resolves or rejects.

    Why the new test doesn't catch it

    The fixture primes the statement cache with a first call so the failing call hits a .prepared statement synchronously inside doRun. There, run() throws → doRun's own catch (JSMySQLQuery.zig:114) returns error.JSError straight to the JS caller before enqueueRequest, and the JS layer turns that into a rejection. MySQLRequestQueue.advance is never involved, so the double-fail() path isn't exercised.

    Why existing code doesn't prevent it

    rejectWithJSValue deliberately guards on fail() to avoid double-rejecting an already-settled query, and run()'s errdefer pre-emptively marks .fail to keep the query from being retried. Both are individually reasonable, but together they mean any error thrown out of run() on the advance path is silently dropped. Nothing else on this path (neither onError nor advance) invokes the JS rejection callback.

    Impact

    For trigger #2 the PR converts a heap overflow into a hung await — strictly safer, but not the "clean rejection" the description claims. In practice the array-mutation-during-prepare scenario is adversarial / pathological, so the user-visible impact is small; the more important point is that this same short-circuit already swallows Value.fromJS errors, getter exceptions, and error.InvalidQueryBinding whenever they fire during the post-prepare bind, and this PR adds two more error returns that flow through it.

    How to fix

    This is pre-existing and lives outside the files this PR touches. Either:

    • Drop _ = this.#query.fail(); from run()'s errdefer (JSMySQLQuery.zig:279) and let rejectWithJSValue own the .pending → .fail transition; or
    • Change rejectWithJSValue to invoke onQueryRejectFn even when the status is already .fail (and rely on the JS side / isCompleted() to dedupe).

    Either way it's a small follow-up; not blocking for this PR.

@robobun robobun force-pushed the farm/9728add3/mysql-bind-oob branch from 3e94aac to 89590a3 Compare April 29, 2026 02:35
Comment thread test/js/sql/sql-mysql-bind-oob.test.ts

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

The force-push resolved my previous blocker — MySQLQuery.zig now contains the bounds-check + reorder and the unrelated files are gone. Deferring to a human for final sign-off since this is a memory-safety fix that reorders the prepared-statement execute path.

Extended reasoning...

Overview

The PR now touches exactly three files: src/sql/mysql/MySQLQuery.zig (the actual fix), plus a new subprocess fixture and standalone test under test/js/sql/. The Zig change adds an i >= params.len overflow guard and an i != params.len underflow guard inside bind(), moves bind() to run before writer.start(0) so a bind failure doesn't leave a stale 4-byte header in the connection write buffer, and defers need_to_send_params = false until after packet.end() succeeds. All four review threads (write-buffer desync, need_to_send_params placement, ASAN stderr filtering, and the MYSQL_URL skip narrowing) are resolved, and the botched 3e94aac commit that I flagged last round has been replaced by a clean 89590a3git diff f133e38..89590a3 -- src/sql/mysql/MySQLQuery.zig is now empty and the changed-files list is back to 3.

Security risks

This is a security fix: it closes a user-triggerable heap buffer overflow in the MySQL prepared-statement bind path (OOB write into a bun.default_allocator.alloc'd slice when a side-effecting array getter grows the params array between signature generation and binding). The change is strictly defensive — it adds bounds checks and reorders operations so failures don't corrupt connection state. I don't see any new attack surface introduced; the errdefer that frees params[0..i] already covers the new early-return paths correctly.

Level of scrutiny

High. This is native memory-safety code in a database driver, and the bindAndExecute reorder changes when bytes hit the wire-protocol write buffer relative to JS-observable side effects. The change has been through five review rounds including one force-push that briefly dropped the fix entirely. While I've verified the current diff is correct and self-consistent, a maintainer familiar with the MySQL connection/statement lifecycle should confirm the reorder has no unintended interaction with pipelining or the statement cache.

Other factors

The regression test exercises both the bounds check (asserts ERR_MYSQL_WRONG_NUMBER_OF_PARAMETERS_PROVIDED) and the write-buffer reorder (asserts a follow-up query on the same pooled connection still returns [{x: 2}]), runs against the docker MySQL container in CI, and follows repo conventions for ASAN-stderr filtering and exit-code-last assertion ordering. No CODEOWNERS entry covers src/sql/mysql/.

Comment thread test/js/sql/sql-mysql-bind-oob.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.

All prior review feedback is addressed and the current diff looks correct to me, but since this hardens a user-triggerable heap overflow and reorders packet emission in bindAndExecute, I'd like a human familiar with the MySQL driver to give it a final look.

Extended reasoning...

Overview

Three files: src/sql/mysql/MySQLQuery.zig adds two bounds checks in bind() (overflow guard inside the iterator loop, underflow guard after it) returning the pre-existing error.WrongNumberOfParametersProvided, and reorders bindAndExecute() so bind() runs before writer.start(0) and need_to_send_params is cleared only after packet.end(). Two new test files add a subprocess-based regression that exercises both the bounds check and post-failure connection usability against a real MySQL.

Security risks

The change removes a security risk — a user-triggerable heap buffer overflow via a side-effecting array index getter passed to sql.unsafe(). The fix is purely defensive (more validation, earlier failure). I see no new injection, auth, or data-exposure surface introduced. The reorder means a bind failure no longer leaves a stale 4-byte header in the pooled connection's write buffer, which was a protocol-desync risk I flagged earlier and is now covered by the fixture's follow-up query assertion.

Level of scrutiny

Moderate-to-high. The Zig change is small (~20 lines) and the logic is clear, but it sits on the prepared-statement execute path of a production database driver and changes the relative ordering of packet-header emission vs. JS value coercion. I traced bind() and confirmed it never touches the writer, so moving it earlier is safe; execute.new_params_bind_flag is captured before the flag mutation in both old and new orderings, so the success path is unchanged. Still, per the approval guidelines I should not auto-approve security-sensitive code.

Other factors

Every inline comment on this PR (mine on the write-buffer rollback and the dropped-fix force-push, CodeRabbit's on need_to_send_params ordering, ASAN stderr filtering, skip-condition narrowing, and the per-test timeout) is resolved in the current head (1451d6d). The error enum and JS error code already existed in AnyMySQLError.zig. No CODEOWNERS entry covers src/sql/. The early CI comment showed musl build-infra failures unrelated to this change. The bug-hunting system found nothing in the current revision.

@Jarred-Sumner Jarred-Sumner merged commit b1d945b into main Apr 30, 2026
74 of 77 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/9728add3/mysql-bind-oob branch April 30, 2026 23:50
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
oven-sh#29886)

## Problem

`MySQLQuery.bind()` allocates `params = alloc(Value,
execute.param_types.len)` — sized to the signature captured when the
statement was prepared — and then iterates a **fresh**
`QueryBindingIterator` over the user-supplied values array with no
bounds check:

```zig
while (try iter.next()) |js_value| {
    const param = execute.param_types[i];   // OOB read when i >= len
    params[i] = try Value.fromJS(...);       // OOB heap write
    i += 1;
}
```

`Signature.generate()` and `bind()` each call `JSArrayIterator.init()`,
which re-reads `array.length`. If the user's array grows between the two
iterations, `bind()` writes past the end of its allocation. The array is
the user's own object in the `sql.unsafe(query, args)` path — no
defensive copy is taken.

Two concrete triggers (neither is guarded):

1. **Cached statement + side-effecting getter**: prime the statement
cache with `sql.unsafe("select ?", [1])`. Then call it again with an
array whose index-0 getter pushes onto itself. `Signature.generate`
captures `length=1` before the getter fires, yields the same signature
hash, and falls through to the cached `.prepared` statement →
`bindAndExecute` → `bind()` re-reads `length=101` and walks off the end.
2. **Async mutation between prepare and execute**: start a query
(signature generated, `COM_STMT_PREPARE` sent), mutate the array before
the server responds, then `bind()` sees the new length.

`PostgresRequest.writeBind` already handles this (`if (i >= len) break
:brk .text`); MySQL did not.

## Repro

```js
await sql.unsafe("select ? as x", [1]); // prime cache

const values = [1];
let fired = 0;
Object.defineProperty(values, "0", {
  get() {
    if (fired++ === 0) for (let i = 0; i < 100; i++) values.push(1);
    return 1;
  },
});
await sql.unsafe("select ? as x", values);
```

Debug build before this change:

```
panic(main thread): index out of bounds: index 1, len 1
sql.mysql.MySQLQuery.bind
/src/sql/mysql/MySQLQuery.zig:27:42
```

In release builds this is a silent heap buffer overflow.

## Fix

In `bind()`, return `error.WrongNumberOfParametersProvided` when the
iterator yields more values than the allocated buffer, and likewise when
it yields fewer (which would leave trailing slots uninitialized before
`PreparedStatement.Execute.write` iterates them).

## Verification

With the fix, the repro rejects cleanly:

```
{"ok":false,"code":"ERR_MYSQL_WRONG_NUMBER_OF_PARAMETERS_PROVIDED","message":"failed to bind and execute query"}
```

Added a subprocess-based regression test to
`test/js/sql/sql-mysql.test.ts` (fixture in
`test/js/sql/sql-mysql-bind-oob.fixture.ts`) so a regression shows up as
a test failure rather than crashing the whole suite.

---------

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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.

3 participants