CVE-2026-79068
Overview
Files Changed
third_party/blink/renderer/core/streams/readable_stream.cc
Patch
From 9c98825aa2512e76331f40fe071c69d55e3132bf Mon Sep 17 00:00:00 2001
From: Adam Rice <ricea@chromium.org>
Date: Tue, 14 Jul 2026 16:04:58 -0700
Subject: [PATCH] [Streams] Verify stream usage within the same isolated world
ReadableStream and WritableStream objects are bound to the isolated
world they were created in. Passing them across isolated worlds
boundaries can lead to security issues or unexpected behavior due to
context mismatch.
This CL adds `wrapper_world_id_` to `WritableStream` and
`ReadableStream`. It records the `DOMWrapperWorld` ID on construction
and CHECKs that any subsequent operations on the stream use a
`ScriptState` belonging to the same world. This will help catch cases
where streams are improperly passed between isolated worlds.
Bug: 520516462
Change-Id: I7fbe0788181c84c6138dc2413b747dbbdfa767e2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7917395
Commit-Queue: Adam Rice <ricea@chromium.org>
Reviewed-by: Nidhi Jaju <nidhijaju@chromium.org>
Reviewed-by: Fergal Daly <fergal@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1662235}
---
diff --git a/third_party/blink/renderer/core/streams/readable_stream.cc b/third_party/blink/renderer/core/streams/readable_stream.cc
index 868aa34..e82ad0fe 100644
--- a/third_party/blink/renderer/core/streams/readable_stream.cc
+++ b/third_party/blink/renderer/core/streams/readable_stream.cc
@@ -39,6 +39,7 @@
#include "third_party/blink/renderer/core/streams/writable_stream_default_controller.h"
#include "third_party/blink/renderer/core/streams/writable_stream_default_writer.h"
#include "third_party/blink/renderer/core/streams/writable_stream_transferring_optimizer.h"
+#include "third_party/blink/renderer/platform/bindings/dom_wrapper_world.h"
#include "third_party/blink/renderer/platform/bindings/exception_code.h"
#include "third_party/blink/renderer/platform/bindings/exception_state.h"
#include "third_party/blink/renderer/platform/bindings/script_state.h"
@@ -287,7 +288,7 @@
ScriptValue underlying_source,
ScriptValue strategy,
ExceptionState& exception_state) {
- auto* stream = MakeGarbageCollected<ReadableStream>();
+ auto* stream = MakeGarbageCollected<ReadableStream>(script_state);
stream->InitInternal(script_state, underlying_source, strategy, false,
exception_state);
if (exception_state.HadException()) {
@@ -315,7 +316,7 @@
std::unique_ptr<ReadableStreamTransferringOptimizer> optimizer) {
V8DoNotRunMicrotasksScope microtasks_scope(script_state);
- auto* stream = MakeGarbageCollected<ReadableStream>();
+ auto* stream = MakeGarbageCollected<ReadableStream>(script_state);
stream->InitWithCountQueueingStrategy(
script_state, underlying_source, high_water_mark,
allow_per_chunk_transferring, std::move(optimizer), IGNORE_EXCEPTION);
@@ -362,7 +363,7 @@
DCHECK_GE(high_water_mark, 0);
// 4. Let stream be a new ReadableStream.
- auto* stream = MakeGarbageCollected<ReadableStream>();
+ auto* stream = MakeGarbageCollected<ReadableStream>(script_state);
// 5. Perform ! InitializeReadableStream(stream).
Initialize(stream);
@@ -393,7 +394,7 @@
ExceptionState& exception_state) {
// https://streams.spec.whatwg.org/#abstract-opdef-createreadablebytestream
// 1. Let stream be a new ReadableStream.
- auto* stream = MakeGarbageCollected<ReadableStream>();
+ auto* stream = MakeGarbageCollected<ReadableStream>(script_state);
// 2. Perform ! InitializeReadableStream(stream).
Initialize(stream);
@@ -420,7 +421,7 @@
UnderlyingByteSourceBase* underlying_byte_source) {
// https://streams.spec.whatwg.org/#abstract-opdef-createreadablebytestream
// 1. Let stream be a new ReadableStream.
- auto* stream = MakeGarbageCollected<ReadableStream>();
+ auto* stream = MakeGarbageCollected<ReadableStream>(script_state);
// Construction of the byte stream cannot fail because the trivial start
// algorithm will not throw.
@@ -436,6 +437,7 @@
ReadableStream* stream,
UnderlyingByteSourceBase* underlying_byte_source,
ExceptionState& exception_state) {
+ CHECK(stream);
auto* pull_algorithm =
MakeGarbageCollected<PullAlgorithm>(underlying_byte_source);
auto* cancel_algorithm =
@@ -461,6 +463,7 @@
StreamAlgorithm* pull_algorithm,
StreamAlgorithm* cancel_algorithm,
ExceptionState& exception_state) {
+ CHECK(stream);
// Step 2 and 4 of
// https://streams.spec.whatwg.org/#abstract-opdef-createreadablebytestream
// 2. Perform ! InitializeReadableStream(stream).
@@ -476,7 +479,8 @@
}
}
-ReadableStream::ReadableStream() = default;
+ReadableStream::ReadableStream(ScriptState* script_state)
+ : wrapper_world_id_(script_state->World().GetWorldId()) {}
ReadableStream::~ReadableStream() = default;
@@ -489,6 +493,7 @@
ScriptPromise<IDLUndefined> ReadableStream::cancel(
ScriptState* script_state,
ExceptionState& exception_state) {
+ CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
return cancel(script_state,
ScriptValue(script_state->GetIsolate(),
v8::Undefined(script_state->GetIsolate())),
@@ -499,6 +504,7 @@
ScriptState* script_state,
ScriptValue reason,
ExceptionState& exception_state) {
+ CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
// https://streams.spec.whatwg.org/#rs-cancel
// 2. If ! IsReadableStreamLocked(this) is true, return a promise rejected
// with a TypeError exception.
@@ -514,6 +520,7 @@
V8ReadableStreamReader* ReadableStream::getReader(
ScriptState* script_state,
ExceptionState& exception_state) {
+ CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
// https://streams.spec.whatwg.org/#rs-get-reader
// 1. If options["mode"] does not exist, return ?
// AcquireReadableStreamDefaultReader(this).
@@ -528,6 +535,7 @@
ScriptState* script_state,
const ReadableStreamGetReaderOptions* options,
ExceptionState& exception_state) {
+ CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
// https://streams.spec.whatwg.org/#rs-get-reader
if (options->hasMode()) {
DCHECK_EQ(options->mode(), V8ReadableStreamReaderMode::Enum::kByob);
@@ -548,6 +556,7 @@
ReadableStreamDefaultReader* ReadableStream::GetDefaultReaderForTesting(
ScriptState* script_state,
ExceptionState& exception_state) {
+ CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
auto* result = getReader(script_state, exception_state);
if (!result)
return nullptr;
@@ -557,6 +566,7 @@
ReadableStreamBYOBReader* ReadableStream::GetBYOBReaderForTesting(
ScriptState* script_state,
ExceptionState& exception_state) {
+ CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
auto* options = ReadableStreamGetReaderOptions::Create();
options->setMode(V8ReadableStreamReaderMode::Enum::kByob);
auto* result = getReader(script_state, options, exception_state);
@@ -568,6 +578,7 @@
ReadableStream* ReadableStream::pipeThrough(ScriptState* script_state,
ReadableWritablePair* transform,
ExceptionState& exception_state) {
+ CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
return pipeThrough(script_state, transform, StreamPipeOptions::Create(),
exception_state);
}
@@ -577,12 +588,17 @@
ReadableWritablePair* transform,
const StreamPipeOptions* options,
ExceptionState& exception_state) {
+ CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
// https://streams.spec.whatwg.org/#rs-pipe-through
- DCHECK(transform->hasReadable());
+ CHECK(transform->hasReadable());
ReadableStream* readable_stream = transform->readable();
- DCHECK(transform->hasWritable());
+ CHECK(transform->hasWritable());
WritableStream* writable_stream = transform->writable();
+ CHECK_EQ(writable_stream->GetWrapperWorldId(),
+ script_state->World().GetWorldId());
+ CHECK_EQ(readable_stream->wrapper_world_id_,
+ script_state->World().GetWorldId());
// 1. If ! IsReadableStreamLocked(this) is true, throw a TypeError exception.
if (IsLocked(this)) {
@@ -617,6 +633,7 @@
ScriptState* script_state,
WritableStream* destination,
ExceptionState& exception_state) {
+ CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
return pipeTo(script_state, destination, StreamPipeOptions::Create(),
exception_state);
}
@@ -626,6 +643,7 @@
WritableStream* destination,
const StreamPipeOptions* options,
ExceptionState& exception_state) {
+ CHECK_EQ(wrapper_world_id_, script_state->World().GetWorldId());
// https://streams.spec.whatwg.org/#rs-pipe-to
// 1. If ! IsReadableStreamLocked(this) is true, return a promise rejected
// with a TypeError exception.
Original Bug Report
Potential cross-world object leak in Blink Streams implementation via stored_error_
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential cross-world object leak in Blink’s Streams implementation (WritableStream and ReadableStream) allows a context in one DOMWrapperWorld to obtain a direct V8 reference to an object from another world. This occurs because the stream’s error state (stored_error_) is held as a raw, non-world-safe V8 reference and returned to other worlds through promise rejections. This can potentially bypass DOMWrapperWorld isolation, allowing cross-context code execution.
Affected files:
third_party/blink/renderer/core/streams/writable_stream.hthird_party/blink/renderer/core/streams/writable_stream.ccthird_party/blink/renderer/core/streams/writable_stream_default_writer.ccthird_party/blink/renderer/modules/serial/serial_port.cc
Estimated timestamp from git blame: 2019-07-09
Root Cause Analysis
In Blink’s WritableStream (third_party/blink/renderer/core/streams/writable_stream.h:282) and ReadableStream (third_party/blink/renderer/core/streams/readable_stream.h:367) implementations, the stored_error_ field stores the stream’s error state as a raw, non-world-safe V8 reference:
TraceWrapperV8Reference<v8::Value> stored_error_;
When a stream is aborted or errored, this field is populated with the raw V8 value provided by the caller. When the error is later propagated to stream writers or readers, it is read raw using GetStoredError(isolate) and passed directly to resolver rejections.
For example, in WritableStreamDefaultWriter::EnsureReadyPromiseRejected (third_party/blink/renderer/core/streams/writable_stream_default_writer.cc:308):
writer->ready_resolver_->Reject(error);
Because ready_resolver_->Reject(v8::Local<v8::Value>) delegates to Reject<IDLAny>, it resolves/rejects the promise using ToV8Traits<IDLAny>::ToV8. For raw v8::Local<v8::Value> types, this traits class returns the reference verbatim without cloning or performing a world boundary check (to_v8_traits.h:96-101). Consequently, a promise in one DOMWrapperWorld (e.g., the main world) can be rejected with a raw V8 object originating from another DOMWrapperWorld (e.g., an isolated world).
Similarly, when the writer’s ready promise is already settled, EnsureReadyPromiseRejected overwrites the resolver with a new one created in the active caller’s context (writable_stream_default_writer.cc:304-305). When a script in a different world accesses writer.ready, it receives this cross-world promise verbatim via ToV8Traits<IDLPromise<T>>::ToV8 (script_promise.h:74-80).
Potential Attack Scenario
Below are the potential/conceptual steps an attacker might follow to trigger this boundary bypass (note that these are suggested steps, as our tooling does not have the ability to execute code or verify a functional proof of concept):
- A page (running in DOMWrapperWorld A) and a victim extension content script (running in isolated DOMWrapperWorld B) obtain access to the same underlying C++
WritableStreamorReadableStreaminstance. - The content script in World B aborts or errors the stream, passing a local object (such as an Error or DOMException wrapper) as the reason. This raw World B reference is stored in the stream’s C++
stored_error_field. - The page in World A gets a writer on the shared stream and attempts to read or write to it, or registers a handler on the writer’s
readypromise. - Due to the lack of world-safety checking on
stored_error_, the main-world promise is rejected with the raw V8 object originating from World B (or the page receives a raw reference to World B’s promise). - The page catches the rejection and accesses the leaked object’s constructor chain (e.g.,
e.constructor.constructor('return globalThis')()) to obtain a reference to World B’s global context, potentially allowing the execution of arbitrary JavaScript inside the extension’s privileged context.
Suggested Remediation
To prevent raw V8 references from crossing world boundaries, the stream’s error state should be stored and retrieved in a world-safe manner.
- Modify
stored_error_to useWorldSafeV8Referenceinstead ofTraceWrapperV8Reference:WorldSafeV8Reference<v8::Value> stored_error_; - When retrieving the stored error, ensure it is accessed or cloned appropriately for the target
ScriptStateusingGetAcrossWorld(target_script_state)or by utilizingScriptValuewrappers when rejecting resolvers.
Evaluated with Chrome root at commit: e9507a33bb4148ee071aaaf8a7e9ad68770359bf
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.