Skip to content

sql: purify NaN before boxing binary float8/double values from the wire - #33824

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/151020ac/sql-purify-nan-double
Jul 10, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
farm/151020ac/sql-purify-nan-double

Conversation

@robobun

@robobun robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #33823

Problem

bun:sql boxes the raw 8 bytes of a binary float8/DOUBLE column straight into a JSValue. A tagged-template or parameterized query automatically requests binary result format for numeric columns, so the bytes come from whatever sits on the other end of the socket (the database, a proxy, or a MITM on a plaintext connection).

jsNumber(double) NaN-boxes by adding DoubleEncodeOffset. JSC's JSVALUE64 encoding is only sound when the boxed double is a non-NaN or the single canonical purified NaN. Any other NaN bit pattern collides with the tag ranges JSC uses for booleans, undefined, Int32, and cell pointers, so a server can make a double precision column come back as an arbitrary forged JSValue, or crash the process with a forged cell pointer.

This is the same root cause already fixed for bun:ffi/N-API in #32787 and for v8::Number::New in #33072; that first PR explicitly called out the SQL decoders as a known, deferred gap.

panic(main thread): Segmentation fault at address 0x1234567D

Cause

src/jsc/bindings/SQLClient.cpp, DataCellTag::Double:

return jsNumber(cell.value.number);

cell.value.number is produced by f64::from_bits(...) over the raw wire bytes (Postgres parse_binary_float8, MySQL MYSQL_TYPE_DOUBLE/FLOAT), with no validation or purification in between. Both producers feed this one sink.

Fix

Purify the NaN at the sink, mirroring napi_create_double:

return jsNumber(purifyNaN(cell.value.number));

Purifying at the sink covers every DataCellTag::Double producer (Postgres and MySQL, float8 and float4). Non-NaN doubles are untouched; any NaN bit pattern collapses to the canonical NaN.

Verification

New test test/js/sql/postgres-binary-float-nan-box.test.ts stands up a mock TCP server speaking the real Postgres v3 wire protocol and returns a binary float8 column with forged bit patterns that previously decoded as boolean true, undefined, and an int32. With the fix each comes back as the number NaN; an ordinary double (3.5) round-trips unchanged.

  • Without the fix (USE_SYSTEM_BUN=1): the forged columns return boolean/undefined, assertions fail.
  • With the fix (bun bd test): 4 pass.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 4 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: 05b26c9b-c2f7-422a-abd2-5dbb26468227

📥 Commits

Reviewing files that changed from the base of the PR and between bdd6c1f and 46969f3.

📒 Files selected for processing (2)
  • src/jsc/bindings/SQLClient.cpp
  • test/js/sql/postgres-binary-float-nan-box.test.ts

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

@robobun

robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:32 AM PT - Jul 9th, 2026

❌ @robobun, your commit 46969f3 has 3 failures in Build #70912 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33824

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

bun-33824 --bun

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

@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 — one-line purifyNaN() wrap matching the established pattern from #32787/#33072, with a hermetic wire-protocol test.

Extended reasoning...

Overview

The production change is a single token: jsNumber(cell.value.number) → jsNumber(purifyNaN(cell.value.number)) in src/jsc/bindings/SQLClient.cpp for the DataCellTag::Double case. purifyNaN is the canonical JSC helper that is identity for non-NaN doubles and collapses any NaN bit pattern to JSC's single canonical NaN, preventing tag-range collisions in JSVALUE64 NaN-boxing. The accompanying test (test/js/sql/postgres-binary-float-nan-box.test.ts) uses the existing test/js/sql/wire-frames.ts mock-server helpers to feed three forged NaN payloads plus a control value (3.5) through the real client path.

Security risks

This closes a type-confusion / forged-JSValue hole where untrusted wire bytes could produce arbitrary JSValues (including cell pointers → segfault). The fix cannot introduce a new risk: purifyNaN only alters values that are already NaN, and the only observable change is that non-canonical NaNs now surface as the JS number NaN instead of a mistagged value. There is no auth, crypto, or permissions logic touched.

Level of scrutiny

Low. This is the third application of the identical fix in this codebase — the same wrap already landed in napi.cpp (#32787) and V8Number.cpp (#33072), and purifyNaN is also used in SerializedScriptValue.cpp and JSDOMConvertNumbers.h. The pattern is well-understood and the diff is mechanical. No CODEOWNERS entry covers these paths.

Other factors

The test is hermetic (local net server via the shared listeningServer/pg* frame builders, no external hosts), cleans up via await using + server.close() in finally, uses test.each for the payload matrix, and includes a positive control asserting non-NaN doubles round-trip unchanged. All imported helpers exist in test/js/sql/wire-frames.ts. The bug-hunting system found no issues, and there are no outstanding reviewer comments.

@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 — one-line purifyNaN wrap mirroring the identical fix already landed in napi.cpp and V8Number.cpp, with a wire-level regression test.

Extended reasoning...

Overview

This PR wraps cell.value.number in purifyNaN() before passing it to jsNumber() in src/jsc/bindings/SQLClient.cpp (DataCellTag::Double case). It adds a regression test at test/js/sql/postgres-binary-float-nan-box.test.ts that stands up a mock Postgres wire server (via the existing test/js/sql/wire-frames.ts helper) and returns forged NaN bit patterns in a binary float8 column, asserting they decode as NaN rather than forged JSValues, plus a control case verifying an ordinary double (3.5) round-trips unchanged.

Security risks

The PR fixes a security issue (untrusted wire bytes → non-canonical NaN → forged JSValue via NaN-boxing collision, potentially a crash or worse via forged cell pointers). The fix itself introduces no new attack surface: purifyNaN is a pure, side-effect-free canonicalizer that is a no-op on non-NaN inputs and collapses any NaN payload to JSC's single canonical NaN. There is no auth, crypto, or permissions logic involved.

Level of scrutiny

Low. This is a mechanical, one-line application of an established in-tree pattern — the exact same jsNumber(purifyNaN(x)) idiom already appears in napi.cpp, v8/V8Number.cpp, SerializedScriptValue.cpp, and JSDOMConvertNumbers.h for the same reason (boxing an untrusted double). The PR description explicitly cites #32787 and #33072 as prior instances of the same root-cause fix, and notes #32787 called out the SQL decoders as a known deferred gap. There is no design ambiguity here.

Other factors

  • The test imports (listeningServer, pgAuthenticationOk, pgReadyForQuery, pgRowDescription, pgDataRow, pgCommandComplete) all resolve against the existing test/js/sql/wire-frames.ts harness.
  • The test includes a positive control (non-NaN 3.5) so a regression that over-purifies would be caught.
  • The bug-hunting system found no issues.
  • No prior reviewer comments or outstanding requests on the PR timeline.

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

The diff is green: the new test `test/js/sql/postgres-binary-float-nan-box.test.ts` passed (4 pass, 0 fail) on the darwin x64 lane, and the change is a single `purifyNaN` wrap in `SQLClient.cpp`.

The two red tests on that lane are unrelated to this change:

  • `bun run --no-orphans (perl): fast-exit intermediate ... daemon still reaped` timed out after 30s (spawn/daemon-reaping, not SQL)
  • `test/js/bun/test/expect-assertions.test.ts` (test-runner assertion behavior)

Neither touches the SQL decoders or NaN-boxing. Ready for a maintainer to merge.

@Jarred-Sumner
Jarred-Sumner merged commit 70f6679 into main Jul 10, 2026
73 of 77 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/151020ac/sql-purify-nan-double branch July 10, 2026 01:05
dylan-conway pushed a commit that referenced this pull request Jul 25, 2026
… forge a JSValue (#35435)

## What

`File`/`Blob` store `lastModified` as a raw `f64`. The `lastModified`
getter boxed that value with `JSValue::js_number(...)` without purifying
NaN. The structured-clone deserialize path (`read_float`, a plain
`f64::from_ne_bytes`) reads the value straight off the wire, so a
crafted `v8.deserialize` / `bun:jsc.deserialize` buffer could plant an
impure NaN.

JSVALUE64 boxing is only sound for a non-NaN or the single canonical
NaN. Any other NaN bit pattern collides with the tag ranges for
`boolean`, `undefined`, `Int32`, and cell pointers, so `.lastModified`
(a property that must always be a `Number`) decoded as a forged
immediate or a forged cell pointer that segfaults when dereferenced.

The constructor already mapped `NaN` to `0` (#33922), and the generic
structured-clone number path already purifies
(`jsNumber(purifyNaN(d))`), but the custom `File`/`Blob` tag getter
skipped it. This is the same bug class and primitive as #33823/#33824
(`bun:sql` `float8`) in a sink those fixes did not cover.

## Repro (pre-fix)

```js
import { serialize, deserialize } from "bun:jsc";

const sentinelBE = Buffer.from([0x3a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f, 0x60, 0x71]);
const sentinel = sentinelBE.readDoubleBE(0);
const sentinelLE = Buffer.alloc(8);
sentinelLE.writeDoubleLE(sentinel, 0);

const buf = Buffer.from(serialize(new File(["x"], "a.txt", { lastModified: sentinel })));
const at = buf.indexOf(sentinelLE);
Buffer.from("0810000000feffff", "hex").copy(buf, at); // forged unmapped cell pointer

const forged = deserialize(buf).lastModified;
Object.prototype.toString.call(forged); // SEGV
```

```
panic(main thread): Segmentation fault at address 0xFFFFFFFFFFFFFFFF
```

Under a debug + ASAN build the impure NaN trips `ASSERTION FAILED:
!isImpureNaN(d)` at the moment `js_number` boxes it.

## Fix

Purify every NaN at both `lastModified` boxing sites in
`src/runtime/webcore/Blob.rs`, mirroring the sink-level fix used in
#32787 and #33824. `JSValue::purify_nan` already exists. A deserialized
`.lastModified` now always reads back as a `Number` (canonical NaN for a
planted NaN), never a forged immediate or cell.

## Verification

- New test in `test/js/web/structured-clone-blob-file.test.ts` forges
`boolean`, `undefined`, `int32`, and cell-pointer payloads through both
`bun:jsc.deserialize` and `v8.deserialize`, asserting each comes back as
`number` NaN. It runs in a child process since the pre-fix build
crashes.
- Fails on the unfixed build (child aborts at the `!isImpureNaN` assert
before producing output), passes with the fix.

Closes #35434

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 1 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/web/structured-clone-blob-file.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Jarred-Sumner pushed a commit that referenced this pull request Jul 27, 2026
… forge a JSValue (#35435)

## What

`File`/`Blob` store `lastModified` as a raw `f64`. The `lastModified`
getter boxed that value with `JSValue::js_number(...)` without purifying
NaN. The structured-clone deserialize path (`read_float`, a plain
`f64::from_ne_bytes`) reads the value straight off the wire, so a
crafted `v8.deserialize` / `bun:jsc.deserialize` buffer could plant an
impure NaN.

JSVALUE64 boxing is only sound for a non-NaN or the single canonical
NaN. Any other NaN bit pattern collides with the tag ranges for
`boolean`, `undefined`, `Int32`, and cell pointers, so `.lastModified`
(a property that must always be a `Number`) decoded as a forged
immediate or a forged cell pointer that segfaults when dereferenced.

The constructor already mapped `NaN` to `0` (#33922), and the generic
structured-clone number path already purifies
(`jsNumber(purifyNaN(d))`), but the custom `File`/`Blob` tag getter
skipped it. This is the same bug class and primitive as #33823/#33824
(`bun:sql` `float8`) in a sink those fixes did not cover.

## Repro (pre-fix)

```js
import { serialize, deserialize } from "bun:jsc";

const sentinelBE = Buffer.from([0x3a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f, 0x60, 0x71]);
const sentinel = sentinelBE.readDoubleBE(0);
const sentinelLE = Buffer.alloc(8);
sentinelLE.writeDoubleLE(sentinel, 0);

const buf = Buffer.from(serialize(new File(["x"], "a.txt", { lastModified: sentinel })));
const at = buf.indexOf(sentinelLE);
Buffer.from("0810000000feffff", "hex").copy(buf, at); // forged unmapped cell pointer

const forged = deserialize(buf).lastModified;
Object.prototype.toString.call(forged); // SEGV
```

```
panic(main thread): Segmentation fault at address 0xFFFFFFFFFFFFFFFF
```

Under a debug + ASAN build the impure NaN trips `ASSERTION FAILED:
!isImpureNaN(d)` at the moment `js_number` boxes it.

## Fix

Purify every NaN at both `lastModified` boxing sites in
`src/runtime/webcore/Blob.rs`, mirroring the sink-level fix used in
#32787 and #33824. `JSValue::purify_nan` already exists. A deserialized
`.lastModified` now always reads back as a `Number` (canonical NaN for a
planted NaN), never a forged immediate or cell.

## Verification

- New test in `test/js/web/structured-clone-blob-file.test.ts` forges
`boolean`, `undefined`, `int32`, and cell-pointer payloads through both
`bun:jsc.deserialize` and `v8.deserialize`, asserting each comes back as
`number` NaN. It runs in a child process since the pre-fix build
crashes.
- Fails on the unfixed build (child aborts at the `!isImpureNaN` assert
before producing output), passes with the fix.

Closes #35434

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 1 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/web/structured-clone-blob-file.test.ts

<!-- robobun:evidence:end -->

---------

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

2 participants