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 <szager@chromium.org>
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 <szager@chromium.org>
Reviewed-by: Philip Rogers <pdr@chromium.org>
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