Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/workerd/api/http.c++
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,11 @@ Body::ExtractedBody Body::extractBody(jsg::Lock& js, Initializer init) {
buffer = kj::mv(text);
}
KJ_CASE_ONEOF(bytesRef, jsg::JsRef<jsg::JsBufferSource>) {
// Per the Fetch spec we must copy the input buffer here. Previously we skipped the copy
// for non-resizable buffers as an optimization, but the spec requires it for all buffer
// types to ensure the body is an independent snapshot of the data at construction time.
// Per the Fetch spec we must copy the input buffer here. Beyond spec conformance, this
// fixes a UAF: the incoming data may alias a v8::BackingStore whose underlying memory can
// be freed if the original ArrayBuffer is detached and transferred (e.g. via structuredClone
// with a transfer list) and then garbage collected. This applies to both resizable and
// fixed-size buffers. Copying severs the dependency on the V8 backing store.
buffer = kj::heapArray(bytesRef.getHandle(js).asArrayPtr());
}
KJ_CASE_ONEOF(blob, jsg::Ref<Blob>) {
Expand Down
6 changes: 6 additions & 0 deletions src/workerd/api/tests/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,12 @@ wd_test(
data = ["blob-test.js"],
)

wd_test(
src = "response-uaf-test.wd-test",
args = ["--experimental"],
data = ["response-uaf-test.js"],
)

Comment thread
erikcorry marked this conversation as resolved.
wd_test(
src = "blob2-test.wd-test",
args = ["--experimental"],
Expand Down
44 changes: 44 additions & 0 deletions src/workerd/api/tests/response-uaf-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0

// Regression test for a use-after-free: creating a Response from an ArrayBuffer,
Comment thread
erikcorry marked this conversation as resolved.
// then transferring the buffer via structuredClone and collecting the clone,
// must not cause the Response body read to access freed memory.
//
// Without the fix, this crashes under ASAN with:
// heap-use-after-free READ of size 1024

export default {
async test() {
// Fixed-size ArrayBuffer (non-resizable). The UAF is not specific to
// resizable buffers — it affects any ArrayBuffer whose backing store can
// be transferred away.
const buffer = new ArrayBuffer(1024);
new Uint8Array(buffer).fill(0x42);

const res = new Response(buffer);

// Transfer detaches the original buffer. The clone becomes the sole
// JS-visible owner of the backing store.
structuredClone(buffer, { transfer: [buffer] });

// Collect the clone (which was not assigned to a variable). This frees
// the backing store memory if nothing else prevents it.
gc();

// Reading the body must not touch freed memory.
const result = new Uint8Array(await res.arrayBuffer());
if (result.length !== 1024) {
throw new Error(`Expected 1024 bytes, got ${result.length}`);
}
// Verify the data is intact (not garbage from freed memory).
for (let i = 0; i < result.length; i++) {
if (result[i] !== 0x42) {
throw new Error(
`Byte ${i}: expected 0x42, got 0x${result[i].toString(16)}`
);
}
}
},
};
15 changes: 15 additions & 0 deletions src/workerd/api/tests/response-uaf-test.wd-test
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Workerd = import "/workerd/workerd.capnp";

const unitTests :Workerd.Config = (
v8Flags = ["--expose-gc"],
services = [
( name = "response-uaf-test",
worker = (
modules = [
(name = "worker", esModule = embed "response-uaf-test.js")
],
compatibilityFlags = ["nodejs_compat"],
)
),
],
);
Loading