Repository navigation
Bun.serve: reject a Request that a different server received in timeout(), requestIP() and upgrade() - #41885
Bun.serve: reject a Request that a different server received in timeout(), requestIP() and upgrade()#41885robobun wants to merge 1 commit into
Conversation
…ut(), requestIP() and upgrade() server.timeout(), server.requestIP() and server.upgrade() resolved the Request's context and acted on it without checking which server the context belongs to. One server could clear or shorten another server's connection timeout, and upgrade() attached this server's websocket handlers to a connection that lives in the other server's app. Compare the context's server backref with the receiver and throw a TypeError (ERR_INVALID_ARG_VALUE) when they differ. A Request that already detached from its server keeps the existing no-op behavior.
|
Warning Review limit reached
On-demand reviews are free for the next 13 days. After that, they cost $0.25 per reviewed file. Or wait 17 minutes for your next included review. View limit detailsLimit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (5)
Comment |
|
Closing: this is covered by #41811, which makes the same change (an ownership check in |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. It changes user-facing API behavior — requestIP()/timeout()/upgrade() now throw where they previously no-op'd or (for requestIP) returned a correct value — and touches a CODEOWNER-gated .d.ts, so a maintainer should sign off on the tradeoff the description already flags.
What was reviewed:
check_request_ownerpointer comparison:core::ptr::from_ref(self).cast()vs the type-erasedserver_ptr()/AnyServer.ptr— same address space, null keeps the detached no-op.- All five call sites (Request + NodeHTTPResponse arms of
timeout/upgrade, plusrequest_ip); unix-socket early returns still precede the check. - Error routed through
jsc::ErrorCode::INVALID_ARG_VALUE; message names the fix. - New test:
port: 0,using,withResolverswithonerror/onclosewired to reject, one.toEqualon the combined result, asserts the upgrade landed on the right server.
Extended reasoning...
Overview
The PR adds an ownership check to Bun.serve's per-request methods so that calling server.timeout(), server.requestIP(), or server.upgrade() with a Request/NodeHTTPResponse that a different Bun.serve() instance dispatched throws a TypeError (ERR_INVALID_ARG_VALUE). It touches AnyRequestContext.rs (new server_ptr()), server_body.rs (new check_request_owner() wired into five call sites), serve.d.ts (@ throws JSDoc), the server docs page, and adds one concurrent test to serve.test.ts.
Security risks
None identified. The change tightens validation rather than relaxing it. The pointer comparison is identity-only (no dereference of the foreign pointer), and the null case falls through to the pre-existing no-op path, so a detached/pooled context can't be misidentified. There is no new user-controlled data reaching a native boundary.
Level of scrutiny
Medium-high, warranting maintainer review rather than auto-approval. This is a deliberate behavior change on a public API surface: previously A.requestIP(reqB) returned a correct value and A.timeout(reqB, n) silently acted on B's connection; now both throw. REVIEW.md's "Never silently break existing users" applies — the PR argues (reasonably) that any such caller is already a bug, but the description itself offers to drop the requestIP check if the tradeoff isn't wanted, which is a design call for a maintainer. Additionally, packages/bun-types/serve.d.ts is CODEOWNER-gated.
Other factors
The implementation follows the repo's error conventions (centralized jsc::ErrorCode, remedy in the message), the pointer-provenance shape matches src/CLAUDE.md guidance (core::ptr::eq / from_ref), and the new test follows test/CLAUDE.md conventions closely (no sleeps, failure events reject the awaited promise, single combined .toEqual, it.concurrent). The test exercises the Request arm; the NodeHTTPResponse arm's throw path isn't directly asserted, though the PR reports the first_party/ws suite (which goes through that arm on the happy path) passes. No prior reviews or objections exist on the timeline.
Problem
server.timeout(req, s),server.requestIP(req)andserver.upgrade(req)act on aRequestthat a differentBun.serve()instance received.A.timeout(reqB, 0)clears B'sidleTimeouton that connection, and for a WebSocket handshake sent to B,A.upgrade(reqB)returnstrueand runs A's handlers on B's connection.timeout()(server_body.rs:1568),request_ip()(:1533) andon_upgrade()(:1796) userequest.request_contextwithout comparing itsserverbackref withself.on_upgrade()only matched the ssl/debug type tag.Fix
AnyRequestContext::server_ptr()returns the owning server.check_request_owner()compares it withselfand throwsTypeError(ERR_INVALID_ARG_VALUE) for a different live server. TheNodeHTTPResponsearms compareresponse.serverthe same way.null/falseresults. The foreign case throws, because it is always a caller bug and the message names the fix: use theserverthatfetch(req, server)receives.ctx.serveris the receiver's own address on every dispatch path, and no internal caller, doc example or existing test pairs a request with another server.test/js/bun/http/serve.test.ts(new test, fails on 1.4.3), plus the websocket-server andfirst_party/wssuites.Background
RequestContextwith aserverbackref to theNewServerthat owns the pool. A JSRequestreaches it through the tagged pointerAnyRequestContext.server.upgrade()builds aServerWebSocketfrom the receiver'swebsockethandlers, then moves the socket into the WebSocket context of the request's own server. With a foreign receiver, the handlers and the socket belong to different servers.Notes
requestIP()on a foreign request was read-only and returned a correct value before this change. It now throws too, so the three per-request methods share one contract. Code that shares one handler between two servers and closes over the wrongservervariable gets the error message instead of working by accident forrequestIP/timeoutand failing silently (or cross-wiring) forupgrade. If that tradeoff is not wanted forrequestIP, dropping its check is a one-line change. A search of the issue tracker found no report that depends on cross-server calls.nullfromtimeout()/requestIP()before they look at the request, as before.--hot: a reload that keeps the same server id reuses the sameNewServer, so in-flight requests still match. A reload that changes hostname/port creates a new server, and the old server's in-flight requests are foreign to it.ws'shandleUpgrade()path callsserver.upgrade(nodeHttpResponse)withsocket.server[kBunInternals], which is the server that created the response, so it is unaffected (first_party/ws78/78 pass).seconds > 255clamp intimeout()is a separate item and is covered by Bun.serve: validate server.timeout() seconds argument #34958.idleTimeout: 8answered at 13 s afterA.timeout(reqB, 0); A withidleTimeout: 30was reset at 3 s afterB.timeout(reqA, 2).A.requestIP(reqB)answered. A WebSocket client to B got A's handlers andA.pendingWebSocketsbecame 1 while B's stayed 0.requestIP|timeout|upgradetests inserve.test.ts,websocket-server.test.ts+websocket-server-upgrade-reentrant.test.ts+websocket-upgrade-signal-gc.test.ts,first_party/ws/ws.test.ts+ws-upgrade-events.test.ts,serve-http2-lifecycle.test.ts,bun-serve-routes.test.ts,bun-server.test.ts,bun-types.test.ts.