Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Media
DescriptionInappropriate implementation in Media
ComponentMedia
Bug ClassLogic Error
Tracker502371717
Fix commitf0274bfc4767 (chromium/src) +80/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/html/media/html_media_element.cc
modified
StubMediaSourceAttachment
third_party/blink/renderer/core/html/media/html_media_element_test.cc
modified
MockWebMediaPlayer
third_party/blink/renderer/core/html/media/html_media_element_test.cc
modified
TEST_P
third_party/blink/renderer/core/html/media/html_media_element_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/html/media/html_media_element.cc
  • third_party/blink/renderer/core/html/media/html_media_element.h
  • third_party/blink/renderer/core/html/media/html_media_element_test.cc
From f0274bfc4767441894c544d71eb6505b6f2d23be Mon Sep 17 00:00:00 2001
From: Dale Curtis <dalecurtis@chromium.org>
Date: Tue, 21 Apr 2026 15:43:02 -0700
Subject: [PATCH] Improve HTMLMediaElement opaque handling for non-media content

This makes two changes to how opaque handling is done:
* MSE content is never considered opaque since its data comes from
the page.
* SRC content uses the actual network state instead of the preload
attribute to determine loading.

I verified the test cases in https://crbug.com/40580490 don't
reproduce after these changes.

Fixed: 502371717
Change-Id: Ica576163c07131efcf61c5ebf3963d924aeab3c3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7769454
Reviewed-by: Ted (Chromium) Meyer <tmathmeyer@chromium.org>
Commit-Queue: Dale Curtis <dalecurtis@chromium.org>
Auto-Submit: Dale Curtis <dalecurtis@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1618509}
---

diff --git a/third_party/blink/renderer/core/html/media/html_media_element.cc b/third_party/blink/renderer/core/html/media/html_media_element.cc
index 831c604..720ea8a0 100644
--- a/third_party/blink/renderer/core/html/media/html_media_element.cc
+++ b/third_party/blink/renderer/core/html/media/html_media_element.cc
@@ -1097,6 +1097,7 @@
     // state.
     ready_state_ = kHaveNothing;
     ready_state_maximum_ = kHaveNothing;
+    network_state_maximum_ = kNetworkEmpty;
 
     DCHECK(!paused_ || play_promise_resolvers_.empty());
 
@@ -1922,6 +1923,11 @@
   if (!web_media_player_)
     return true;
 
+  // MSE content is always same origin since it must be provided by JS.
+  if (media_source_attachment_) {
+    return true;
+  }
+
   const auto network_state = web_media_player_->GetNetworkState();
   if (network_state == WebMediaPlayer::kNetworkStateNetworkError)
     return false;
@@ -4681,6 +4687,9 @@
     return;
 
   network_state_ = state;
+  if (network_state_ > network_state_maximum_) {
+    network_state_maximum_ = network_state_;
+  }
   if (update_media_controls && GetMediaControls())
     GetMediaControls()->NetworkStateChanged();
 }
@@ -5114,7 +5123,7 @@
 
 bool HTMLMediaElement::MediaShouldBeOpaque() const {
   return !IsMediaDataCorsSameOrigin() && ready_state_ < kHaveMetadata &&
-         EffectivePreloadType() != WebMediaPlayer::kPreloadNone;
+         network_state_maximum_ >= NetworkState::kNetworkLoading;
 }
 
 void HTMLMediaElement::SetError(MediaError* error) {
diff --git a/third_party/blink/renderer/core/html/media/html_media_element.h b/third_party/blink/renderer/core/html/media/html_media_element.h
index 3b6f791..a8b9cdd 100644
--- a/third_party/blink/renderer/core/html/media/html_media_element.h
+++ b/third_party/blink/renderer/core/html/media/html_media_element.h
@@ -803,6 +803,7 @@
   double playback_rate_ = 1.0;
   double default_playback_rate_ = 1.0;
   NetworkState network_state_ = kNetworkEmpty;
+  NetworkState network_state_maximum_ = kNetworkEmpty;
   ReadyState ready_state_ = kHaveNothing;
   ReadyState ready_state_maximum_ = kHaveNothing;
 
diff --git a/third_party/blink/renderer/core/html/media/html_media_element_test.cc b/third_party/blink/renderer/core/html/media/html_media_element_test.cc
index 6a70582e..901be93f 100644
--- a/third_party/blink/renderer/core/html/media/html_media_element_test.cc
+++ b/third_party/blink/renderer/core/html/media/html_media_element_test.cc
@@ -27,6 +27,7 @@
 #include "third_party/blink/renderer/core/html/media/html_audio_element.h"
 #include "third_party/blink/renderer/core/html/media/html_video_element.h"
 #include "third_party/blink/renderer/core/html/media/media_error.h"
+#include "third_party/blink/renderer/core/html/media/media_source_attachment.h"
 #include "third_party/blink/renderer/core/html/media/media_video_visibility_tracker.h"
 #include "third_party/blink/renderer/core/html/time_ranges.h"
 #include "third_party/blink/renderer/core/html/track/audio_track_list.h"
@@ -89,6 +90,31 @@
   }
 }
 
+class StubMediaSourceAttachment : public MediaSourceAttachment {
+ public:
+  StubMediaSourceAttachment() = default;
+  void Unregister() override {}
+  MediaSourceTracer* StartAttachingToMediaElement(HTMLMediaElement*,
+                                                  bool* success) override {
+    *success = true;
+    return nullptr;
+  }
+  void CompleteAttachingToMediaElement(
+      MediaSourceTracer* tracer,
+      std::unique_ptr<WebMediaSource>) override {}
+  void Close(MediaSourceTracer* tracer) override {}
+  WebTimeRanges BufferedInternal(MediaSourceTracer* tracer) const override {
+    return {};
+  }
+  WebTimeRanges SeekableInternal(MediaSourceTracer* tracer) const override {
+    return {};
+  }
+  void OnTrackChanged(MediaSourceTracer* tracer, TrackBase*) override {}
+  void OnElementTimeUpdate(double time) override {}
+  void OnElementError() override {}
+  void OnElementContextDestroyed() override {}
+};
+
 class MockWebMediaPlayer : public EmptyWebMediaPlayer {
  public:
   MOCK_METHOD0(Play, void());
@@ -485,6 +511,11 @@
 
   void ClearMediaPlayer() { Media()->ClearMediaPlayer(); }
 
+  void SetMediaSourceAttachment(
+      scoped_refptr<MediaSourceAttachment> attachment) {
+    Media()->media_source_attachment_ = std::move(attachment);
+  }
+
  protected:
   // Helpers to call MediaPlayerObserver mojo methods and check their results.
   void NotifyMediaPlaying() {
@@ -2740,4 +2771,42 @@
   }
 }
 
+TEST_P(HTMLMediaElementTest, MediaShouldBeOpaque_MSE) {
+  Media()->SetSrc(SrcSchemeToURL(TestURLScheme::kHttp));
+  test::RunPendingTasks();
+  ASSERT_TRUE(Media()->GetWebMediaPlayer());
+
+  // MSE content should never be opaque.
+  SetMediaSourceAttachment(base::MakeRefCounted<StubMediaSourceAttachment>());
+
+  // Set some state that would normally make it opaque.
+  EXPECT_CALL(*MockMediaPlayer(), WouldTaintOrigin())
+      .WillRepeatedly(Return(true));
+  SetReadyState(HTMLMediaElement::kHaveNothing);
+  SetNetworkState(WebMediaPlayer::kNetworkStateLoading);
+
+  EXPECT_FALSE(MediaShouldBeOpaque());
+}
+
+TEST_P(HTMLMediaElementTest, MediaShouldBeOpaque_NetworkState) {
+  Media()->SetSrc(SrcSchemeToURL(TestURLScheme::kHttp));
+  EXPECT_CALL(*MockMediaPlayer(), WouldTaintOrigin())
+      .WillRepeatedly(Return(true));
+
+  // SRC content without loading should not be opaque.
+  EXPECT_FALSE(MediaShouldBeOpaque());
+
+  // This will kick off loading and increase network state through loading.
+  test::RunPendingTasks();
+  ASSERT_TRUE(Media()->GetWebMediaPlayer());
+
+  SetNetworkState(WebMediaPlayer::kNetworkStateLoading);
+  EXPECT_TRUE(MediaShouldBeOpaque());
+
+  // Even if state moves back to Idle, it was once loading, so it remains
+  // opaque.
+  SetNetworkState(WebMediaPlayer::kNetworkStateIdle);
+  EXPECT_TRUE(MediaShouldBeOpaque());
+}
+
 }  // namespace blink
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/html/media/html_media_element_test.cc b/third_party/blink/renderer/core/html/media/html_media_element_test.cc
index 6a70582e..901be93f 100644
--- a/third_party/blink/renderer/core/html/media/html_media_element_test.cc
+++ b/third_party/blink/renderer/core/html/media/html_media_element_test.cc
@@ -27,6 +27,7 @@
 #include "third_party/blink/renderer/core/html/media/html_audio_element.h"
 #include "third_party/blink/renderer/core/html/media/html_video_element.h"
 #include "third_party/blink/renderer/core/html/media/media_error.h"
+#include "third_party/blink/renderer/core/html/media/media_source_attachment.h"
 #include "third_party/blink/renderer/core/html/media/media_video_visibility_tracker.h"
 #include "third_party/blink/renderer/core/html/time_ranges.h"
 #include "third_party/blink/renderer/core/html/track/audio_track_list.h"
@@ -89,6 +90,31 @@
   }
 }
 
+class StubMediaSourceAttachment : public MediaSourceAttachment {
+ public:
+  StubMediaSourceAttachment() = default;
+  void Unregister() override {}
+  MediaSourceTracer* StartAttachingToMediaElement(HTMLMediaElement*,
+                                                  bool* success) override {
+    *success = true;
+    return nullptr;
+  }
+  void CompleteAttachingToMediaElement(
+      MediaSourceTracer* tracer,
+      std::unique_ptr<WebMediaSource>) override {}
+  void Close(MediaSourceTracer* tracer) override {}
+  WebTimeRanges BufferedInternal(MediaSourceTracer* tracer) const override {
+    return {};
+  }
+  WebTimeRanges SeekableInternal(MediaSourceTracer* tracer) const override {
+    return {};
+  }
+  void OnTrackChanged(MediaSourceTracer* tracer, TrackBase*) override {}
+  void OnElementTimeUpdate(double time) override {}
+  void OnElementError() override {}
+  void OnElementContextDestroyed() override {}
+};
+
 class MockWebMediaPlayer : public EmptyWebMediaPlayer {
  public:
   MOCK_METHOD0(Play, void());
@@ -485,6 +511,11 @@
 
   void ClearMediaPlayer() { Media()->ClearMediaPlayer(); }
 
+  void SetMediaSourceAttachment(
+      scoped_refptr<MediaSourceAttachment> attachment) {
+    Media()->media_source_attachment_ = std::move(attachment);
+  }
+
  protected:
   // Helpers to call MediaPlayerObserver mojo methods and check their results.
   void NotifyMediaPlaying() {
@@ -2740,4 +2771,42 @@
   }
 }
 
+TEST_P(HTMLMediaElementTest, MediaShouldBeOpaque_MSE) {
+  Media()->SetSrc(SrcSchemeToURL(TestURLScheme::kHttp));
+  test::RunPendingTasks();
+  ASSERT_TRUE(Media()->GetWebMediaPlayer());
+
+  // MSE content should never be opaque.
+  SetMediaSourceAttachment(base::MakeRefCounted<StubMediaSourceAttachment>());
+
+  // Set some state that would normally make it opaque.
+  EXPECT_CALL(*MockMediaPlayer(), WouldTaintOrigin())
+      .WillRepeatedly(Return(true));
+  SetReadyState(HTMLMediaElement::kHaveNothing);
+  SetNetworkState(WebMediaPlayer::kNetworkStateLoading);
+
+  EXPECT_FALSE(MediaShouldBeOpaque());
+}
+
+TEST_P(HTMLMediaElementTest, MediaShouldBeOpaque_NetworkState) {
+  Media()->SetSrc(SrcSchemeToURL(TestURLScheme::kHttp));
+  EXPECT_CALL(*MockMediaPlayer(), WouldTaintOrigin())
+      .WillRepeatedly(Return(true));
+
+  // SRC content without loading should not be opaque.
+  EXPECT_FALSE(MediaShouldBeOpaque());
+
+  // This will kick off loading and increase network state through loading.
+  test::RunPendingTasks();
+  ASSERT_TRUE(Media()->GetWebMediaPlayer());
+
+  SetNetworkState(WebMediaPlayer::kNetworkStateLoading);
+  EXPECT_TRUE(MediaShouldBeOpaque());
+
+  // Even if state moves back to Idle, it was once loading, so it remains
+  // opaque.
+  SetNetworkState(WebMediaPlayer::kNetworkStateIdle);
+  EXPECT_TRUE(MediaShouldBeOpaque());
+}
+
 }  // namespace blink
Loading diff…

Original Bug Report

reported by vm...@google.com

Renderer-side cross-origin info leak via mid-load preload attribute downgrade

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 without the Chrome Security team.

Overview: A logic flaw in HTMLMediaElement allows a page to bypass cross-origin information leak protections by dynamically switching the preload attribute to ’none’ after a load has started. This causes MediaShouldBeOpaque() to prematurely return false, exposing detailed demuxer error messages and progress events for cross-origin resources.

Affected files:

  • third_party/blink/renderer/core/html/media/html_media_element.cc
  • third_party/blink/renderer/platform/media/web_media_player_impl.cc
  • media/filters/demuxer_manager.cc
  • third_party/blink/renderer/platform/media/multi_buffer_data_source.cc
  • content/renderer/media/batching_media_log.cc
  • third_party/blink/renderer/platform/media/resource_multi_buffer_data_provider.cc

Estimated timestamp from git blame: 2018-11-16

Summary

A potential logic error in HTMLMediaElement::MediaShouldBeOpaque() allows a malicious page to leak sensitive information from cross-origin media resources. By initiating a media load and then programmatically switching the preload attribute to none before the metadata is received, an attacker can bypass the renderer-side opacity check. This check is designed to redact detailed error messages and suppress progress events for cross-origin media lacking CORS headers to prevent information leaks.

Technical Details

HTMLMediaElement::MediaShouldBeOpaque() determines whether a media resource’s details should be hidden from the renderer’s script. It is implemented as follows:

bool HTMLMediaElement::MediaShouldBeOpaque() const {
  return !IsMediaDataCorsSameOrigin() && ready_state_ < kHaveMetadata &&
         EffectivePreloadType() != WebMediaPlayer::kPreloadNone;
}

The condition EffectivePreloadType() != WebMediaPlayer::kPreloadNone appears to assume that if preload is none, no network loading or parsing is actively occurring. However, this assumption breaks if the load was already started before the attribute was changed.

When a page sets the src attribute, the internal flag ignore_preload_none_ is reset to false (in ParseAttribute), and the load algorithm is invoked. If an attacker then sets element.preload = 'none' (e.g., within a loadstart event listener):

  1. EffectivePreloadType() returns kPreloadNone because it reads the new attribute and ignore_preload_none_ is false.
  2. This change propagates to the MultiBufferDataSource to adjust buffering limits, but it does not actively abort the existing network fetch or the demuxer parsing process.
  3. Because EffectivePreloadType() is now kPreloadNone, MediaShouldBeOpaque() evaluates to false.

This bypasses protections in two key areas:

  • Error Message Redaction: In MediaLoadingFailed(), if a cross-origin load fails (e.g., a 404, or demuxer failing to parse an HTML document), the detailed error string (retrieved from the media player via GetErrorMessage()) is usually replaced with an empty string if MediaShouldBeOpaque() is true. Because the check evaluates to false, the sensitive demuxer or network diagnostic string is passed through and exposed via the MediaError.message property.
  • Progress Event Suppression: In ProgressEventTimerFired(), progress and stalled events are normally suppressed for opaque media. The bypass allows these events to fire, creating a timing side-channel.

Impact

This issue constitutes a cross-origin information leak (XS-Leak). It enables:

  • A 200-vs-4xx status oracle for cross-origin resources (by distinguishing error messages).
  • Exposure of internal demuxer diagnostics for cross-origin resources that pass ORB (Opaque Response Blocking) sniffing but fail subsequent media parsing.
  • A timing side-channel via progress events for opaque media loads.

Potential Reproduction Steps

Note: These steps are suggested as we do not have execution capabilities to run a PoC.

  1. On an attacker-controlled page, create an <audio> element with preload="metadata" and no crossorigin attribute.
  2. Attach a loadstart event listener that immediately sets the element’s preload attribute to none: audio.addEventListener('loadstart', () => { audio.preload = 'none'; });
  3. Attach an error listener to inspect audio.error.message and a progress listener to observe event timing.
  4. Set audio.src to a cross-origin URL (e.g., an endpoint returning JSON or a 404 status).
  5. Observe that the error listener receives an unredacted demuxer error string, and progress events fire.

Suggested Fix

MediaShouldBeOpaque() should not rely solely on EffectivePreloadType() != kPreloadNone to determine if a load is active. A more robust approach would be to check the actual network state (e.g., network_state_ == kNetworkLoading) or maintain a specific flag indicating whether an active fetch/parse operation for opaque media is underway, regardless of subsequent changes to the preload attribute.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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