Medium watch CVSS 4.3 chrome Logic Error 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOrigin validation error in Paint
DescriptionOrigin validation error in Paint
ComponentPaint
Sub-componentHiC
Bug ClassLogic Error
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker532952073
Fix commit98b5e8f99f86 (chromium/src) +149/-13
CWECWE-346 (Origin validation error)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-07-09 (per vendor advisory)
Fix landed2026-07-30 (public git)
Disclosed2026-09-08
Patch gap40 days — fix public in source before the advisory

Background

Origin-tainted filter
an SVG/CSS filter whose operations reference cross-origin content (e.g. a cross-origin feImage), reported by FilterOperations::OriginTainted() so the compositor knows the result must not leak to a same-origin sink.
`PaintFlag::kPrivacyPreserving`
a paint flag set when element content is captured for readback (e.g. drawElementImage into a canvas), signalling that cross-origin pixels must be dropped rather than rendered into the capture.
`EffectPaintPropertyNode`
a node in Blink’s paint property tree describing an effect (filter, mask, clip-path, opacity, view transition) applied to a subtree; ScopedSVGPaintState selects which of these effect nodes is active while painting SVG content.
`is_in_tainted_subtree`
a flag added by this patch to both the tree-builder context and EffectPaintPropertyNode::State, marking every effect node at or below an origin-tainted filter.

Root Cause Analysis

When painting SVG under kPrivacyPreserving, the pre-patch effect-selection code in scoped_svg_paint_state.cc only inspected the current object’s own filter: if filter->Filter()->OriginTainted() was true it substituted filter->Parent() to drop the tainted effect. That check was purely local, so a cross-origin filter placed on an ancestor (for example an SVG <g filter="url(#cross)"> wrapping the painted content inside an external mask) left the descendant’s paint state pointing at its own untainted effect node, and the tainted ancestor filter was still applied and captured. The invariant that “an origin-tainted filter taints its entire subtree” was never enforced, so cross-origin pixel data reached the canvas readback path.

The fix propagates taint down the tree by setting context_.is_in_tainted_subtree = true in FragmentPaintPropertyTreeBuilder::UpdateFilter whenever a filter’s operations are origin-tainted, stamping that flag onto every descendant EffectPaintPropertyNode, and then rewrites the effect selection to walk up the effect chain dropping all nodes whose IsInTaintedSubtree() is true. ComputeChange and a forced kSubtreeUpdateIsolationPiercing subtree update ensure the property tree is rebuilt when taintedness changes so the new flag actually reaches descendants.

Key insight
The privacy check was applied only to the node directly carrying the filter, not to descendants of a tainted ancestor filter; the fix records taint as a subtree-wide property (is_in_tainted_subtree) and drops the whole tainted effect chain during privacy-preserving paint instead of just the local filter.

Attack Path

  1. Embed cross-origin data in a filter The attacker page defines an SVG filter containing an feImage that references a cross-origin image, so the filter’s operations are marked OriginTainted().
  2. Nest the tainted filter above the painted content The tainted filter is placed on an ancestor (a <g> wrapping the target, itself referenced by an external mask), so the leaf element’s own effect node is untainted while a tainted filter sits above it.
  3. Capture the element The subtree is placed under a layoutsubtree canvas child and captured via ctx.drawElementImage(...), which paints with PaintFlag::kPrivacyPreserving.
  4. Escape the local-only check Because the pre-patch selector only tested the leaf’s own filter, the tainted ancestor filter is not dropped and its cross-origin feImage output is rendered into the captured bitmap.
  5. Read back cross-origin pixels ctx.getImageData(...) returns the captured pixels, disclosing cross-origin image content that the same-origin policy should have withheld (the test asserts these pixels must remain the privacy-preserving fill color).

Impact Assessment

An attacker-controlled page can read back pixel data derived from cross-origin resources through drawElementImage, a same-origin-policy / origin-validation bypass yielding cross-origin information disclosure in the renderer process. The precondition is that the victim content be embeddable in an SVG filter (e.g. as an feImage) and captured via the HTML-in-canvas element-capture API. The advisory classifies this as an origin validation error in Paint and does not report remote code execution or in-the-wild exploitation; any escalation beyond cross-origin pixel disclosure is (inferred) and not demonstrated by the patch.

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/paint/paint_property_tree_builder.cc
modified
if
third_party/blink/renderer/core/paint/scoped_svg_paint_state.cc
modified
if
third_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.cc
modified
promise_test
third_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-mask-nested-filter.tentative.https.sub.html
modified

Files Changed

  • third_party/blink/renderer/core/paint/scoped_svg_paint_state.cc
  • third_party/blink/renderer/core/paint/paint_property_tree_builder.cc
  • third_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.h
  • third_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-mask-nested-filter.tentative.https.sub.html
  • third_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.cc
  • third_party/blink/renderer/core/paint/paint_property_tree_builder.h

Audit Directions

  • Local vs. subtree-scoped security checks
    Audit other privacy/taint checks that inspect only an object’s own property node rather than its ancestors; any effect that must be inherited (filters, masks, clip-paths) needs a subtree-propagated flag like is_in_tainted_subtree.
  • Origin-taint propagation across effect chains
    Review every path that consumes FilterOperations::OriginTainted() or kPrivacyPreserving to confirm nested and aliased effect nodes (Unalias(), Parent()) are traversed, since attackers can hide tainted content on ancestors, in masks, or behind opacity.
  • Property-tree invalidation on security-relevant state
    Check that security-affecting fields feed into ComputeChange and force subtree updates; a flag that is never diffed can leave stale, unsafe nodes in place across incremental paint updates.
From 98b5e8f99f864b41632b43e21f113d0a6f482614 Mon Sep 17 00:00:00 2001
From: Stefan Zager <[email protected]>
Date: Thu, 30 Jul 2026 13:52:47 -0700
Subject: [PATCH] [HiC] An origin-tainted filter should taint its subtree

Bug: 532952073
Change-Id: I86a21f9c6fc1928dab4b96f078121818d3409fa0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8131725
Commit-Queue: Stefan Zager <[email protected]>
Reviewed-by: Philip Rogers <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1671390}
---
diff --git a/third_party/blink/renderer/core/paint/paint_property_tree_builder.cc b/third_party/blink/renderer/core/paint/paint_property_tree_builder.cc
index 8165f1e..6e52ca2 100644
--- a/third_party/blink/renderer/core/paint/paint_property_tree_builder.cc
+++ b/third_party/blink/renderer/core/paint/paint_property_tree_builder.cc
@@ -2090,6 +2090,7 @@
       }
 
       EffectPaintPropertyNode::State state;
+      state.is_in_tainted_subtree = context_.is_in_tainted_subtree;
       state.is_in_canvas_subtree = context_.is_in_canvas_subtree;
       state.local_transform_space = context_.current.transform;
       if (EffectCanUseCurrentClipAsOutputClip())
@@ -2201,6 +2202,7 @@
 
       if (mask_clip) {
         EffectPaintPropertyNode::State mask_state;
+        mask_state.is_in_tainted_subtree = context_.is_in_tainted_subtree;
         mask_state.is_in_canvas_subtree = context_.is_in_canvas_subtree;
         mask_state.local_transform_space = context_.current.transform;
         mask_state.output_clip = context_.current.clip;
@@ -2225,6 +2227,7 @@
 
       if (needs_mask_based_clip_path_) {
         EffectPaintPropertyNode::State clip_path_state;
+        clip_path_state.is_in_tainted_subtree = context_.is_in_tainted_subtree;
         clip_path_state.is_in_canvas_subtree = context_.is_in_canvas_subtree;
         clip_path_state.local_transform_space = context_.current.transform;
         clip_path_state.output_clip = context_.current.clip;
@@ -2286,6 +2289,7 @@
   CHECK(context_.current.clip);
   CHECK(context_.current.transform);
   EffectPaintPropertyNode::State state;
+  state.is_in_tainted_subtree = context_.is_in_tainted_subtree;
   state.is_in_canvas_subtree = context_.is_in_canvas_subtree;
   state.direct_compositing_reasons = CompositingReason::kElementCapture;
   state.local_transform_space = context_.current.transform;
@@ -2306,6 +2310,7 @@
 
     if (transition) {
       EffectPaintPropertyNode::State state;
+      state.is_in_tainted_subtree = context_.is_in_tainted_subtree;
       state.is_in_canvas_subtree = context_.is_in_canvas_subtree;
       state.local_transform_space = context_.current.transform;
       state.output_clip = context_.current.clip;
@@ -2365,6 +2370,7 @@
       DCHECK(transition);
 
       EffectPaintPropertyNode::State state;
+      state.is_in_tainted_subtree = context_.is_in_tainted_subtree;
       state.is_in_canvas_subtree = context_.is_in_canvas_subtree;
       state.direct_compositing_reasons =
           CompositingReason::kViewTransitionElement;
@@ -2528,21 +2534,26 @@
 
 void FragmentPaintPropertyTreeBuilder::UpdateFilter() {
   DCHECK(properties_);
+  bool was_tainted = properties_->Filter() && properties_->Filter()->Filter() &&
+                     properties_->Filter()->Filter()->OriginTainted();
   if (NeedsPaintPropertyUpdate()) {
     if (NeedsFilter(object_, full_context_)) {
       EffectPaintPropertyNode::State state;
+      state.is_in_tainted_subtree = context_.is_in_tainted_subtree;
       state.is_in_canvas_subtree = context_.is_in_canvas_subtree;
       state.local_transform_space = context_.current.transform;
       EffectPaintPropertyNode::FilterInfo filter_info;
       UpdateFilterEffect(object_, properties_->Filter(), filter_info);
+      bool is_filter_tainted = filter_info.operations.OriginTainted();
       bool is_filter_disallowed =
           RuntimeEnabledFeatures::CanvasDrawElementEnabled(
               object_.GetDocument().GetExecutionContext()) &&
-          object_.IsInCanvasSubtree() && filter_info.operations.OriginTainted();
+          object_.IsInCanvasSubtree() && is_filter_tainted;
       if (!(filter_info.operations.IsEmpty() || is_filter_disallowed)) {
         state.filter_info =
             std::make_unique<EffectPaintPropertyNode::FilterInfo>(
                 std::move(filter_info));
+        state.is_in_tainted_subtree |= is_filter_tainted;
       }
 
       // The CSS filter spec didn't specify how filters interact with overflow
@@ -2603,14 +2614,25 @@
     }
   }
 
+  bool is_tainted = false;
   if (properties_->Filter()) {
     context_.current_effect = properties_->Filter();
     if (const auto* input_clip = properties_->PixelMovingFilterClipExpander()) {
       context_.current.clip = input_clip;
     }
+    if (auto* filter_ops = properties_->Filter()->Filter()) {
+      if (filter_ops->OriginTainted()) {
+        is_tainted = true;
+        context_.is_in_tainted_subtree = true;
+      }
+    }
   } else {
     DCHECK(!properties_->PixelMovingFilterClipExpander());
   }
+  if (was_tainted != is_tainted) {
+    full_context_.force_subtree_update_reasons |=
+        PaintPropertyTreeBuilderContext::kSubtreeUpdateIsolationPiercing;
+  }
 }
 
 static FloatRoundedRect ToSnappedClipRect(const PhysicalRect& rect) {
@@ -3586,6 +3608,7 @@
 
     if (needs_effect_node) {
       EffectPaintPropertyNode::State effect_state;
+      effect_state.is_in_tainted_subtree = context_.is_in_tainted_subtree;
       effect_state.is_in_canvas_subtree = context_.is_in_canvas_subtree;
       effect_state.local_transform_space = context_.current.transform;
       effect_state.output_clip = output_clip;
@@ -3632,6 +3655,7 @@
     // transition, for the same reason as explained above. Scroll corners
     // are only painted for non-overlay scrollbars.
     EffectPaintPropertyNode::State effect_state;
+    effect_state.is_in_tainted_subtree = context_.is_in_tainted_subtree;
     effect_state.is_in_canvas_subtree = context_.is_in_canvas_subtree;
     effect_state.local_transform_space = context_.current.transform;
     effect_state.output_clip = output_clip;
diff --git a/third_party/blink/renderer/core/paint/paint_property_tree_builder.h b/third_party/blink/renderer/core/paint/paint_property_tree_builder.h
index 344715d..5c320785 100644
--- a/third_party/blink/renderer/core/paint/paint_property_tree_builder.h
+++ b/third_party/blink/renderer/core/paint/paint_property_tree_builder.h
@@ -217,6 +217,10 @@
   // all non-alias effects.
   bool self_or_ancestor_participates_in_view_transition = false;
 
+  // Set to true when we visit an object with filter operations that have a
+  // tainted origin, and propagated to all its descendants.
+  bool is_in_tainted_subtree = false;
+
   // Set to true when we visit a canvas child and is propagated to all
   // descendant effects.
   bool is_in_canvas_subtree = false;
diff --git a/third_party/blink/renderer/core/paint/scoped_svg_paint_state.cc b/third_party/blink/renderer/core/paint/scoped_svg_paint_state.cc
index abbc0cc5..c505001 100644
--- a/third_party/blink/renderer/core/paint/scoped_svg_paint_state.cc
+++ b/third_party/blink/renderer/core/paint/scoped_svg_paint_state.cc
@@ -143,20 +143,22 @@
   auto& paint_controller = paint_info_.context.GetPaintController();
   auto state = paint_controller.CurrentPaintChunkProperties();
 
-  const auto* filter = properties.Filter();
-  if (filter && filter->Filter() && filter->Filter()->OriginTainted() &&
-      (paint_info_.GetPaintFlags() & PaintFlag::kPrivacyPreserving)) {
-    state.SetEffect(*filter->Parent());
-    filter = nullptr;
-  } else if (filter) {
-    state.SetEffect(*filter);
-  } else if (const auto* effect = properties.Effect()) {
-    state.SetEffect(*effect);
+  const EffectPaintPropertyNodeOrAlias* effect = properties.Filter();
+  const auto* filter_clip = properties.PixelMovingFilterClipExpander();
+  if (!effect) {
+    effect = properties.Effect();
+    filter_clip = nullptr;
   }
 
-  const auto* filter_clip = properties.PixelMovingFilterClipExpander();
-  if (!filter) {
-    filter_clip = nullptr;
+  if (paint_info_.GetPaintFlags() & PaintFlag::kPrivacyPreserving) {
+    while (effect && (effect->Unalias().IsInTaintedSubtree())) {
+      filter_clip = nullptr;
+      effect = effect->Parent();
+    }
+  }
+
+  if (effect) {
+    state.SetEffect(*effect);
   }
 
   if (filter_clip) {
diff --git a/third_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.cc b/third_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.cc
index 32b9bbd..5a5fd4b4 100644
--- a/third_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.cc
+++ b/third_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.cc
@@ -23,6 +23,9 @@
   if (!a || !b || a->output_bounds != b->output_bounds) {
     return PaintPropertyChangeType::kChangedOnlyValues;
   }
+  if (a->operations.OriginTainted() != b->operations.OriginTainted()) {
+    return PaintPropertyChangeType::kChangedOnlyValues;
+  }
   if (a->operations != b->operations) {
     return is_running_filter_animation_on_compositor
                ? PaintPropertyChangeType::kChangedOnlyCompositedValues
@@ -65,6 +68,7 @@
           other.self_or_ancestor_participates_in_view_transition ||
       needs_effect_for_2d_scale_transform !=
           other.needs_effect_for_2d_scale_transform ||
+      is_in_tainted_subtree != other.is_in_tainted_subtree ||
       is_in_canvas_subtree != other.is_in_canvas_subtree) {
     return PaintPropertyChangeType::kChangedOnlyValues;
   }
diff --git a/third_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.h b/third_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.h
index 638d3f3..ee47259e 100644
--- a/third_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.h
+++ b/third_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.h
@@ -168,6 +168,8 @@
 
     bool needs_effect_for_2d_scale_transform = false;
 
+    bool is_in_tainted_subtree = false;
+
     bool is_in_canvas_subtree = false;
 
     PaintPropertyChangeType ComputeChange(
@@ -303,6 +305,8 @@
     return state_.direct_compositing_reasons & CompositingReason::kCanvasChild;
   }
 
+  bool IsInTaintedSubtree() const { return state_.is_in_tainted_subtree; }
+
   bool IsInCanvasSubtree() const { return state_.is_in_canvas_subtree; }
 
   bool FlattensAtLeafOf3DScene() const {
diff --git a/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-mask-nested-filter.tentative.https.sub.html b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-mask-nested-filter.tentative.https.sub.html
new file mode 100644
index 0000000..efaf94f
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-mask-nested-filter.tentative.https.sub.html
@@ -0,0 +1,98 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>drawElementImage does not use cross-origin feImages on nested filtered SVG content inside masks</title>
+  <link rel="help" href="https://github.com/WICG/html-in-canvas">
+  <script src="/resources/testharness.js"></script>
+  <script src="/resources/testharnessreport.js"></script>
+  <script src="/html/canvas/resources/canvas-tests.js"></script>
+  <script src="/html/canvas/resources/wait-for-canvas-paint.js"></script>
+</head>
+
+<body>
+  <svg width="0" height="0">
+    <defs>
+      <filter id="filter-same" filterUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
+        <feImage href="https://{{location[host]}}/images/green-100x100.png"/>
+        <feComponentTransfer>
+          <feFuncR type="linear" slope="0" intercept="1"/>
+          <feFuncG type="linear" slope="0" intercept="1"/>
+          <feFuncB type="linear" slope="0" intercept="1"/>
+        </feComponentTransfer>
+      </filter>
+      <filter id="filter-cross" filterUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
+        <feImage href="https://{{hosts[alt][www]}}:{{ports[https][0]}}/images/red-100x100.png"/>
+        <feComponentTransfer>
+          <feFuncR type="linear" slope="0" intercept="1"/>
+          <feFuncG type="linear" slope="0" intercept="1"/>
+          <feFuncB type="linear" slope="0" intercept="1"/>
+        </feComponentTransfer>
+      </filter>
+      <mask id="mask-same" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
+        <g filter="url(#filter-same)">
+          <rect width="100" height="100" fill="black" filter="url(#filter-same)"/>
+        </g>
+      </mask>
+      <mask id="mask-cross" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
+        <g filter="url(#filter-cross)">
+          <rect width="100" height="100" fill="black" filter="url(#filter-cross)"/>
+        </g>
+      </mask>
+      <mask id="mask-cross-outer" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
+        <g filter="url(#filter-cross)">
+          <rect width="100" height="100" fill="black" filter="url(#filter-same)"/>
+        </g>
+      </mask>
+      <mask id="mask-cross-opacity" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
+        <g filter="url(#filter-cross)">
+          <rect width="100" height="100" fill="black" opacity="0.5"/>
+        </g>
+      </mask>
+    </defs>
+  </svg>
+
+  <canvas id="canvas" width="100" height="400" layoutsubtree>
+    <svg id="child" width="100" height="400">
+      <svg x="0" y="0" width="100" height="100">
+        <rect width="100" height="100" fill="rgb(20, 120, 220)" mask="url(#mask-same)"/>
+      </svg>
+      <svg x="0" y="100" width="100" height="100">
+        <rect width="100" height="100" fill="rgb(30, 130, 230)" mask="url(#mask-cross)"/>
+      </svg>
+      <svg x="0" y="200" width="100" height="100">
+        <rect width="100" height="100" fill="rgb(40, 140, 240)" mask="url(#mask-cross-outer)"/>
+      </svg>
+      <svg x="0" y="300" width="100" height="100">
+        <rect width="100" height="100" fill="rgb(50, 150, 250)" mask="url(#mask-cross-opacity)"/>
+      </svg>
+    </svg>
+  </canvas>
+
+  <script>
+    window.onload = () => {
+      promise_test(async function(t) {
+        await waitForCanvasPaint(canvas);
+        var ctx = canvas.getContext("2d");
+        ctx.fillStyle = "blue";
+        ctx.fillRect(0, 0, 100, 400);
+        ctx.drawElementImage(document.getElementById("child"), 0, 0);
+
+        // Fetch all pixel data once to avoid multiple slow readbacks.
+        const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+
+        let pixel = _getPixelFromImageData(imgData, 50, 50);
+        assert_array_equals(pixel, [20, 120, 220, 255], "Nested same origin filter images in external mask should be drawn");
+
+        pixel = _getPixelFromImageData(imgData, 50, 150);
+        assert_array_equals(pixel, [0, 0, 255, 255], "Nested cross origin filter images in external mask should not be drawn");
+
+        pixel = _getPixelFromImageData(imgData, 50, 250);
+        assert_array_equals(pixel, [0, 0, 255, 255], "Cross origin filter image on ancestor of filtered element in external mask should not be drawn");
+
+        pixel = _getPixelFromImageData(imgData, 50, 350);
+        assert_array_equals(pixel, [0, 0, 255, 255], "Cross origin filter image on ancestor of non-filtered element with opacity in external mask should not be drawn");
+      });
+    }
+  </script>
+</body>
+</html>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-mask-nested-filter.tentative.https.sub.html b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-mask-nested-filter.tentative.https.sub.html
new file mode 100644
index 0000000..efaf94f
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-mask-nested-filter.tentative.https.sub.html
@@ -0,0 +1,98 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>drawElementImage does not use cross-origin feImages on nested filtered SVG content inside masks</title>
+  <link rel="help" href="https://github.com/WICG/html-in-canvas">
+  <script src="/resources/testharness.js"></script>
+  <script src="/resources/testharnessreport.js"></script>
+  <script src="/html/canvas/resources/canvas-tests.js"></script>
+  <script src="/html/canvas/resources/wait-for-canvas-paint.js"></script>
+</head>
+
+<body>
+  <svg width="0" height="0">
+    <defs>
+      <filter id="filter-same" filterUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
+        <feImage href="https://{{location[host]}}/images/green-100x100.png"/>
+        <feComponentTransfer>
+          <feFuncR type="linear" slope="0" intercept="1"/>
+          <feFuncG type="linear" slope="0" intercept="1"/>
+          <feFuncB type="linear" slope="0" intercept="1"/>
+        </feComponentTransfer>
+      </filter>
+      <filter id="filter-cross" filterUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
+        <feImage href="https://{{hosts[alt][www]}}:{{ports[https][0]}}/images/red-100x100.png"/>
+        <feComponentTransfer>
+          <feFuncR type="linear" slope="0" intercept="1"/>
+          <feFuncG type="linear" slope="0" intercept="1"/>
+          <feFuncB type="linear" slope="0" intercept="1"/>
+        </feComponentTransfer>
+      </filter>
+      <mask id="mask-same" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
+        <g filter="url(#filter-same)">
+          <rect width="100" height="100" fill="black" filter="url(#filter-same)"/>
+        </g>
+      </mask>
+      <mask id="mask-cross" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
+        <g filter="url(#filter-cross)">
+          <rect width="100" height="100" fill="black" filter="url(#filter-cross)"/>
+        </g>
+      </mask>
+      <mask id="mask-cross-outer" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
+        <g filter="url(#filter-cross)">
+          <rect width="100" height="100" fill="black" filter="url(#filter-same)"/>
+        </g>
+      </mask>
+      <mask id="mask-cross-opacity" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
+        <g filter="url(#filter-cross)">
+          <rect width="100" height="100" fill="black" opacity="0.5"/>
+        </g>
+      </mask>
+    </defs>
+  </svg>
+
+  <canvas id="canvas" width="100" height="400" layoutsubtree>
+    <svg id="child" width="100" height="400">
+      <svg x="0" y="0" width="100" height="100">
+        <rect width="100" height="100" fill="rgb(20, 120, 220)" mask="url(#mask-same)"/>
+      </svg>
+      <svg x="0" y="100" width="100" height="100">
+        <rect width="100" height="100" fill="rgb(30, 130, 230)" mask="url(#mask-cross)"/>
+      </svg>
+      <svg x="0" y="200" width="100" height="100">
+        <rect width="100" height="100" fill="rgb(40, 140, 240)" mask="url(#mask-cross-outer)"/>
+      </svg>
+      <svg x="0" y="300" width="100" height="100">
+        <rect width="100" height="100" fill="rgb(50, 150, 250)" mask="url(#mask-cross-opacity)"/>
+      </svg>
+    </svg>
+  </canvas>
+
+  <script>
+    window.onload = () => {
+      promise_test(async function(t) {
+        await waitForCanvasPaint(canvas);
+        var ctx = canvas.getContext("2d");
+        ctx.fillStyle = "blue";
+        ctx.fillRect(0, 0, 100, 400);
+        ctx.drawElementImage(document.getElementById("child"), 0, 0);
+
+        // Fetch all pixel data once to avoid multiple slow readbacks.
+        const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+
+        let pixel = _getPixelFromImageData(imgData, 50, 50);
+        assert_array_equals(pixel, [20, 120, 220, 255], "Nested same origin filter images in external mask should be drawn");
+
+        pixel = _getPixelFromImageData(imgData, 50, 150);
+        assert_array_equals(pixel, [0, 0, 255, 255], "Nested cross origin filter images in external mask should not be drawn");
+
+        pixel = _getPixelFromImageData(imgData, 50, 250);
+        assert_array_equals(pixel, [0, 0, 255, 255], "Cross origin filter image on ancestor of filtered element in external mask should not be drawn");
+
+        pixel = _getPixelFromImageData(imgData, 50, 350);
+        assert_array_equals(pixel, [0, 0, 255, 255], "Cross origin filter image on ancestor of non-filtered element with opacity in external mask should not be drawn");
+      });
+    }
+  </script>
+</body>
+</html>
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.