server.publish()/ws.publish(): convert topic and message before reading server state - #39389
server.publish()/ws.publish(): convert topic and message before reading server state#39389Jarred-Sumner wants to merge 2 commits into
Conversation
do_publish borrowed the topic as a ZigString view into a JSString and then converted the message with to_js_string(), which can run user JS and GC. When the topic came from toString()/toPrimitive the backing JSString was otherwise unreferenced and got collected, so uWS read a freed buffer. Convert the topic to an owning slice, resolve the message payload before reading the app pointer, and re-fetch the publish context in ServerWebSocket.publish/publishText after converting the message.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 42 minutes Limit details: You’ve used all 1 included review currently available under your plan. You completed 72 included PR reviews in the past 7 days; at that activity level, included reviews refill at 1 review per hour. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
There was a problem hiding this comment.
Beyond the inline finding, I checked the refactored server_body.rs::publish() payload-resolution block: js_str.view(global).to_slice() dropping the temporary ZigString is fine (it's a Copy ptr+len; bytes live in the JSString kept alive via ensure_still_alive), JSValue::to_slice on the topic returns a ref-holding/owned ZigStringSlice that survives GC, and the removed second empty-topic check was unreachable.
Extended reasoning...
Checked the ~60-line refactor of server_body.rs::publish() for lifetime hazards introduced by collapsing three branches into one: the deferred-init let string_slice; outlives buffer, the array_buffer binding is hoisted so buffer.slice() stays valid, and to_boolean() on compress doesn't run user JS. The topic now goes through JSValue::to_slice (owning/ref-holding) instead of get_zig_string (borrowed view), which is the actual UAF fix. The removed "publish requires a non-empty topic" branch was dead — ZigString::to_slice() on a non-empty ZigString never yields an empty slice.
| // `to_js_string` can run user JS that stops the server. | ||
| let Some(ctx) = self.publish_ctx() else { | ||
| return Ok(JSValue::js_number(0.0)); | ||
| }; |
There was a problem hiding this comment.
🔴 send() has the same "to_js_string can run user JS" shape this PR guards in publish()/publishText(), but is left unguarded: an object message's [Symbol.toPrimitive] can call ws.terminate(), which synchronously runs webSocketData->~WebSocketData(), and the following self.websocket().send(...) then reads/appends to the destructed ext (getBufferedAmount(), buffer.append). close() in this file already re-checks is_closed() after coercion for exactly this reason — add if self.is_closed() { return Ok(JSValue::js_number(0.0)); } right after to_js_string here to complete the class.
Extended reasoning...
What the bug is
ServerWebSocket::send() calls message_value.to_js_string(global_this)? on any non-buffer, non-Blob argument without an is_string() gate (unlike sendText()), so an object argument's [Symbol.toPrimitive]/toString runs. That user JS can call ws.terminate() (or server.stop(true)), which — before send() proceeds — synchronously destructs the uWS per-socket ext data. The very next line, self.websocket().send(buffer, Opcode::Text, compress, true), then operates on a destructed C++ object.
Code path
send()reaches the string branch and callsto_js_string(global_this)on an object arg.- User
[Symbol.toPrimitive]callsws.terminate()→self.websocket().close()→uws_ws_close→us_socket_close(). us_socket_close()dispatches the close callback synchronously;WebSocketContext's close handler runswebSocketData->~WebSocketData();(WebSocketContext.h:307), destructing the in-placedWebSocketData(whose baseAsyncSocketDataowns astd::string buffer).- Control returns to
send().self.flagsstill holds the samepacked_websocket_ptr(nothing clears it), soself.websocket()returns the same pointer. WebSocket::send()(bun-uws WebSocket.h:129+) readsgetBufferedAmount()— the destructedstd::string's.length()— andwebSocketData->subscriber, and on the write2 short-write path callswebSocketData->buffer.append(...)on the destructed string. All of this is C++ UB; theappendin particular is heap corruption if the string had freed its backing storage.
Why existing code doesn't prevent it
send() checks self.is_closed() before the coercion, not after. Nothing after to_js_string re-validates liveness. In contrast, close() in this same file already carries the guard — see the comment "to_slice_or_null can run user toString(), which may re-entrantly ws.close()… re-check the guard" followed by if self.is_closed() { return Ok(JSValue::UNDEFINED); }. And this PR adds the analogous re-check to publish() / publishText() after to_js_string. send() is the remaining sibling.
Addressing the refutation
One reviewer objected that (a) us_socket_close() does not free the socket allocation synchronously (it links to closed_head for end-of-tick free), so the pointer is not "dangling", and (b) this is not the same class as the PR's fix because send() reads neither the topic bytes nor handler.app.
Point (a) is correct about the allocation but does not make the code safe. The socket struct itself survives the tick, but its ext — the in-placed WebSocketData — is destructed synchronously at WebSocketContext.h:307 by the close handler that us_socket_close dispatches before returning. WebSocket::send() immediately reads from and (on the short-write branch) writes into that destructed object. Using a destructed non-trivial C++ object is undefined behavior regardless of whether its storage has been deallocated; if buffer had a heap allocation, ~basic_string() freed it and the subsequent .length()/.append() is a heap use-after-free of the string's backing. us_socket_write2 checking is_closed and returning 0 does not help — that 0 is what routes execution into the backpressure branch that touches buffer.
Point (b) reads the class too narrowly. REVIEW.md's rule is about the pattern — "Anything that can run user JS can synchronously free your state… re-validate liveness guards after every callback" — and this PR is applying exactly that pattern to to_js_string sites in this file. The PR's own added comment reads "to_js_string can run user JS that stops the server"; server.stop(true) force-closes this socket too, landing in the same ~WebSocketData() path. And close() already establishes is_closed()-after-coercion as the local idiom for this pattern. Per REVIEW.md — "Fix the whole class in the same PR… same-class sites are ONE concern, not scope creep. Grep for every sibling site sharing the pattern" — send() is a sibling site.
Step-by-step proof
// inside a websocket message handler:
ws.send(Object.assign(new String('z'), {
[Symbol.toPrimitive]() { ws.terminate(); Bun.gc(true); return 'x'; }
}));send():is_closed()→ false; falls through buffer/Blob checks; callsto_js_string.toPrimitiveruns →terminate()setsflags.closed = true, callsself.websocket().close()→us_socket_close→ close handler runswebSocketData->~WebSocketData()and Ruston_close(which seeswas_closed = trueand skips accounting).to_js_stringreturns"x".self.websocket().send("x", Text, …)→ WebSocket.h:133 readsgetBufferedAmount()on destructedAsyncSocketData::buffer; line 147 reads destructedwebSocketData->subscriber; if the ≥16 KB fast path is taken,us_socket_write2returns 0 (closed) and the else-branch appends header+payload to the destructedbuffer. UB / heap corruption.
Fix
One line, mirroring close() and this PR's publish() change:
let js_string = message_value.to_js_string(global_this)?;
if self.is_closed() {
return Ok(JSValue::js_number(0.0));
}
let view = js_string.view(global_this);|
Updated 9:10 PM PT - Aug 16th, 2026
❌ @Jarred-Sumner, your commit aadbbb2 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 39389That installs a local version of the PR into your bun-39389 --bun |
…e publish context once, after conversion
| const TOPIC = "t_".repeat(4000); | ||
| const server = Bun.serve({ | ||
| port: 0, | ||
| fetch(req, s) { return s.upgrade(req) ? undefined : new Response("x"); }, | ||
| websocket: { open(ws) { ws.subscribe(TOPIC); }, message() {} }, | ||
| }); | ||
| const client = new WebSocket("ws://127.0.0.1:" + server.port + "/"); | ||
| const got = Promise.withResolvers(); | ||
| client.onmessage = e => got.resolve(e.data); | ||
| client.onclose = () => got.resolve("closed"); | ||
| await new Promise((resolve, reject) => { client.onopen = resolve; client.onerror = reject; }); | ||
| // A String object so toPrimitive hands back a fresh, otherwise-unreferenced JSString. | ||
| const topic = Object.assign(new String("z"), { [Symbol.toPrimitive]() { return "t_".repeat(4000).slice(0) + ""; } }); | ||
| const data = Object.assign(new String("z"), { | ||
| [Symbol.toPrimitive]() { | ||
| Bun.gc(true); | ||
| const k = []; | ||
| for (let i = 0; i < 300; i++) k.push("Q".repeat(40000 + i)); |
There was a problem hiding this comment.
🟡 Use Buffer.alloc(n, fill).toString() instead of "x".repeat(n) in the new test's subprocess script — "t_".repeat(4000) (twice) and "Q".repeat(40000 + i) in the 300-iteration loop. .repeat() is very slow in debug JSC builds (REVIEW.md harness convention), and the neighboring Sec-WebSocket-Protocol test in this file already follows the pattern with Buffer.alloc(128, ...).toString().
Extended reasoning...
What the issue is
The new "server.publish() keeps the topic alive while converting the message" test spawns a subprocess whose script builds large repetitive strings with String.prototype.repeat:
const TOPIC = "t_".repeat(4000);— an 8,000-char topic[Symbol.toPrimitive]() { return "t_".repeat(4000).slice(0) + ""; }— the same 8,000 chars again per publishfor (let i = 0; i < 300; i++) k.push("Q".repeat(40000 + i));— ~12M chars of.repeat()inside the heap-spray loop
REVIEW.md's "Copy harness conventions exactly" section explicitly lists this: "Buffer.alloc(n, fill).toString() instead of \"x\".repeat(n) (slow in debug JSC)." test/CLAUDE.md carries the same rule. The subprocess runs the debug-built binary under Malloc=1, so debug-JSC .repeat() cost applies directly.
Why the convention matters here
The 300 × ~40k .repeat() loop is exactly the shape the rule targets — millions of characters through a debug-JSC intrinsic that is not JIT-optimized in debug builds. Under debug+ASAN this can add measurable wall-clock to a file that already runs a lot of subprocess tests, and REVIEW.md notes "a correct but slow test still gets changes-requested."
Why the substitution is safe for the repro
The test's GC-UAF repro needs (a) a fresh, otherwise-unreferenced JSString for the topic so Bun.gc(true) inside the message's toPrimitive can collect it, and (b) heap spray to reuse the freed block. Buffer.alloc(n, fill).toString() satisfies both:
Buffer.alloc(8000, "t_").toString()allocates a fresh JSString each call from the native UTF-8 → JS path; nothing else references it, so it is just as collectible as the current"t_".repeat(4000).slice(0) + ""result. The.slice(0) + ""de-rope dance becomes unnecessary.Buffer.alloc(40000 + i, "Q").toString()produces the same-length, same-content spray strings — identical heap pressure.
The neighboring "Sec-WebSocket-Protocol … does not use-after-free" test in this same file (also a Malloc=1 ASAN repro of a freed StringImpl) already follows the convention: const part = Buffer.alloc(128, "abcdefghijklmnopqrstuvwxyz0123456789").toString();.
Step-by-step
- Subprocess starts under the debug binary with
Malloc=1. "t_".repeat(4000)runs once forTOPIC— 8,000 chars via debug.repeat().server.publish(topic, data)triggerstopic[Symbol.toPrimitive]→ another"t_".repeat(4000)+.slice(0)+ concat.- Then
data[Symbol.toPrimitive]runs the 300-iteration loop; each iteration calls"Q".repeat(40000 + i)— cumulatively ~12M chars through debug.repeat(). - None of this
.repeat()work is load-bearing for the assertion — the test only checks{ rc: 7, result: "payload" }— so the equivalentBuffer.alloc(...).toString()output is byte-identical while running through Bun's native fast path.
Fix
const TOPIC = Buffer.alloc(8000, "t_").toString();
// …
const topic = Object.assign(new String("z"), { [Symbol.toPrimitive]() { return Buffer.alloc(8000, "t_").toString(); } });
// …
for (let i = 0; i < 300; i++) k.push(Buffer.alloc(40000 + i, "Q").toString());Severity
Nit — a documented harness/style convention affecting debug-lane test speed, not correctness.
|
Re the send() note: the closed check after string conversion for send/sendText/subscribe/etc. is in #39385 (each method now converts its argument once, then does a single closed check right before the uWS call), so it isn't duplicated here. |
What
server.publish(topic, data)borrowed the topic as aZigStringview into a JSString and then converteddatawithto_js_string(), which can run user JS and GC. When the topic came fromtoString()/toPrimitivethe backing JSString was otherwise unreferenced and got collected, so uWSTopicTree::lookupTopicread a freed buffer (ASanheap-use-after-free READ of size 8000withMalloc=1; silently mis-delivered otherwise).ServerWebSocket.publish/publishText/publishBinaryhad the same shape via the publish context fetched before the conversions.Both paths now convert the topic and the message first —
to_js_string()once each, holding theJSString*s (ensure_still_aliveafter the uWS call) and borrowing their views — and readself.app/ the publish context exactly once, after that.Repro (before)
Tests
test/js/bun/websocket/websocket-server.test.ts— "server.publish() keeps the topic alive while converting the message". Fails on the ASan canary and debug main; passes here.