Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper resource exposure in StreamsAPI
DescriptionImproper resource exposure in StreamsAPI
ComponentStreamsAPI
Bug ClassLogic Error
Tracker520516462
Fix commit9c98825aa251 (chromium/src) +129/-27
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Files Changed

  • third_party/blink/renderer/core/streams/readable_stream.cc
From 9c98825aa2512e76331f40fe071c69d55e3132bf Mon Sep 17 00:00:00 2001
From: Adam Rice <[email protected]>
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 <[email protected]>
Reviewed-by: Nidhi Jaju <[email protected]>
Reviewed-by: Fergal Daly <[email protected]>
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.
Loading diff…

Original Bug Report

reported by [email protected]

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.h
  • third_party/blink/renderer/core/streams/writable_stream.cc
  • third_party/blink/renderer/core/streams/writable_stream_default_writer.cc
  • third_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):

  1. A page (running in DOMWrapperWorld A) and a victim extension content script (running in isolated DOMWrapperWorld B) obtain access to the same underlying C++ WritableStream or ReadableStream instance.
  2. 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.
  3. 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 ready promise.
  4. 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).
  5. 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.

  1. Modify stored_error_ to use WorldSafeV8Reference instead of TraceWrapperV8Reference:
    WorldSafeV8Reference<v8::Value> stored_error_;
    
  2. When retrieving the stored error, ensure it is accessed or cloned appropriately for the target ScriptState using GetAcrossWorld(target_script_state) or by utilizing ScriptValue wrappers 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.

View on issue tracker