Skip to content

Bun.serve: reject upgrade(), timeout() and requestIP() for a Request that another server received - #41811

Open
robobun wants to merge 1 commit into
mainfrom
robobun/22c566ef/server-request-ownership
Open

robobun wants to merge 1 commit into
mainfrom
robobun/22c566ef/server-request-ownership

Conversation

@robobun

@robobun robobun commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • serverB.upgrade(req) accepts a Request that another Bun.serve() instance (A) received, when A and B have the same (TLS, debug) type. The ServerWebSocket is built from B's websocket handlers on A's socket and A's uWS app: B.open/B.message fire, but A.subscriberCount() sees the socket, B.publish() reaches nobody, and B.stop(true) does not close it.
  • serverB.timeout(reqA, n) changes A's connection timeout, and serverB.requestIP(reqA) answers, for the same reason.
  • Cause: on_upgrade resolves the Request with request_context.get::<ServerRequestContext<SSL, DEBUG>>() (src/runtime/server/server_body.rs), which checks only the context type tag. timeout() and requestIP() go through the type-erased AnyRequestContext dispatch and never look at self.

Fix

  • RequestContext already stores a backref to the server that created it (ctx.server, None once detached). Add AnyRequestContext::server_ptr() and NewServer::check_same_server(), and call it from upgrade(), timeout() and requestIP() (for a node:http response object, through its AnyServer field).
  • A Request still attached to a different server throws TypeError: upgrade() must be called on the same server that received the request. A detached Request (finished, aborted, already upgraded) keeps today's results: false, null, no-op.
  • It throws instead of returning false because passing another server's Request is always a programming error, while an aborted or finished request is a runtime condition that false already reports.
  • Verified: test/js/bun/http/bun-server.test.ts ("upgrade(), timeout() and requestIP() throw for a Request that another server received") fails on 1.4.3 and passes with the fix. Also ran the upgrade tests in websocket-server.test.ts, websocket-server-upgrade-reentrant.test.ts, the requestIP/timeout tests in serve.test.ts, and serve-http2.test.ts.

Background

  • Each Bun.serve() call creates one NewServer<SSL, DEBUG> (four monomorphizations). Every request gets a pooled RequestContext whose server field points back at that instance.
  • A JS Request created by the server holds an AnyRequestContext: a (type tag, pointer) pair over the eight RequestContext monomorphizations (HTTP/1 plus the HTTP/2 and HTTP/3 variants). get::<T>() compares only the tag, so two plain-HTTP servers produce contexts that look the same.
  • upgrade() takes the socket out of the HTTP parser of whichever uWS app owns it and registers the WebSocket there. So the app (topics, publish(), stop()) comes from the request, while the handlers come from the server the method was called on.
Notes
  • Found by a two-server fuzz pass: B.upgrade(reqA) -> true, B.open, B.message hi, then A.subscriberCount(room)=1 B.subscriberCount(room)=0 A.publish=1 B.publish=0, after B.stop(true) readyState=1, after A.stop(true) readyState=3. B.timeout(reqA, 2) cut A's connection at about 3 s with A's idleTimeout: 30.
  • If A and B differ in TLS or debug mode, upgrade() already returned false (tag mismatch). The ownership check runs before the tag check, so both cases now throw the same error.
  • HTTP/2 and HTTP/3 requests use the mux contexts. server_ptr() dispatches over those too, so requestIP()/timeout() on the owning server are unchanged (serve-http2.test.ts requestIP/upgrade tests pass).
Rebase notes

Rebased onto main (519963e). The commits are now one commit (the branch had a merge of main in it).


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/http/bun-server.test.ts

@coderabbitai

coderabbitai Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: ec135b3e-498c-4876-84df-231cfbfc9eb2
📥 Commits

Reviewing files that changed from the base of the PR and between bb35d1b and b93225c.

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

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.


Walkthrough

The server runtime now exposes the attached server pointer on AnyRequestContext and checks request or response ownership before requestIP(), timeout(), and upgrade() act. Tests cover cross-server rejection and successful operations by the receiving server.

Changes

Server Ownership Validation

Layer / File(s) Summary
Ownership lookup
src/runtime/server/AnyRequestContext.rs, src/runtime/server/server_body.rs
AnyRequestContext exposes an optional erased pointer to its attached server. NewServer checks whether an owner matches the current server and accepts missing owners.
Guarded server operations
src/runtime/server/server_body.rs, test/js/bun/http/bun-server.test.ts
requestIP(), timeout(), and upgrade() check ownership for their request or response targets. Tests verify that cross-server calls throw and that the receiving server can use these operations.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to b9322

Cross-server upgrade, timeout and requestIP calls now throw as intended. One narrow edge case, a foreign request used while the server listens on a Unix socket or has been terminated, still returns null or false instead of throwing. That is a safe fallback and not worth blocking the merge.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the main change: rejecting upgrade(), timeout(), and requestIP() calls that use a Request received by another server.
Description check ✅ Passed The description explains the problem, fix, expected behavior, and reported verification. It covers the required template information, although it uses Problem, Fix, and Verified headings instead of th…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@robobun

robobun commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on 1.4.3-canary (f42e98025) with two plain-HTTP servers: B.upgrade(reqA) returned true and ran B's open/message handlers, while A.subscriberCount('room') was 1, B.publish() returned 0, and only A.stop(true) closed the socket. B.timeout(reqA, n) and B.requestIP(reqA) also acted on A's connection.

The new test in test/js/bun/http/bun-server.test.ts fails on the stock binary (all three calls "did not throw", and the foreign upgrade detaches the request so the owning server's upgrade() returns false) and passes on this branch.

CI: the only red lane is test/js/node/test/parallel/test-crypto-dh-leak.js on x64-asan, which fails the same way on main and on unrelated PRs; everything else passed on retry. Nothing in the failure set touches this diff.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/bun/http/bun-server.test.ts Outdated
@robobun

robobun commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:45 AM PT - Oct 3rd, 2026

✅ @robobun, your commit b93225c3a95555b369518856e2710342aed2c0e9 passed in Build #123218! 🎉


🧪   To try this PR locally:

bunx bun-pr 41811

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

bun-41811 --bun

Comment thread src/runtime/server/server_body.rs 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.

Code review found no issues

No high-confidence issues detected in this change.

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

@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

I arrived at the same fix independently for the timeout() report (branch robobun/429791ba/serve-foreign-request-check, opened as #41885 before I saw this PR, now closed in favor of this one). Same shape: AnyRequestContext::server_ptr() plus one ownership check in timeout(), requestIP() and both arms of upgrade().

Two small things from that branch that this PR could pick up if wanted:

  • packages/bun-types/serve.d.ts: a @throws {TypeError} If \request` was received by a different serverline onupgrade, requestIPandtimeout, and a short paragraph under "Per-Request Controls" in docs/runtime/http/server.mdx`.
  • The test there drives a real WebSocket handshake through the receiving server's fetch, so it also asserts that after the foreign upgrade() throws, the request is still upgradable by its own server and the socket gets that server's open handler.

…t another server received

Each of these resolved the Request to its RequestContext by context type
only (ssl x debug), so a Request dispatched by server A was accepted by
server B when both had the same type. upgrade() then built the
ServerWebSocket from B's handlers on A's socket and A's uWS app: B's
callbacks fired, but the topics, publish() and stop() stayed with A.
timeout() changed A's connection timeout from B.

The RequestContext already holds a backref to the server that created
it. Compare it with the server the method was called on and throw a
TypeError when they differ. A detached Request (no server) keeps the
old false/null/undefined results.

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/runtime/server/server_body.rs — nit: a server listening on a unix socket silently accepts another server's Request in timeout(), returning null instead of the new error. The is_unix early return at server_body.rs:1570-1572 runs before check_same_server at :1584, unlike requestIP() where the ownership check precedes request_ip's unix check. Fix: run check_same_server before the is_unix return (and before the TERMINATED / handler.server early returns in on_upgrade at :1671-1687) so a foreign Request throws on every variant.

    Why this was flagged

    Bun.serve({ unix: path, ... }) as server B; inside server A's fetch handler call B.timeout(req, 5). timeout() at server_body.rs:1570 returns Ok(JSValue::NULL) because B.config.address.is_unix() before reaching the new check at :1584, so no error is thrown. B.requestIP(req) on the same unix server does throw, because do_request_ip at :1508 checks ownership before request_ip's unix return at :1543. on_upgrade similarly returns false at :1672 and :1686 for a terminated or finalizing B before the check at :1693/:1820. Base returned null in all these cases; the inconsistency is new and harmless (no cross-server mutation happens on these paths), so this is a nit about the variant matrix the PR claims to cover.

    Verification: The server the method is called on is configured with unix: and it is handed a Request owned by a different, still-attached server. In /home/claude/bun/src/runtime/server/server_body.rs, timeout() returns early at lines 1570-1572 before check_same_server at line 1584, so a unix-socket server B silently returns null for B.timeout(reqA, n) instead of throwing the PR's new error.

Comment on lines +1693 to +1697
self.check_same_server(
global,
"upgrade",
Some(node_http_response.server.ptr.cast_const()),
)?;

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.

🔴 node:http users who close() and listen() again lose WebSocket upgrades on connections from the first listener: the upgrade now throws instead of succeeding. ws.js completeUpgrade calls socket.server[kBunInternals].upgrade(req), i.e. the http.Server's CURRENT Bun.serve, while req is a NodeHTTPResponse whose server is the generation that accepted the connection, so check_same_server at server_body.rs:1693-1697 rejects it. Fix: do not apply the ownership check on the NodeHTTPResponse branches of upgrade() and timeout() (both act on the response's own server/connection), or make ws.js call upgrade on the response's own server; same pattern at server_body.rs:1590.

Why this was flagged

An http.Server gets a keep-alive request that is in flight when the user calls server.close() and then server.listen() again. A later WebSocket upgrade request on that connection is dispatched by the first generation's Bun.serve; ws.js completeUpgrade does const server = socket.server[kBunInternals] (src/js/thirdparty/ws.js:1549), which is the NEW Bun.serve set at _http_server.ts:698, then server.upgrade(req, ...) (ws.js:1566) with req = the old generation's NodeHTTPResponse. In on_upgrade the new check at server_body.rs:1693-1697 compares node_http_response.server.ptr (old NewServer) with self (new NewServer) and throws 'upgrade() must be called on the same server that received the request'. On the base branch this call reached node_http_response.upgrade(), which builds the ServerWebSocket from self.server (NodeHTTPResponse.rs:625-641), so the upgrade succeeded. The TERMINATED and handler.server guards at server_body.rs:1671-1687 do not stop this because the old generation was stopped gracefully and the new generation is live.

Verification: src/js/thirdparty/ws.js:1549 const server = socket.server[kBunInternals]; then :1566 server.upgrade(req, {...}). New check at src/runtime/server/server_body.rs:1693-1697 compares node_http_response.server.ptr against self and throws upgrade() must be called on the same server that received the request.

let request = arg.as_class_ref::<Request>().ok_or_else(|| {
global.throw_invalid_arguments(format_args!("Expected Request object"))
})?;
self.check_same_server(global, "requestIP", request.request_context.server_ptr())?;

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.

🔴 Bun.serve users who call requestIP() or timeout() through a server instance other than the one that received the request now get a TypeError and a failed request where the base returned the correct value. This follows from the stated purpose, but for these two methods nothing was mis-wired on the base: request_ip at server_body.rs:1542-1548 and set_timeout at 1585 read only the Request's own context, and the called server contributes nothing but the is_unix check. Fix: either confine the ownership check to upgrade(), where the called server's websocket handlers really are used, or keep the throw and document it as a breaking change; the call is the author's.

Why this was flagged

Two Bun.serve instances exist in one process and a fetch handler shared by both calls A.requestIP(req) or A.timeout(req, n) for a Request that B received; this is ordinary code when one server object is module-level and a second listener (redirect, health, admin port) reuses the same handler. On the base branch do_request_ip resolves the address from request.request_context.get_remote_socket_info() (server_body.rs:1546), which is the request's own socket, and timeout() calls request_context.set_timeout(value) on the request's own connection (server_body.rs:1585); self is only consulted for self.config.address.is_unix(). Both calls therefore returned the right answer for the request. After this change the new lines at server_body.rs:1508 and 1584 throw 'requestIP() must be called on the same server that received the request' / 'timeout() must be called on the same server that received the request', so the handler rejects and the client receives an error response.

Verification: On the base, request_ip (server_body.rs:1542-1555) derives the address solely from request.request_context.get_remote_socket_info(), and the called server contributes only self.config.address.is_unix(); likewise timeout() (server_body.rs:1585) calls request_context.set_timeout(value) on the request's own connection. The new check_same_server (server_body.rs:1282-1297) is wired at :1508 and :1584.

Comment on lines +1693 to +1697
self.check_same_server(
global,
"upgrade",
Some(node_http_response.server.ptr.cast_const()),
)?;

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 (optional): Maintainers get no regression coverage for the node:http half of this fix: the NodeHTTPResponse branches of upgrade() and timeout() gain check_same_server but the new test exercises only Request arguments. The NodeHTTPResponse check at server_body.rs:1693-1697 (and the timeout one at server_body.rs:1590) is reachable from JS via the ws shim path, so it can regress silently. Fix: add a sibling case alongside the new test that drives both NodeHTTPResponse call sites cross-server and same-server, e.g. two node:http servers where otherHttp[Symbol.for("::bunternal::")].upgrade(req.socket[Symbol.for("::bunternal::")]) must throw and the owning server's call must still succeed.

Why this was flagged

The diff adds check_same_server to four entry points: the Request branches of requestIP (server_body.rs:1508), timeout (server_body.rs:1584) and upgrade (server_body.rs:1820), and the NodeHTTPResponse branches of timeout (server_body.rs:1590) and upgrade (server_body.rs:1693-1697). The only test added, test/js/bun/http/bun-server.test.ts:112-163, passes a Bun.serve Request to every call; nothing in the PR passes a NodeHTTPResponse. A NodeHTTPResponse reaches these methods from user JS: _http_server.ts:2191 exposes it as socket[Symbol.for("::bunternal::")] and src/js/thirdparty/ws.js:1549-1566 calls socket.server[kBunInternals].upgrade(socket[kBunInternals], ...). If the NodeHTTPResponse argument expression or the comparison against response.server.ptr were later changed or dropped, no test would fail, so the two node:http sites can silently revert to the pre-PR behaviour of accepting another server's response. REVIEW.md asks that every sibling entry point receiving the same fix be covered.

Verification: Any future change to the NodeHTTPResponse branches of upgrade()/timeout() (server_body.rs:1693-1697 and :1590) can drop the cross-server rejection without any test failing. The only test added (test/js/bun/http/bun-server.test.ts:112-163) passes a Bun.serve Request; nothing passes a NodeHTTPResponse.

This branch has not been deployed

No deployments
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