Skip to content

sql: detach the stored socket in on_close/on_connect_error - #32861

Open
robobun wants to merge 4 commits into
mainfrom
farm/d0f6b155/sql-detach-socket-on-close
Open

sql: detach the stored socket in on_close/on_connect_error#32861
robobun wants to merge 4 commits into
mainfrom
farm/d0f6b155/sql-detach-socket-on-close

Conversation

@robobun

@robobun robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

What

PostgresSQLConnection and JSMySQLConnection kept the raw us_socket_t* in self.socket after usockets dispatched on_close / on_connect_error. usockets frees closed sockets at us_internal_loop_post later in the same tick, so any subsequent read through self.socket (the connectionTimeout EventLoopTimer, ref() / unref() / close() on the native handle, update_has_pending_activity) dereferenced freed memory.

ASan signature from the reproduction:

AddressSanitizer: heap-use-after-free
READ of size 1 at 0x...05d2 (s->flags.is_closed, offset +18 of a freed 104-byte us_socket_t)
  us_socket_is_closed                      packages/bun-usockets/src/socket.c:143
  PostgresSQLConnection::update_has_pending_activity / ref_and_close
freed by:      us_internal_free_closed_sockets  (end of tick)
allocated by:  us_create_poll ← us_socket_group_connect

Fix

Detach the stored handle at the start of SocketHandler::on_close and SocketHandler::on_connect_error for both drivers, matching what the Valkey client already does in src/runtime/valkey_jsc/js_valkey.rs. A detached socket reports is_closed() == true without dereferencing anything, and write() / close() on it are no-ops.

Test

test/js/sql/sql-connection-socket-uaf.test.ts captures the native connection handle by shadowing the query handle's .run(connection, query), lets the mock server drop the socket, yields past us_internal_free_closed_sockets, then calls .ref() (Postgres) / .close() (MySQL) on the native handle. Under ASan this deterministically reads the freed is_closed flag without the fix and exits cleanly with it.

Existing coverage that exercises the modified callbacks still passes:

  • test/js/sql/sql-connect-error-reporting.test.ts
  • test/js/sql/sql-close-pending-connection.test.ts
  • test/js/sql/sql-mysql-clean-reentry.test.ts
  • test/js/sql/postgres-tls-ctx-leak.test.ts

Related: #32573 fixes a separate re-entrant TLS close crash and also detaches in Postgres on_close; this PR additionally covers on_connect_error for both drivers and adds a deterministic ASan regression.

PostgresSQLConnection and JSMySQLConnection kept the raw us_socket_t
pointer in self.socket after the on_close/on_connect_error dispatch.
usockets frees closed sockets at us_internal_loop_post later in the
same tick, so any subsequent read of self.socket (the connectionTimeout
EventLoopTimer, ref()/unref()/close() on the native handle) dereferenced
freed memory.

Detach the stored handle at the start of on_close and on_connect_error,
matching what the Valkey client already does.
@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: aab6b183-6dac-4338-82c9-df77fdcfb8aa

📥 Commits

Reviewing files that changed from the base of the PR and between 59d4ce9 and 384bf5a.

📒 Files selected for processing (1)
  • test/js/sql/sql-connection-socket-uaf.test.ts

Walkthrough

Adds socket detachment in on_close and on_connect_error for both MySQL and Postgres drivers, and adds a fault-injection test that exercises the post-close path under debug/ASAN.

Socket UAF Fix

Layer / File(s) Summary
Detach socket before close/error callbacks
src/sql_jsc/mysql/JSMySQLConnection.rs, src/sql_jsc/postgres/PostgresSQLConnection.rs
Both drivers now replace the stored TCP socket with a detached SocketTCP before close or connection-error callback logic runs.
Fault-injection socket UAF test
test/js/sql/sql-connection-socket-uaf.test.ts
Adds a subprocess test with Postgres and MySQL handshake-only servers, captures the native connection, destroys the socket, touches the connection after cleanup, and asserts clean exit.

Possibly related PRs

  • oven-sh/bun#32028: Modifies the same MySQL/Postgres uSockets close/connect-failure handlers in JSMySQLConnection.rs and PostgresSQLConnection.rs.

Suggested reviewers

  • cirospaciari
  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: detaching SQL sockets on close/error.
Description check ✅ Passed The description covers the fix and includes clear test verification, though its headings differ from 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.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:00 PM PT - Jun 27th, 2026

@robobun, your commit 384bf5a has 3 failures in Build #65627 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32861

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

bun-32861 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Crash in createInstance in Postgres client in Bun.SQL #24434 - Crash in createInstance in Postgres client — segfault during PostgresSQLConnection__createInstance in a microtask is consistent with accessing a freed us_socket_t* after close events have fired
  2. Bun crashes when using bun test with the SQL built-in library (PostgreSQL) #23305 - Bun crashes when using bun test with SQL (PostgreSQL) — segfault at heap address during Postgres usage with no clear application-level cause, consistent with socket use-after-free after connection close

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

Fixes #24434
Fixes #23305

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. sql(postgres,mysql): guard ref_and_close against re-entrant TLS close dispatch #32573 - Both PRs detach the stored us_socket_t* in PostgresSQLConnection::on_close with identical code to fix the same use-after-free class; sql(postgres,mysql): guard ref_and_close against re-entrant TLS close dispatch #32573 is a superset that also guards ref_and_close/close() against re-entrant TLS close dispatch

🤖 Generated with Claude Code

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Re the duplicate flag: #32573 and this PR overlap on the Postgres on_close detach but are not supersets of each other.

#32573 fixes a separate re-entrant TLS close crash (Sentry BUN-3KKD) and, as part of that, detaches in Postgres on_close and in MySQLConnection::close(). It does not touch on_connect_error for either driver, and its own PR body notes the test is "coverage for the re-entry path rather than a deterministic fail-before".

This PR detaches in on_close and on_connect_error for both drivers, and the test in test/js/sql/sql-connection-socket-uaf.test.ts deterministically triggers the ASan heap-use-after-free at us_socket_is_closed without the fix (no race window needed).

The two can merge in either order; whichever lands second will have a trivial rebase on the shared Postgres on_close hunk.

@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 — small, well-tested UAF fix that mirrors the existing Valkey detach pattern.

Extended reasoning...

Overview

This PR adds a single line (plus comment) at the top of four socket callbacks — on_close and on_connect_error in both JSMySQLConnection.rs and PostgresSQLConnection.rs — replacing the stored us_socket_t* with SocketTCP::detached() before any user-visible callback runs. It also adds a deterministic ASAN regression test (sql-connection-socket-uaf.test.ts) that captures the native connection handle, drops the server socket, yields past us_internal_free_closed_sockets, and then touches the handle.

Security risks

None introduced. The change removes a heap-use-after-free: previously the stored raw socket pointer remained set after usockets queued it for free at end-of-tick, so later is_closed() / close() / ref() calls dereferenced freed memory. A detached socket reports is_closed() == true without dereferencing and makes write()/close() no-ops, so downstream code (the failclean_queue_and_close path, timer callbacks, update_has_pending_activity) behaves identically minus the UAF.

Level of scrutiny

Low-to-medium. Although this is native socket-lifecycle code, the fix is purely additive, mechanical, and copies the exact pattern already shipping in src/runtime/valkey_jsc/js_valkey.rs (lines ~2022 and ~2052) for the same two callbacks. set_socket is a trivial field assignment, and nothing in the subsequent fail/on_close paths needs the live socket pointer — they only call close() on it, which is already a no-op for an about-to-be-freed closed socket and remains a no-op for a detached one.

Other factors

  • No CODEOWNERS cover these paths.
  • The bug-hunting system found no issues.
  • The PR description lists four existing tests exercising these callbacks that still pass, and the new test is ASAN-gated so it won't flake on release builds.
  • The test relies on monkey-patching the query handle's .run via a symbol lookup, which is a bit fragile to internal refactors, but that's acceptable for a fault-injection regression test and is clearly commented as a do-not-copy pattern.

@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 `@test/js/sql/sql-connection-socket-uaf.test.ts`:
- Around line 1-18: Trim the header comment in the sql-connection-socket-uaf
regression test to 3 lines max and remove PR/bug-history details. Keep only a
brief note about the fault-injection purpose and the socket use-after-free
behavior, referring to the existing test fixture and the native
connection/socket handling in sql-connection-socket-uaf.test.ts.
- Around line 78-88: The startup wait in the SQL socket test only listens for
"listening", so a failed server.listen() can hang instead of rejecting. Update
the server startup await in the net.Server fixture to use once(server,
"listening") so it also attaches the "error" listener and rejects on listen
failure. Keep the change localized to the test setup around server.listen(),
socketRef, and onSocket.
🪄 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: 601cbbea-bc94-4845-a377-3667af1c7912

📥 Commits

Reviewing files that changed from the base of the PR and between df92f8f and 59d4ce9.

📒 Files selected for processing (3)
  • src/sql_jsc/mysql/JSMySQLConnection.rs
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • test/js/sql/sql-connection-socket-uaf.test.ts

Comment thread test/js/sql/sql-connection-socket-uaf.test.ts Outdated
Comment thread test/js/sql/sql-connection-socket-uaf.test.ts Outdated
Use once(server, 'listening') so a listen error rejects instead of
hanging, and tighten the header comment.

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

  • 🟡 test/js/sql/sql-connection-socket-uaf.test.ts:161-163 — Two test-convention nits on adjacent lines: (1) line 163 passes 30_000 as an explicit per-test timeout — test/CLAUDE.md says "Do not set a timeout on tests", and the Bun.spawn already has timeout: 20_000 so it's redundant; (2) line 161 asserts stderr: "" exactly in a test that only runs under debug/ASAN — root CLAUDE.md says not to require stderr to be exactly empty since those builds can emit benign noise. Consider dropping the 30_000 and using stderr: expect.not.stringContaining("AddressSanitizer") (or expect.any(String)) like the sibling tests in this directory.

    Extended reasoning...

    What

    Two repo-guideline nits in the new test file, both on the final assertion/timeout lines:

    expect({ stdout, stderr, exitCode }).toEqual({ stdout: "ok\n", stderr: "", exitCode: 0 });   // line 161
    },
    30_000,                                                                                       // line 163

    (1) Explicit per-test timeout

    test/CLAUDE.md:120 states:

    CRITICAL: Do not set a timeout on tests. Bun already has timeouts.

    The test passes 30_000 as the third argument to test.skipIf(...)(...). It's also redundant in practice: the subprocess is already bounded by Bun.spawn({ ..., timeout: 20_000 }) at line 156, so the test body cannot run longer than ~20s of subprocess time plus a few awaits. Dropping the 30_000 brings it in line with the guideline without changing behaviour. (A handful of existing files in test/js/sql/ do set explicit timeouts, so this isn't strictly enforced — but no reason to add a new one when the spawn timeout already covers it.)

    (2) Exact-empty stderr under debug/ASAN

    Root CLAUDE.md:195 (Landing PRs → Subprocess tests) says:

    Never assert stderr is exactly empty (ASAN/debug builds emit benign warnings); assert a combined { stdout, stderr, exitCode } object.

    This test is gated by skipIf(!isDebug && !isASAN) — i.e. it only runs in the configurations the guideline is warning about. The combined-object shape is right, but stderr: "" means any benign diagnostic on stderr (mimalloc warning, debug-build chatter, ASAN informational line) would fail the test even though the UAF fix is working. The actual failure signal here is an ASAN heap-use-after-free report plus a non-zero exit code — neither of which requires stderr to be byte-for-byte empty.

    Sibling tests in test/js/sql/ use the more robust pattern:

    • sql-onconnect-onclose-throw.test.ts:106stderr: expect.any(String)
    • sql-mysql-columns-realloc-oom.test.ts:112stderr: expect.any(String)
    • several others filter stderr for "AddressSanitizer" before asserting.

    Step-by-step example of how the stderr assertion could spuriously fail:

    1. CI runs the debug+ASAN job; isDebug is true so the test runs.
    2. The fixture works correctly: the socket is detached, no UAF, the child prints ok\n and calls process.exit(0).
    3. The debug allocator or ASAN runtime emits a one-line informational warning to stderr (e.g. an env-var parse note or a mimalloc message) — not an error, just noise.
    4. exitCode is 0, stdout is "ok\n", but stderr is "<warning>\n" instead of "".
    5. toEqual({ ..., stderr: "" }) fails → red CI for a passing fix.

    Addressing the counter-argument: one verifier noted that sql-mysql-clean-reentry.test.ts:137 (and node-tls-duplex-close-throw-uaf.test.ts) already assert stderr: "" under the same gating with the same bunEnv + ASAN_OPTIONS + process.exit(0) setup, and they're green — so the practical flake risk today is low. That's fair, and is why this is filed as a nit rather than a blocking issue: bunEnv sets BUN_DEBUG_QUIET_LOGS=1 and process.exit(0) skips atexit leak summaries, which together suppress most known noise sources. But the repo guideline is explicit, the more-robust pattern is already the dominant one in this directory, and matching it costs nothing.

    Suggested change

    expect({ stdout, stderr, exitCode }).toEqual({
      stdout: "ok\n",
      stderr: expect.not.stringContaining("AddressSanitizer"),
      exitCode: 0,
    });
    },
    // drop the 30_000
    );

    This keeps stderr in the failure diff (so an actual ASAN report is visible when the test fails), asserts on the real failure signal, and removes the redundant timeout.

The Bun.spawn timeout already bounds the subprocess, and the default
test timeout covers the pass case. Use expect.any(String) for stderr
(like the sibling sql-onconnect-onclose-throw and
sql-mysql-columns-realloc-oom tests) so benign debug/ASAN noise does
not spuriously fail the test; the real signal is stdout and exitCode.
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed in 384bf5a: dropped the per-test 30_000 (the pass case runs in ~2.3s locally and on the ASan CI lane, and await using proc handles cleanup if the subprocess hangs past the default), and switched stderr: "" to stderr: expect.any(String) matching sql-onconnect-onclose-throw.test.ts / sql-mysql-columns-realloc-oom.test.ts. Verified fail-before still shows the ASan report in the diff and pass-after is clean.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

CI status (build 65627, final): every red lane is infra or an unrelated pre-existing flake; the diff is ready.

  • darwin-26-aarch64-test-bun (red on all 3 builds): buildkite-agent artifact download timed out after 120s before any tests run. Agent darwin-aarch64-26-5-1-1 never downloads the binary.
  • darwin-14-aarch64-test-bun:
    • test/js/web/websocket/autobahn.test.ts: the autobahn Docker image is linux/amd64 and the agent is linux/arm64, exec format error. Infra.
    • test/js/bun/http/fetch-file-upload.test.ts (uploads roundtrip with sendfile()) and test/js/bun/http/bun-serve-file.test.ts: per-test timeouts in the HTTP server suite. No code path in common with this PR's 4-line SQL socket-detach change.

Everything that runs the new test (all x64-asan shards) is green, and none of the red tests touch src/sql_jsc/.

@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 — small, pattern-matching UAF fix mirroring the Valkey client, with a deterministic ASAN regression test.

Extended reasoning...

Overview

This PR adds a single statement to four callback sites — SocketHandler::on_close and SocketHandler::on_connect_error in both JSMySQLConnection.rs and PostgresSQLConnection.rs — that replaces the stored us_socket_t* with a detached sentinel before any downstream logic runs. usockets frees closed sockets at end-of-tick, so the stale pointer was a heap-use-after-free if touched afterward (timer callbacks, ref()/unref()/close(), update_has_pending_activity). A new fault-injection test (test/js/sql/sql-connection-socket-uaf.test.ts) deterministically reproduces the ASAN failure without the fix.

Security risks

None. This strictly narrows the lifetime of a raw pointer; a detached SocketTCP reports is_closed() == true and no-ops on write()/close(), so the change cannot introduce new behavior beyond making the already-closed socket observe as closed sooner. No auth, crypto, or input handling is touched.

Level of scrutiny

The native change is tiny and mechanical: four one-line additions, each identical in shape to what the Valkey client already does at src/runtime/valkey_jsc/js_valkey.rs:2022 and :2052. The fix is defensive (overwriting a stored pointer with a sentinel) and sits at the very start of each handler, before any control flow that could re-enter — there is no ordering subtlety with the existing DerefOnDrop guard in MySQL's on_close since set_socket is a plain store. The PR description includes the ASAN signature and confirms the existing close/connect-error tests still pass.

Other factors

All CodeRabbit feedback (comment trimming, once(server, "listening"), dropping the redundant per-test timeout, relaxing the stderr assertion to expect.any(String)) has been addressed and the threads are resolved. The bug-hunting system found nothing. The musl build failures reported on an earlier commit are in the build-bun step itself and unrelated to four added Rust statements that compile on every other shard. The known overlap with #32573 on the Postgres on_close hunk is a trivial rebase, not a correctness concern. No CODEOWNERS apply.

Jarred-Sumner pushed a commit that referenced this pull request Jun 28, 2026
…ed (#33016)

A backend message that fails the connection can share a TCP read with
messages that follow it. `PostgresRequest::on_data`'s message loop had
no bail-out once `fail()` had run, so the trailing messages in that read
kept being dispatched against the already-failed connection.

### Repro

A mock backend that answers the StartupMessage with one write carrying
two messages:

```
R  int32(8) int32(99)   Authentication, unrecognized type
Z  int32(5) 'I'         ReadyForQuery
```

```ts
const sql = new SQL({ url: `postgres://u@127.0.0.1:${port}/db`, max: 1, idleTimeout: 1, connectionTimeout: 5 });
await sql`select 1`.catch(() => {});
await Bun.sleep(1600);
```

### Cause

The unrecognized `Authentication` type calls `fail()`, which sets the
status to `Failed`, closes the socket, and rejects the pending requests,
but the message loop keeps going and dispatches the `ReadyForQuery` from
the same read. That calls `set_status(Status::Connected)`, which has no
guard against leaving `Failed`, so the dead connection is flipped back
to `Connected` and the `on_data` epilogue re-arms its idle timer.
uSockets frees a closed `us_socket_t` at the end of the event-loop
iteration, so when the timer later fires, `ref_and_close` reads the
freed socket:

```
ERROR: AddressSanitizer: heap-use-after-free
READ of size 1 at 0x71f2125605d2 thread T0
    #0 us_socket_is_closed                              packages/bun-usockets/src/socket.c:143:21
    #4 PostgresSQLConnection::ref_and_close             src/sql_jsc/postgres/PostgresSQLConnection.rs:1528:31
    #5 PostgresSQLConnection::fail_with_js_value        src/sql_jsc/postgres/PostgresSQLConnection.rs:726:14
    #6 PostgresSQLConnection::fail_fmt                  src/sql_jsc/postgres/PostgresSQLConnection.rs:749:14
    #7 PostgresSQLConnection::on_connection_timeout     src/sql_jsc/postgres/PostgresSQLConnection.rs:557:14
    #8 __bun_fire_timer                                 src/runtime/dispatch.rs:1020:35
0x71f2125605d2 is located 18 bytes inside of 104-byte region
freed by thread T0 here:
    #2 us_internal_free_closed_sockets                  packages/bun-usockets/src/loop.c:305:9
```

### Fix

- `PostgresRequest::on_data`: the message loop returns once the
connection's status is `Failed`. `fail()` is terminal; nothing after it
in the same read should be handled (a `DataRow`, `CommandComplete`, or
`ErrorResponse` in that position would be just as wrong as the
`ReadyForQuery`).
- `PostgresSQLConnection::set_status`: refuses to transition out of
`Failed`. The transition function owns that invariant; every other
consumer of `Status` (the timer interval, `update_has_pending_activity`,
the idempotency check in `fail_with_js_value`) already assumes `Failed`
is terminal.

### Verification

`test/js/sql/postgres-failed-connection-resurrection.test.ts` runs a
fixture against the mock backend above and lets it outlive the
idle-timer window. Without the fix the fixture dies with the ASan report
above; with it the fixture exits 0. Gated to ASan builds because the bug
is a read of freed memory, which release lanes do not detect.

The postgres fault-injection and integration suites still pass locally
(90 tests across `test/js/sql/postgres-*.test.ts`, `sql*.test.ts`,
`tls-sql.test.ts`).

### Related

- #32861 detaches the stored socket handle in `on_close` /
`on_connect_error` so nothing can dereference the freed `us_socket_t`
regardless of how the stale read is reached. It removes the last step of
this chain from the other end; this PR stops the failed connection from
being resurrected at all.
- #30950 guards the JS pool's `handleConnected` against the reverse
ordering within one read (a legitimately queued `onconnect` microtask
arriving after a synchronous `onclose`).
@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Independent confirmation of the MySQL half of this from a new fuzz report, via a different trigger: instead of the server dropping the socket, a scripted server completes the handshake and auth, then answers the first post-auth command with a byte that is neither OK nor ERR. The client reports UnexpectedPacket, tears the connection down itself (on_error(None) -> fail_with_js_value -> deferred clean_queue_and_close), and a later MySQLConnection.close() from JS takes the Failed arm of do_close and passes the freed us_socket_t* back to us_socket_close.

Reproduced 3/3 under ASan on today's main (d816daf):

==ERROR: AddressSanitizer: heap-use-after-free
READ of size 8
  #0 us_socket_close                                packages/bun-usockets/src/socket.c:340
  #4 <MySQLConnection>::close                       src/sql_jsc/mysql/MySQLConnection.rs:275
  #5 <MySQLConnection>::clean_queue_and_close       src/sql_jsc/mysql/MySQLConnection.rs:290
  #6 <JSMySQLConnection>::do_close                  src/sql_jsc/mysql/JSMySQLConnection.rs:654
  #13 MySQLConnectionPrototype__doClose
freed by:
  us_internal_free_closed_sockets <- us_internal_loop_post   packages/bun-usockets/src/loop.c:305
allocated by:
  us_create_poll <- us_socket_group_connect <- JSMySQLConnection::create_instance

I verified by reading the code that the on_close detach in this PR covers this variant too: the deferred clean_queue_and_close -> close() path reaches us_socket_close, which always dispatches SocketHandler::on_close before queueing the socket for the post-close sweep, so the stored handle is detached before the sweep can free it. The same goes for the Connecting state (us_connecting_socket_close dispatches on_connect_error). So both the server-initiated teardown this PR's test exercises and the client-initiated one above land on the same detach.

For completeness, #32573 independently fixes the same MySQL path from the other end, by detaching inside MySQLConnection::close() itself. Either PR resolves the report above; both together are belt and suspenders.

If it is useful as a second test variant (client-initiated teardown, distinct from the socket-drop one already in sql-connection-socket-uaf.test.ts), the fixture is below. Not opening a separate PR for this.

malformed-post-auth-frame variant
import net from "node:net";
import { SQL } from "bun";
import { mysqlHandshakeV10, mysqlOkPacket, mysqlRawPacket } from "./wire-frames.ts";

// Handshake + auth OK, then answer the first post-auth command with a garbage
// byte that is neither OK (0x00) nor ERR (0xff). The client reports
// UnexpectedPacket and tears the connection down itself.
const server = net.createServer(socket => {
  let buffered = Buffer.alloc(0);
  let authed = false;
  socket.write(mysqlHandshakeV10());
  socket.on("data", chunk => {
    buffered = Buffer.concat([buffered, chunk]);
    while (buffered.length >= 4) {
      const len = buffered[0] | (buffered[1] << 8) | (buffered[2] << 16);
      if (buffered.length < 4 + len) break;
      const seq = buffered[3];
      buffered = buffered.subarray(4 + len);
      if (!authed) {
        authed = true;
        socket.write(mysqlOkPacket(seq + 1));
      } else {
        socket.write(mysqlRawPacket(seq + 1, Buffer.from([0xaa, 0xaa, 0xaa, 0xaa])));
      }
    }
  });
  socket.on("error", () => {});
});
server.listen(0, "127.0.0.1");
await new Promise(r => server.on("listening", r));
const { port } = server.address();

const sql = new SQL({ url: `mysql://root@127.0.0.1:${port}/db`, max: 1 });

const q = sql`SELECT 1`;
q.values();
const handleSym = Object.getOwnPropertySymbols(q).find(s => s.description === "handle");
const handle = q[handleSym];
const protoRun = Object.getPrototypeOf(handle).run;
let nativeConnection;
Object.defineProperty(handle, "run", {
  configurable: true,
  writable: true,
  value(connection, query) {
    nativeConnection = connection;
    return protoRun.call(this, connection, query);
  },
});

const code = await q.then(() => "resolved", e => e?.code ?? String(e));

// Yield real event-loop iterations so the post-close sweep has freed the socket.
await new Promise(r => setImmediate(r));
await new Promise(r => setImmediate(r));

// doClose takes the Failed arm and must not touch the freed us_socket_t.
nativeConnection.close();
await new Promise(r => setImmediate(r));

console.log("ok", JSON.stringify({ code, connected: nativeConnection.connected }));
// expected: ok {"code":"ERR_MYSQL_UNEXPECTED_PACKET","connected":false}
server.close();
process.exit(0);

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