Skip to content

ws client: cap close-reason transcode buffer at 125 to remove unsafe cast#30778

Open
robobun wants to merge 9 commits into
mainfrom
farm/bfaf4a85/ws-close-reason-buf-overrun
Open

ws client: cap close-reason transcode buffer at 125 to remove unsafe cast#30778
robobun wants to merge 9 commits into
mainfrom
farm/bfaf4a85/ws-close-reason-buf-overrun

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented May 15, 2026

Summary

Fixes #30771WebSocket<SSL>::close in src/http_jsc/websocket_client.rs transcodes the close reason into a 128-byte stack buffer and then pointer-casts the buffer to &mut [u8; 125] before calling send_close_with_body(.., body_len = cursor.position()). When the transcoded UTF-8 lands in (125, 128], the downstream &data[..body_len] read on the 125-byte array reference panics at runtime — and the panic crosses the extern "C" boundary, aborting the process.

Repro

A UTF-16 reason of 42 × U+0800 (126 UTF-8 bytes) passes the C++ reason.length() > 123 spec check because length() is code units, not UTF-8 bytes:

const ws = new WebSocket(url);
ws.addEventListener('open', () => ws.close(1000, '\u0800'.repeat(42)));

Runtime panic:

panic: range end index 126 out of range for slice of length 125

Same failure mode via the Latin-1 arm (copy_latin1_into_utf8 can transcode 64 × 0x80 → 128 UTF-8 bytes) and the UTF-8 arm (any 126-byte pre-tagged UTF-8 slice).

Cause

Three transcoding arms in close() each write into close_reason_buf: [u8; 128]:

  • 16-bit: cursor.write_all(&to_owned_slice()) — succeeds up to 128 bytes before returning WriteZero.
  • UTF-8: cursor.write_all(str.slice()) — same.
  • Latin-1: copy_latin1_into_utf8(dst, str.slice()) — returns NoSpaceLeft only when the buffer ran out before the source, so up to 128 bytes of output slips through on a 128-byte output.

Then close_reason_buf.as_mut_ptr().cast::<[u8; 125]>() is dereferenced as &mut [u8; 125]. The reference only carries 125 bytes of provenance; send_close_with_body's &data[..body_len] with body_len > 125 panics on the bounds check (and is UB under Stacked/Tree Borrows even without the panic).

Fix

Shrink close_reason_buf to [u8; 125]. The existing overflow handling in all three arms (break 'inner → fall through to the no-reason send_close_with_body(code, None, 0)) now triggers exactly when the transcoded UTF-8 exceeds the RFC 6455 125-byte payload cap, matching the Zig original's wrote.ptr[0..125] intent without the unsafe cast. The array reference we hand to send_close_with_body now covers the full 125-byte provenance, and the cast is gone.

A debug_assert!(wrote_len <= close_reason_buf.len()) catches any regression that reintroduces the bug.

Test

test/js/web/websocket/websocket-close-fragmented.test.ts gains 'close() with reason that transcodes beyond 125 UTF-8 bytes does not crash': a raw TCP server completes the WebSocket handshake, the client calls close(1000, '\u0800'.repeat(42)), and the test waits for 'close' on the client.

Before: bun test aborts with range end index 126 out of range for slice of length 125.
After: 2 pass / 0 fail.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 15, 2026

Updated 7:28 AM PT - May 21st, 2026

@robobun, your commit bbf49b2 has 2 failures in Build #56643 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30778

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

bun-30778 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 15, 2026

Review Change Stack

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

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: ebf109d3-7650-407a-8638-0990cdddfbc0

📥 Commits

Reviewing files that changed from the base of the PR and between 8041ffc and bbf49b2.

📒 Files selected for processing (1)
  • src/codegen/bake-codegen.ts
💤 Files with no reviewable changes (1)
  • src/codegen/bake-codegen.ts

Walkthrough

The PR bounds close-frame reason encoding to RFC limits, removes an unsafe buffer cast, adds a regression test exercising long Unicode close reasons, and JSON-stringifies the build-time OVERLAY_CSS value.

Changes

WebSocket close frame payload safety

Layer / File(s) Summary
WebSocket close buffer and send-path fix
src/http_jsc/websocket_client.rs
Reduce close-reason buffer to 125 bytes, bound encoding cursor to 123 bytes (MAX_REASON_BYTES), and replace the unsafe [u8; 128]-to-[u8; 125] pointer cast with a safe mutable borrow when calling send_close_with_body with the correct encoded length.
Regression test for close reason encoding bounds
test/js/web/websocket/websocket-close-fragmented.test.ts
Add test that spawns a child Bun process running a raw-socket WebSocket fixture; the fixture calls close() with a crafted Unicode reason to exercise transcoding within the bounds. Parent process asserts clean exit and correct close code logging.

Build-time CSS serialization

Layer / File(s) Summary
OVERLAY_CSS JSON stringification
src/codegen/bake-codegen.ts
Build-time define for OVERLAY_CSS now wraps the CSS result with JSON.stringify() to serialize it as a string in the generated bundle.

Suggested reviewers:

  • RiskyMH
  • dylan-conway
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The OVERLAY_CSS change in bake-codegen.ts is unrelated to the WebSocket close-reason fix; this is the only out-of-scope change and was retained per the author's rationale. Remove the OVERLAY_CSS JSON.stringify change from src/codegen/bake-codegen.ts as it is unrelated to the WebSocket close-reason buffer fix and should be in a separate PR.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: reducing buffer size and removing an unsafe cast from WebSocket close-reason handling.
Description check ✅ Passed The description covers the required template sections: 'What does this PR do?' (detailed explanation with context) and 'How did you verify your code works?' (test verification details).
Linked Issues check ✅ Passed The PR fully addresses #30771 by eliminating the unsafe pointer cast, shrinking the buffer to [u8; 125], enforcing the 123-byte reason limit via MAX_REASON_BYTES, and adding debug assertions to prevent regression.

✏️ 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.

@github-actions
Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Harden 36 reachable security findings across runtime, install, parsers, http #30722 - Also fixes the WebSocket close reason buffer overflow in websocket_client.rs (finding Support macros in Bun.js SSR #36: clamp body_len to 125 and bail on overlong UTF-8 transcode), but via runtime bounds checks rather than shrinking the buffer

🤖 Generated with Claude Code

Comment thread src/http_jsc/websocket_client.rs
@robobun robobun force-pushed the farm/bfaf4a85/ws-close-reason-buf-overrun branch from 67ad0ca to 4cb9101 Compare May 15, 2026 09:32
Comment thread test/js/web/websocket/websocket-close-fragmented.test.ts Outdated
Comment thread test/js/web/websocket/websocket-close-fragmented.test.ts Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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/web/websocket/websocket-close-fragmented.test.ts`:
- Around line 148-160: The test currently discards child stderr ("stderr:
\"ignore\"") which drops crash diagnostics; change Bun.spawn so proc is created
with stderr: "pipe" (alongside stdout: "pipe"), await proc.stderr.text()
together with proc.stdout.text() and proc.exited, and update the assertion to
include stderr in the failure output; when asserting stderr is empty, filter out
the known ASAN startup warning coming from bunEnv before comparing so benign
ASAN messages don't fail the test.
- Around line 216-219: Replace the use of the string .repeat call in the
ws.close call: locate the ws.close(1000, "\\u0800".repeat(42)) invocation and
replace the repetitive-string construction with the repository pattern using
Buffer.alloc(...).toString() (i.e., build the repeated "\u0800" payload via
Buffer.alloc(count, fill).toString() rather than String.prototype.repeat) so the
test uses Buffer.alloc for creating the repetitive payload.
🪄 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: a9778c52-1a99-4cf3-a408-be0e12c1b2b4

📥 Commits

Reviewing files that changed from the base of the PR and between f6cd1cc and 5a4e9b4.

📒 Files selected for processing (1)
  • test/js/web/websocket/websocket-close-fragmented.test.ts

Comment on lines +148 to +160
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", CLOSE_LONG_REASON_FIXTURE],
env: bunEnv,
stdout: "pipe",
stderr: "ignore",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
// With the fix: the fixture reaches the close listener, prints
// `close:1000`, and exits cleanly. Without the fix: the child aborts
// inside `WebSocket::close` before the listener fires — stdout is
// empty and exitCode is non-zero (SIGILL from the Rust panic).
expect({ stdout: stdout.trim(), exitCode }).toEqual({
stdout: "close:1000",
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.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Pipe the child stderr instead of discarding it.

This test is guarding a crash path; with stderr: "ignore", a regression only gives you stdout/exitCode and drops the most useful crash diagnostics.

♻️ Suggested change
     await using proc = Bun.spawn({
       cmd: [bunExe(), "-e", CLOSE_LONG_REASON_FIXTURE],
       env: bunEnv,
       stdout: "pipe",
-      stderr: "ignore",
+      stderr: "pipe",
     });
-    const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
+    const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+    const cleanStderr = stderr
+      .split("\n")
+      .filter(line => !line.startsWith("WARNING: ASAN interferes"))
+      .join("\n")
+      .trim();
     // With the fix: the fixture reaches the close listener, prints
     // `close:1000`, and exits cleanly. Without the fix: the child aborts
     // inside `WebSocket::close` before the listener fires — stdout is
     // empty and exitCode is non-zero (SIGILL from the Rust panic).
-    expect({ stdout: stdout.trim(), exitCode }).toEqual({
+    expect({ stdout: stdout.trim(), stderr: cleanStderr, exitCode }).toMatchObject({
       stdout: "close:1000",
+      stderr: "",
       exitCode: 0,
     });

Based on learnings, crash-prone spawned-process tests should keep subprocess output in the failure diff, and bunEnv-based tests should filter the known ASAN startup warning before asserting stderr is empty.

🤖 Prompt for 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.

In `@test/js/web/websocket/websocket-close-fragmented.test.ts` around lines 148 -
160, The test currently discards child stderr ("stderr: \"ignore\"") which drops
crash diagnostics; change Bun.spawn so proc is created with stderr: "pipe"
(alongside stdout: "pipe"), await proc.stderr.text() together with
proc.stdout.text() and proc.exited, and update the assertion to include stderr
in the failure output; when asserting stderr is empty, filter out the known ASAN
startup warning coming from bunEnv before comparing so benign ASAN messages
don't fail the test.

Comment on lines +216 to +219
// 42 code units × 3 UTF-8 bytes = 126 bytes — one byte past the
// 125-byte close-frame payload cap. C++ spec check bounds on UTF-16
// code-unit count (42 < 123), so this reaches the native close path.
ws.close(1000, "\\u0800".repeat(42));
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace .repeat(42) with the repo’s Buffer.alloc(...).toString() pattern.

This fixture is building a repetitive string in a test, and this repo explicitly avoids .repeat() for that case.

♻️ Suggested change
-  // 42 code units × 3 UTF-8 bytes = 126 bytes — one byte past the
+  // 126 UTF-8 bytes total — one byte past the
   // 125-byte close-frame payload cap. C++ spec check bounds on UTF-16
   // code-unit count (42 < 123), so this reaches the native close path.
-  ws.close(1000, "\\u0800".repeat(42));
+  ws.close(1000, Buffer.alloc(126, "\\u0800").toString());

As per coding guidelines, "To create repetitive strings in tests, use Buffer.alloc(count, fill).toString() instead of "string".repeat(count) for better performance in debug builds."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 42 code units × 3 UTF-8 bytes = 126 bytes — one byte past the
// 125-byte close-frame payload cap. C++ spec check bounds on UTF-16
// code-unit count (42 < 123), so this reaches the native close path.
ws.close(1000, "\\u0800".repeat(42));
// 126 UTF-8 bytes total — one byte past the
// 125-byte close-frame payload cap. C++ spec check bounds on UTF-16
// code-unit count (42 < 123), so this reaches the native close path.
ws.close(1000, Buffer.alloc(126, "\\u0800").toString());
🤖 Prompt for 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.

In `@test/js/web/websocket/websocket-close-fragmented.test.ts` around lines 216 -
219, Replace the use of the string .repeat call in the ws.close call: locate the
ws.close(1000, "\\u0800".repeat(42)) invocation and replace the
repetitive-string construction with the repository pattern using
Buffer.alloc(...).toString() (i.e., build the repeated "\u0800" payload via
Buffer.alloc(count, fill).toString() rather than String.prototype.repeat) so the
test uses Buffer.alloc for creating the repetitive payload.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — 5a4e9b4 resolves the prettier-reflow nit; all my earlier comments are now addressed. Deferring to a human since this also bundles an unrelated bake-codegen.ts OVERLAY_CSS behavioral change and overlaps with #30722.

Extended reasoning...

Overview

The headline change is a targeted fix in src/http_jsc/websocket_client.rs: the close-reason transcode buffer is now [u8; 125] (matching send_close_with_body's Option<&mut [u8; 125]> signature), the cursor is built over a 123-byte view (MAX_REASON_BYTES) so overflow bails to the no-reason path at the RFC 6455 boundary, and the unsafe cast::<[u8; 125]>() is gone. A regression test in websocket-close-fragmented.test.ts spawns a child that calls ws.close(1000, '\\u0800'.repeat(42)) and asserts clean exit. The remaining 11 files are autofix.ci/rustfmt reflows of multi-target #[cfg(...)] / #[cfg_attr(...)] attributes (whitespace-only), a jsc_hooks.rs line-wrap, and one substantive one-liner in src/codegen/bake-codegen.ts wrapping OVERLAY_CSS in JSON.stringify(...).

Security risks

The fix removes UB (out-of-bounds slice via a narrowed array reference) and a process-aborting panic across extern "C" reachable from JS WebSocket.prototype.close. The 123-byte cap prevents emitting close frames with header-length 126/127 (the §5.2 extended-length sentinels). I don't see new attack surface introduced; the test fixture binds to 127.0.0.1:0. No auth/crypto/permissions code is touched.

Level of scrutiny

Moderate. The core change is small and well-reasoned, and three rounds of review feedback (off-by-2 on the 125 vs 123 cap, test-convention nits, prettier comment reflow) have all been incorporated. However:

  • It rewrites RFC 6455 close-frame payload bounds in a hand-rolled protocol implementation — an area where off-by-one errors have wire-visible consequences.
  • It bundles an unrelated build-system behavioral change (bake-codegen.ts now JSON.stringifys the CSS define) that isn't covered by the PR title/description.
  • robobun reports CI failures on Build #54713 (at f6cd1cc); 5a4e9b4 was pushed after that comment and CI status for it isn't reflected yet.
  • github-actions flagged #30722 as fixing the same WebSocket overflow via a different approach (runtime clamp vs buffer shrink) — a human should pick one.

Other factors

All three of my prior inline threads are resolved by the current diff (the last via 5a4e9b4). The rustfmt attribute reflows are mechanical and zero-risk. The new test isolates the crash in a subprocess so a regression won't take down the runner, and asserts on stdout + exitCode per repo conventions. Given the bundled unrelated change, the open CI status, and the duplicate-PR coordination needed, this isn't a "merge without a human glance" PR.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 15, 2026

CI red, but the failures are on darwin-14-aarch64-test-bun and darwin-26-aarch64-test-bun both marked Expired — the macOS aarch64 runners timed out before being assigned. No test failure on the ASAN job (where this fix actually matters) or on any Linux/Windows lane. Retrigger used once already; diff is green on everything that ran. Needs a maintainer re-run or merge.

@robobun robobun force-pushed the farm/bfaf4a85/ws-close-reason-buf-overrun branch from 4db0921 to 8041ffc Compare May 16, 2026 11:19
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 16, 2026

Rebased onto main. Conflict in src/http_jsc/websocket_client.rs with #30722 ("Harden 36 reachable security findings"), which fixed the same panic (finding #36) via a different approach — runtime clamp + keep the 128-byte buffer + keep the unsafe cast::<[u8; 125]>(). Kept my version:

  • 125-byte buffer (matches the &mut [u8; 125] signature exactly → no cast needed)
  • Cursor view over 123 bytes so overflow bails at the frame-length boundary (avoiding the malformed-frame edge at 124/125 bytes — header length would be 126/127, the RFC 6455 §5.2 16/64-bit extended-length sentinels)
  • Unsafe cast is gone entirely; provenance covers the full slice

Release bun on main now includes #30679's JSON lexer fix, so the bake-codegen.ts OVERLAY_CSS wrap in this PR is no longer strictly needed but is the canonical Bun.build define shape — left in.

Comment on lines +1633 to +1635
// `send_close_with_body` takes `&mut [u8; 125]`; keep the array at
// 125 bytes so the borrow covers the full provenance and matches
// the other caller (line 1006, server-initiated close echo).
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.

🟡 nit: the new comment references "the other caller (line 1006, server-initiated close echo)" — hardcoded line numbers in comments rot as soon as anything above them is edited in this ~2000-line file. The semantic anchor ("server-initiated close echo") is already enough to find the call site; suggest dropping "line 1006, ".

Extended reasoning...

What the issue is

The comment added at src/http_jsc/websocket_client.rs:1635 reads:

// the other caller (line 1006, server-initiated close echo).

This embeds a hardcoded source line number as a cross-reference. In a ~2000-line file, any edit above line 1006 — a new import, a doc comment, a refactor of an earlier method — shifts the target, and the comment silently goes stale. The comment is being added fresh in this PR, so it's the right time to fix it before it lands.

Why the line number adds no durable value

The comment already includes a stable semantic anchor: "server-initiated close echo". A reader can grep for send_close_with_body (there are exactly two callers) or for "close echo" and find the other call site regardless of where it has drifted to. The line number is supplementary, not the primary anchor — which means when it drifts it becomes pure noise: a reader who jumps to line 1006 and finds unrelated code has to fall back on the semantic description anyway.

Step-by-step proof of rot

  1. Today: the other send_close_with_body call is at line 1006 (verified).
  2. A future PR adds a 10-line helper near the top of impl<const SSL: bool> WebSocket<SSL>.
  3. The server-initiated close echo is now at line 1016; the comment still says "line 1006".
  4. A reader following the comment lands in the middle of an unrelated function body.
  5. The reader has to grep for "server-initiated close echo" / send_close_with_body anyway — exactly what they'd have done if the line number weren't there.

Addressing the "too trivial" objection

One reviewer noted the semantic anchor mitigates the rot, so the line number is merely belt-and-suspenders. That's true, and is why this is a nit, not a blocker. But the comment is brand-new in this PR — the marginal cost of dropping four characters now is zero, vs. leaving a known-to-rot reference in a freshly-authored comment. Since the PR is already touching this exact line, it's the cheapest possible time to address it.

Impact

Zero runtime impact. Pure comment maintainability.

Fix

Drop the line number, keep the semantic referent:

// the other caller (server-initiated close echo).

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 16, 2026

CI red again on same pattern as before: debian-13-x64-asan-test-bun shard 17/20, exit status -1 (not a test failure indicator — that's agent/infra failure code). My test is in shard 14 (confirmed by running the sharding computation locally) which passed.

Looking at recent builds, ASAN shard 17 has failed with exit -1 on multiple unrelated builds (54920, 55200, 55217) — it's a known flaky infra lane, not specific to this PR. Different agents each time (ip-172-31-18-106, ip-172-31-67-55).

Diff is green on every lane that runs to completion, including the ASAN job on shards 0-16, 18, 19. Re-roll already used. Needs a maintainer to re-run the red shard or merge.

robobun and others added 9 commits May 21, 2026 11:40
…cast

WebSocket<SSL>::close allocated a 128-byte `close_reason_buf` on the
stack, transcoded the reason into it (UTF-16→UTF-8 via `to_owned_slice`
or Latin-1→UTF-8 via `copy_latin1_into_utf8`), then pointer-cast the
buffer to `&mut [u8; 125]` and passed it to `send_close_with_body`
with `body_len = cursor.position()`. The cursor position could reach
128, and the downstream `&data[..body_len]` slice overruns the
125-byte array reference.

A 42-code-unit UTF-16 reason of U+0800 passes the C++ spec check
(`reason.length() > 123`) but transcodes to 126 UTF-8 bytes, panicking
the runtime with `range end index 126 out of range for slice of length
125`. The panic crosses the `extern "C" fn close` boundary and
aborts the process.

Shrink the transcode buffer to 125 bytes so:
- `cursor.write_all` fails with WriteZero when the transcoded UTF-8
  exceeds 125 bytes (same `break 'inner` path the Latin-1 arm already
  takes on overflow), falling through to the no-reason `close` frame.
- The fixed-size array is passed directly to `send_close_with_body`,
  eliminating the unsafe pointer cast.

Regression test: send `close(1000, '\u0800'.repeat(42))` to a raw
handshake server and wait for the close event without the runtime
panicking.

Fixes #30771
The shipped `build/release/bun` binary in the tree pre-dates #30679's
JSON lexer fix, so when it runs `bake-codegen.ts` during `bun bd` or
`bun run build:release` the old lexer aborts on the leading `*` of the
minified CSS `define` value:

    *{box-sizing:border-box;...}
    ^
    error: Unsupported syntax: Operators are not allowed in JSON

Main is otherwise unbuildable with the shipped release bun. Wrap
OVERLAY_CSS in `JSON.stringify` — matches the canonical `Bun.build`
`define` pattern and parses cleanly under every JSON lexer generation.
… frame

Follow-up to the buffer-overrun fix: the 125-byte transcode buffer let a
reason of 124 or 125 UTF-8 bytes through, and `send_close_with_body`
then wrote header-length 126/127 — the RFC 6455 §5.2 extended-length
sentinels. Control frames have no extended length encoding, so the
receiver misparses the mask key as length and rejects the frame.

Keep the array at 125 bytes (to match the `Option<&mut [u8; 125]>`
signature on the other caller) but build the cursor over a 123-byte view
so `WriteZero` / `NoSpaceLeft` bail at the correct boundary.

Also address review nits on the regression test: drop the `panicked`
stderr-substring assertion (the stdout + exitCode checks already cover
the regression in two independent ways; the substring check is exactly
the pattern CLAUDE.md warns against) and drop the per-test timeout
(covered by the runner default).
autofix.ci kept collapsing three `//` comments wedged between the test
arrow's `}` and the `30_000` timeout onto the `},` line in reverse
reading order, and after three repeated bot commits the Format job gave
up with 'refusing to patch repeated bot commits'.

Move the comment to the line above `test(...)` where prettier won't
touch it, and let prettier collapse the call back to its preferred
single-line form.
@robobun robobun force-pushed the farm/bfaf4a85/ws-close-reason-buf-overrun branch from 8041ffc to bbf49b2 Compare May 21, 2026 11:46
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 21, 2026

Rebased onto main. Conflict in src/http_jsc/websocket_client.rs — main now has another variation of the overflow fix (via #30722 then re-hardened in #31129): [u8; 128] buffer + runtime if wrote_len > 123 guard + unsafe cast::<[u8; 125]>() reborrow.

Kept this PR's version: [u8; 125] buffer + Cursor::new(&mut buf[..MAX_REASON_BYTES]) where MAX_REASON_BYTES = 123, no unsafe cast. The cursor view enforces the 123-byte frame-payload cap at the write sites (WriteZero / NoSpaceLeft naturally trigger break 'inner), and send_close_with_body receives &mut close_reason_buf directly since the buffer already has the exact [u8; 125] shape its signature asks for.

Rust tests still pass: bun bd test websocket-close-fragmented.test.ts → 2 pass / 0 fail. cargo check -p bun_http_jsc clean.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 21, 2026

Closing: #30771 is already fixed on main via #30722 (runtime if wrote_len > 123 { break 'inner; } guard at src/http_jsc/websocket_client.rs:1701). Main's approach keeps the 128-byte buffer + unsafe cast::<[u8; 125]>() reborrow but prevents both the panic and the malformed-frame edge via the runtime check.

This PR's contribution is now purely stylistic (buffer sized to match the &mut [u8; 125] signature exactly → unsafe cast and runtime guard both become unnecessary), which isn't observable at the JS layer. The regression test proves it: ws.close(1000, '\u0800'.repeat(42)) exits cleanly on both main and this branch.

Happy to leave open if the cleanup is wanted; otherwise please close.

Comment on lines +1665 to +1675
// RFC 6455 §5.5.1: the close-frame payload is ≤ 125 bytes total and
// the 7-bit header length field is the payload length — there is no
// extended-length encoding for control frames. `send_close_with_body`
// prepends the 2-byte status code separately and computes the header
// length as `(body_len + 2) & 0x7F`. A reason of 124 or 125 UTF-8
// bytes therefore writes a header length of 126 or 127, which are
// the 16/64-bit extended-length sentinels per §5.2 and malform the
// frame. Cap the transcode cursor at 123 bytes so the overflow arms
// below (`WriteZero` / `NoSpaceLeft`) bail via `break 'inner` at the
// correct boundary.
const MAX_REASON_BYTES: usize = 123;
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 new comment justifying MAX_REASON_BYTES = 123 says send_close_with_body "computes the header length as (body_len + 2) & 0x7F" and that a 124/125-byte reason "writes a header length of 126 or 127 … and malform[s] the frame" — but after the 2026-05-21 rebase, send_close_with_body does let body_len = body_len.min(123); (line 1346) before computing the header length, so that scenario can no longer occur. The 123-byte cursor cap is still worthwhile — it routes overflow to a clean no-reason close instead of letting .min(123) truncate mid-UTF-8-sequence and trip terminate(InvalidUtf8) at line 1379 — but the comment's stated rationale is now factually wrong and should be updated (or trimmed, per src/CLAUDE.md's "avoid adding comments where not necessary").

Extended reasoning...

What the issue is

The comment block added at src/http_jsc/websocket_client.rs:1665–1675 justifies MAX_REASON_BYTES = 123 like this:

send_close_with_body prepends the 2-byte status code separately and computes the header length as (body_len + 2) & 0x7F. A reason of 124 or 125 UTF-8 bytes therefore writes a header length of 126 or 127, which are the 16/64-bit extended-length sentinels per §5.2 and malform the frame.

That description of send_close_with_body is no longer accurate. The current implementation (lines 1342–1367, brought in via #31129 on main and untouched by this PR) reads:

fn send_close_with_body(&mut self, code: u16, body: Option<&mut [u8; 125]>, body_len: usize) {
    // ... reason text is limited to 123 bytes.
    let body_len = body_len.min(123);          // ← line 1346
    ...
    header.set_len(((body_len + 2) & 0x7F) as u8); // ← line 1367

The .min(123) clamp runs before the header length is computed, so body_len + 2 is at most 125 and the 126/127 extended-length sentinels can never be emitted regardless of what the caller passes.

How the comment went stale

The rationale was correct when written. Per the PR timeline, the original 2026-05-15 review identified the 124/125-byte malformed-frame edge against the then-current send_close_with_body, which had no clamp; commit 0754956 added the 123-byte cursor cap and this comment to fix it. On 2026-05-21 the branch was rebased onto a main that now includes #31129's independent .min(123) defense inside send_close_with_body (the rebase note even says main has "another variation of the overflow fix"). The comment was carried forward unchanged and now describes callee behavior that no longer exists.

Step-by-step proof

Take the comment's own example — a reason that transcodes to 124 UTF-8 bytes — and suppose the cursor cap were absent so 124 reaches send_close_with_body:

  1. send_close_with_body(code, Some(&mut close_reason_buf), 124) is called.
  2. Line 1346: let body_len = 124.min(123)body_len = 123.
  3. Line 1367: header.set_len(((123 + 2) & 0x7F) as u8)set_len(125).
  4. 125 is a valid 7-bit literal length per RFC 6455 §5.2 — not 126 or 127, not an extended-length sentinel. The frame is well-formed.

So the comment's central claim ("writes a header length of 126 or 127 … and malform the frame") is unreachable on the post-rebase code.

Why the cursor cap still has value (the correct rationale)

Without MAX_REASON_BYTES, a 124-byte UTF-8 reason would reach send_close_with_body, get hard-truncated to 123 bytes by .min(123), and — if byte 123 falls mid-multibyte-sequence — fail is_valid_utf8 at line 1379, calling self.terminate(ErrorCode::InvalidUtf8) instead of sending any close frame. The cursor cap makes overflow take break 'inner and fall through to the clean send_close_with_body(code, None, 0) path. That is a real, useful behavioral difference — it's just not the malformed-frame story the comment tells.

Impact

Zero runtime impact — this is purely comment accuracy. But the comment is brand-new in this PR (not pre-existing prose), it makes a concrete present-tense claim about a specific function's behavior, and that claim is verifiably false against the file as it stands. A future reader auditing send_close_with_body will find the .min(123) clamp, see this comment asserting it doesn't exist, and have to spend time reconciling the two. src/CLAUDE.md says "avoid adding comments where not necessary"; an inaccurate 10-line comment is worse than none.

Fix

Either trim the comment to the load-bearing fact:

// RFC 6455 §5.5.1: close-frame payload is 2-byte status + ≤123-byte reason.
// Cap the cursor at 123 so overflow falls through to the no-reason close
// rather than reaching send_close_with_body's .min(123) truncation (which
// could split a UTF-8 sequence and trip terminate(InvalidUtf8)).
const MAX_REASON_BYTES: usize = 123;

or drop the "computes the header length as (body_len + 2) & 0x7F … 126 or 127 … malform the frame" sentences entirely, since send_close_with_body now defends that boundary itself.

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.

unsafe: close_reason_buf cast [u8;128] to [u8;125] — wrote_len may exceed provenance

1 participant