CVE-2026-87563
Overview
Background
- Origin-tainted filter
- an SVG/CSS filter whose operations reference cross-origin content (e.g. a cross-origin
feImage), reported byFilterOperations::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.
drawElementImageinto 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;
ScopedSVGPaintStateselects 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.
is_in_tainted_subtree) and drops the whole tainted effect chain during privacy-preserving paint instead of just the local filter.Attack Path
- Embed cross-origin data in a filter
The attacker page defines an SVG
filtercontaining anfeImagethat references a cross-origin image, so the filter’s operations are markedOriginTainted(). - 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 externalmask), so the leaf element’s own effect node is untainted while a tainted filter sits above it. - Capture the element
The subtree is placed under a
layoutsubtreecanvas child and captured viactx.drawElementImage(...), which paints withPaintFlag::kPrivacyPreserving. - 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
feImageoutput is rendered into the captured bitmap. - 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
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
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/paint/paint_property_tree_builder.cc |
modified | |
ifthird_party/blink/renderer/core/paint/scoped_svg_paint_state.cc |
modified | |
ifthird_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.cc |
modified | |
promise_testthird_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.ccthird_party/blink/renderer/core/paint/paint_property_tree_builder.ccthird_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.hthird_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-mask-nested-filter.tentative.https.sub.htmlthird_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.ccthird_party/blink/renderer/core/paint/paint_property_tree_builder.h
Audit Directions
- Local vs. subtree-scoped security checksAudit 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 chainsReview every path that consumes
FilterOperations::OriginTainted()orkPrivacyPreservingto 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 stateCheck that security-affecting fields feed into
ComputeChangeand force subtree updates; a flag that is never diffed can leave stale, unsafe nodes in place across incremental paint updates.
Patch
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>
Regression Test / PoC
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>