Skip to content

Bun.serve: make server.upgrade() honor handshake headers deleted from req.headers - #41821

Closed
robobun wants to merge 2 commits into
mainfrom
robobun/db40d423/upgrade-deleted-handshake-headers
Closed

robobun wants to merge 2 commits into
mainfrom
robobun/db40d423/upgrade-deleted-handshake-headers

Conversation

@robobun

@robobun robobun commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • In a Bun.serve handler, req.headers.delete("sec-websocket-protocol") then server.upgrade(req) in the same tick still answers 101 with Sec-WebSocket-Protocol: chat. Deleting sec-websocket-key, sec-websocket-version or upgrade still upgrades. With any await before upgrade() the same code honors the deletion (101 without a protocol, 400, 426, 400).
  • Cause: the header read in upgrade() (src/runtime/server/server_body.rs:1836-1881). It reads the five handshake fields from the Request's FetchHeaders, then fills each field that is still empty from r.header(name) on the live uWS request. A deleted field is empty, so the raw line comes back. After an await the uWS request is gone and there is no fallback.

Fix

  • Once request.headers exists, read the handshake from it alone. Read the raw uWS request only when no Headers object was ever created.
  • Correct because a Bun.serve Request builds its FetchHeaders from every field of the uWS request (Request.rs:252). A field missing from it later was removed by the handler. The post-await path already worked this way.
  • Verified: test/js/bun/websocket/websocket-server.test.ts (new it.each, the "before any await" case fails on canary f42e98025). Also ran the rest of that file and the other server.upgrade() test files.

Background

  • server.upgrade(req) validates the opening handshake (RFC 6455 §4.2.1) from these fields, echoes the first offered subprotocol, and derives Sec-WebSocket-Accept from the key.
  • A Bun.serve Request is lazy: req.headers is created from the uWS request on first access. Until fetch() awaits, the Request points at the live uWS request. When fetch() returns a pending promise, the server snapshots url and headers and detaches it.
Notes

Repro matrix on canary f42e98025 (raw request offers Sec-WebSocket-Protocol: chat, superchat), synchronous upgrade():

req.headers.delete(...) before after (same as post-await, which is unchanged)
sec-websocket-protocol 101, Sec-WebSocket-Protocol: chat 101, no protocol header
sec-websocket-key 101 400 (upgrade() returns false)
sec-websocket-version 101 426 Upgrade Required
upgrade 101 400 (upgrade() returns false)

The NodeHTTPResponse branch of upgrade() (node:http servers) is a separate code path and is not touched.

An await Promise.resolve() before upgrade() does not reach the detached path: the microtask runs before the request leaves the HTTP parser's stack. The test uses setImmediate for the "after an await" case.

Other suites run locally on the debug build: websocket-server-upgrade-reentrant.test.ts, websocket-upgrade-signal-gc.test.ts, bun-server.test.ts, test/js/web/websocket/websocket.test.js. Under the debug ASAN build, ServerWebSocket > send() > (benchmark) (330k compressed echo messages) times out at 30 s in this container regardless of this change. It never touches req.headers and takes the unchanged raw-request branch.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/websocket/websocket-server.test.ts

… req.headers

Once request.headers exists on a Bun.serve Request, server.upgrade() now
reads Sec-WebSocket-Key/-Protocol/-Extensions/-Version and Upgrade only
from it. Before, a field missing from the Headers object fell back to the
raw uWS request line, so a header the handler deleted came back, but only
when upgrade() ran before the first await. After an await the raw request
is gone and the deletion was honored. Both paths now agree.
@coderabbitai

coderabbitai Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 13 days. After that, they cost $0.25 per reviewed file.

Or wait 57 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 60ba0f92-5757-4184-b295-f7e964c14daf

📥 Commits

Reviewing files that changed from the base of the PR and between 62838e1 and ec18fdd.

📒 Files selected for processing (2)
  • src/runtime/server/server_body.rs
  • test/js/bun/websocket/websocket-server.test.ts

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

@robobun

robobun commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:41 PM PT - Sep 7th, 2026

❌ @Jarred-Sumner, your commit ec18fdd has 8 failures in Build #112229 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 41821

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

bun-41821 --bun

@robobun

robobun commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on canary f42e98025 with a raw-socket handshake against Bun.serve: the handler runs req.headers.delete(name) then server.upgrade(req) in the same tick. All four deletes (sec-websocket-protocol, sec-websocket-key, sec-websocket-version, upgrade) were ignored on the synchronous path and honored after an await.

Fail-before / pass-after: test/js/bun/websocket/websocket-server.test.ts -t "stays deleted". The "before any await" case fails on canary and passes on this branch. The "after an await" case passes on both and pins the two paths to the same result.

@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 — focused fix at the right layer with a test that would have caught the original bug.

What was reviewed:

  • The if → else if conversion in server_body.rs: the upgrader binding used at the else if is the live shared ref from line 1803; nothing between there and 1862 runs user JS or invalidates it, so dropping the redundant re-borrow is safe.
  • Considered whether a Request could reach this path with a FetchHeaders that was not built from the raw uWS request — it cannot, because any Request without the matching request_context bails at the tagged-pointer check above.
  • The new test reuses the describe's server/afterEach(stop) and rawHandshake helper, covers both sync and detached (setImmediate) paths, all four handshake headers, and includes a control case that still upgrades and selects the first subprotocol.
Extended reasoning...

Overview

This PR fixes server.upgrade() in Bun.serve so that WebSocket handshake headers deleted from req.headers by the fetch handler are not silently resurrected from the underlying uWS raw request. The Rust change in src/runtime/server/server_body.rs is a five-line control-flow tweak: the two header-source blocks (JS FetchHeaders vs. raw uWS request) become mutually exclusive via else if instead of sequential with a per-field is_empty() fallback. A redundant unsafe { &*upgrader_ptr } re-borrow and the now-vacuous if value.is_empty() guard are removed, and a four-line comment records the invariant that once FetchHeaders exists it was built from every raw field, so a missing field means the handler deleted it. A new it.each in test/js/bun/websocket/websocket-server.test.ts exercises both the synchronous and post-setImmediate paths for each of the four handshake headers plus a control case.

Security risks

None identified. The change tightens behavior — a handler that removes Sec-WebSocket-Key, Upgrade, or Sec-WebSocket-Version now correctly causes the upgrade to be rejected (400/426) instead of succeeding via the raw-request fallback. No new untrusted-input parsing is introduced; the RFC 6455 validation logic below the changed block is untouched.

Level of scrutiny

Low-to-moderate. The diff is small, mechanical, and localized to one function. I traced the removed unsafe re-borrow: the upgrader reference used at the new else if site is the shared borrow established at line 1803, and no user-JS re-entry occurs between there and line 1862 (the fast_get calls are pure C++ FFI header lookups), so the borrow remains valid and the ref_() at line 1820 keeps the object alive. I also checked whether the "FetchHeaders is authoritative" assumption could be violated by a user-constructed Request — it cannot, because such a request has no ServerRequestContext and returns false at the tagged-pointer match well before this block.

Other factors

The test is placed in the existing describe block for handshake validation, reuses its server/afterEach/rawHandshake scaffolding, uses port: 0, awaits real events (no timing sleeps), and includes a positive control so the "before any await" case demonstrably fails on the pre-fix code path. The PR description's claim that the post-await path already behaved correctly is consistent with the code (raw upgrader.req is None after detach). Bug-hunt exit reason was dry_streak with no findings and no ruled-out candidates. No prior reviews or outstanding objections on the timeline.

Comment on lines +1833 to +1837
// Once `request.headers` exists it is the whole handshake: it was built
// from every field of the uWS request, so a field missing from it now
// was deleted by the handler and must not be read back from the raw
// request (the detached, post-await path never could).
//

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

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

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #41962. That PR removes the raw-request fallback in server.upgrade() entirely instead of skipping it when req.headers exists, so it covers this deleted-header case (the two it.each rows from this PR are included there unchanged) and also the repeated Sec-WebSocket-Key case, which the else if here still accepts on the untouched path. Closing this one so there is a single PR to review. Reopen if the smaller change is preferred.

@robobun robobun closed this Sep 8, 2026
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.

2 participants