Chrome · Paint
CVE-2026-87563
Logic Error in Paint
Overview
Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
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 |
Files Changed
third_party/blink/renderer/core/paint/paint_property_tree_builder.ccthird_party/blink/renderer/core/paint/paint_property_tree_builder.hthird_party/blink/renderer/core/paint/scoped_svg_paint_state.ccthird_party/blink/renderer/platform/graphics/paint/effect_paint_property_node.cc
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; + }
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.
References
On This Page