From dcdb3397254858e548941059ec1b1f4d16bd04da Mon Sep 17 00:00:00 2001 From: Steven Zimmerman <15812269+EffortlessSteven@users.noreply.github.com> Date: Sat, 30 May 2026 16:10:56 -0400 Subject: [PATCH] fetch: avoid aliasing cloned body buffers --- src/runtime/webcore/Blob.rs | 14 ++++++++++ test/js/web/fetch/body-clone.test.ts | 38 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 49348281ff42..2dc841ea5bab 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3164,6 +3164,20 @@ impl BlobExt for Blob { self.detach(); return Err(global.throw_out_of_memory()); } + // arrayBuffer()/bytes() must copy the bytes (WHATWG). The transfer + // below aliases the store's bytes, observable once the store is + // shared — Response.clone() bumps the store refcount, so mutating + // the clone's buffer would mutate the original (oven-sh/bun#22885). + // Only transfer when this blob uniquely owns the store; else copy. + if let Some(store) = self.store() { + if !store.has_one_ref() { + // SAFETY: `buf` is the store-backed view, read-only here; + // the other owner keeps the store (and `buf`) alive. + return jsc::ArrayBuffer::create::(global, unsafe { + &*buf + }); + } + } // Move the existing +1 out. Cloning then `transfer()` would leak a ref. let store = self.take_store().expect("transfer with null store"); // SAFETY: see `Share` arm. After `take()` the store ref is moved diff --git a/test/js/web/fetch/body-clone.test.ts b/test/js/web/fetch/body-clone.test.ts index ea75f57a9c11..2dbb63bf0daf 100644 --- a/test/js/web/fetch/body-clone.test.ts +++ b/test/js/web/fetch/body-clone.test.ts @@ -568,3 +568,41 @@ test("Blob type from a consumed Response keeps the original content-type after c expect(stdout.trim().split("\n")).toEqual(["application/x-original-type-0000000000000001", "clone-ok", "churn-ok"]); expect(exitCode).toBe(0); }); + +// Regression for oven-sh/bun#22885: arrayBuffer()/bytes() must copy the body +// bytes (WHATWG), not hand out a view aliasing the shared store. Response.clone() +// shares the backing store, so mutating a clone's buffer leaked into the original. +// String bodies already copied; these use store-backed bodies (fetch/Blob/typed +// array), which took the zero-copy transfer path. +test("Response.clone().arrayBuffer() does not alias the original body (#22885)", async () => { + using server = Bun.serve({ port: 0, fetch: () => new Response("hello world") }); + const x = await fetch(server.url); + const buffer = await x.clone().arrayBuffer(); + // The clone reads the correct bytes before mutation. + expect(new TextDecoder().decode(buffer)).toBe("hello world"); + // Mutating the clone's buffer must not reach back into the original body. + new Uint8Array(buffer).fill("X".charCodeAt(0)); + expect(await x.text()).toBe("hello world"); +}); + +test("Response.clone().bytes() does not alias the original body (#22885)", async () => { + const x = new Response(new Blob(["hello world"])); + const bytes = await x.clone().bytes(); + expect(new TextDecoder().decode(bytes)).toBe("hello world"); + bytes.fill("X".charCodeAt(0)); + expect(await x.text()).toBe("hello world"); +}); + +test("mutating one clone's buffer leaves the original and sibling clones intact (#22885)", async () => { + const x = new Response(new Uint8Array([104, 101, 108, 108, 111])); // "hello" + const first = x.clone(); + const second = x.clone(); + new Uint8Array(await first.arrayBuffer()).fill("X".charCodeAt(0)); + expect(await second.text()).toBe("hello"); + expect(await x.text()).toBe("hello"); +}); + +test("Response.arrayBuffer() without clone still returns the body bytes (#22885 fast path)", async () => { + const x = new Response(new Uint8Array([104, 101, 108, 108, 111])); // "hello" + expect(new TextDecoder().decode(await x.arrayBuffer())).toBe("hello"); +});