CVE-2026-13979
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/layout/svg/layout_svg_image.cc |
modified |
Files Changed
third_party/blink/renderer/core/layout/layout_box_model_object.ccthird_party/blink/renderer/core/layout/layout_box_model_object.hthird_party/blink/renderer/core/layout/svg/layout_svg_container.ccthird_party/blink/renderer/core/layout/svg/layout_svg_container.hthird_party/blink/renderer/core/layout/svg/layout_svg_foreign_object.ccthird_party/blink/renderer/core/layout/svg/layout_svg_image.ccthird_party/blink/renderer/core/layout/svg/layout_svg_root.ccthird_party/blink/renderer/core/layout/svg/layout_svg_root.hthird_party/blink/renderer/core/layout/svg/layout_svg_shape.cc
Patch
From 42fea096983aa7648e0598bbfa1ce497c509d320 Mon Sep 17 00:00:00 2001
From: Philip Rogers <pdr@chromium.org>
Date: Tue, 26 May 2026 04:46:21 -0700
Subject: [PATCH] Include filters in SVG occlusion tests
When performing hit tests for occlusion (i.e., intersection observer
v2), we need to include filter effects. This patch updates the SVG hit
testing code to inflate the visual overflow with filters, and use this
for occlusion hit tests.
Fixed: 513988889
Change-Id: Ib2cad21105fd829d0cd744a698907fe6dd999a3c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7858119
Reviewed-by: Fredrik Söderquist <fs@opera.com>
Commit-Queue: Philip Rogers <pdr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1636071}
---
diff --git a/third_party/blink/renderer/core/layout/layout_box_model_object.cc b/third_party/blink/renderer/core/layout/layout_box_model_object.cc
index 7db7dd34..aae0e044 100644
--- a/third_party/blink/renderer/core/layout/layout_box_model_object.cc
+++ b/third_party/blink/renderer/core/layout/layout_box_model_object.cc
@@ -518,6 +518,8 @@
if (!filter_reference_box.size().IsZero()) {
float_rect.UnionEvenIfEmpty(filter_reference_box);
}
+ // TODO(crbug.com/513988889): Update this to call
+ // `SVGLayoutSupport::ApplyFiltersToRect`.
float_rect = StyleRef().Filter().MapRect(float_rect);
}
return PhysicalRect::EnclosingRect(float_rect);
diff --git a/third_party/blink/renderer/core/layout/layout_box_model_object.h b/third_party/blink/renderer/core/layout/layout_box_model_object.h
index 6f2d5bd..25018693 100644
--- a/third_party/blink/renderer/core/layout/layout_box_model_object.h
+++ b/third_party/blink/renderer/core/layout/layout_box_model_object.h
@@ -174,7 +174,7 @@
// Returns the visual overflow rect, expanded to the area affected by any
// filters that paint outside of the box, in physical coordinates.
- PhysicalRect VisualOverflowRectIncludingFilters() const;
+ virtual PhysicalRect VisualOverflowRectIncludingFilters() const;
// Returns a physical rect that is a result of apply this object's filters to
// it. If there are no filters, it returns its argument.
diff --git a/third_party/blink/renderer/core/layout/svg/layout_svg_container.cc b/third_party/blink/renderer/core/layout/svg/layout_svg_container.cc
index 88bea54..31d9981 100644
--- a/third_party/blink/renderer/core/layout/svg/layout_svg_container.cc
+++ b/third_party/blink/renderer/core/layout/svg/layout_svg_container.cc
@@ -244,13 +244,19 @@
content_.HitTest(result, *local_location, phase))
return true;
- // pointer-events: bounding-box makes it possible for containers to be direct
- // targets.
- if (StyleRef().UsedPointerEvents() == EPointerEvents::kBoundingBox) {
- // Check for a valid bounding box because it will be invalid for empty
- // containers.
- if (IsObjectBoundingBoxValid() &&
- local_location->Intersects(ObjectBoundingBox())) {
+ if (IsObjectBoundingBoxValid()) {
+ bool is_visual_overflow =
+ result.GetHitTestRequest().IsHitTestVisualOverflow();
+ gfx::RectF bounds = is_visual_overflow
+ ? SVGLayoutSupport::ApplyFiltersToRect(
+ *this, DecoratedBoundingBox())
+ : ObjectBoundingBox();
+
+ // pointer-events: bounding-box makes it possible for containers to be
+ // direct targets.
+ if ((is_visual_overflow ||
+ StyleRef().UsedPointerEvents() == EPointerEvents::kBoundingBox) &&
+ local_location->Intersects(bounds)) {
UpdateHitTestResult(result, PhysicalOffset::FromPointFRound(
local_location->TransformedPoint()));
if (result.AddNodeToListBasedTestResult(GetElement(), *local_location) ==
diff --git a/third_party/blink/renderer/core/layout/svg/layout_svg_container.h b/third_party/blink/renderer/core/layout/svg/layout_svg_container.h
index b25360f..32ff928 100644
--- a/third_party/blink/renderer/core/layout/svg/layout_svg_container.h
+++ b/third_party/blink/renderer/core/layout/svg/layout_svg_container.h
@@ -83,6 +83,11 @@
return content_.ObjectBoundingBox();
}
+ gfx::RectF ComputeContentVisualOverflowRectIncludingFilters() const {
+ NOT_DESTROYED();
+ return content_.ComputeVisualOverflowRectIncludingFilters();
+ }
+
protected:
LayoutObjectChildList* VirtualChildren() final {
NOT_DESTROYED();
diff --git a/third_party/blink/renderer/core/layout/svg/layout_svg_foreign_object.cc b/third_party/blink/renderer/core/layout/svg/layout_svg_foreign_object.cc
index 1ef359f..ffd6f8e1 100644
--- a/third_party/blink/renderer/core/layout/svg/layout_svg_foreign_object.cc
+++ b/third_party/blink/renderer/core/layout/svg/layout_svg_foreign_object.cc
@@ -10,6 +10,7 @@
#include "third_party/blink/renderer/core/layout/hit_test_result.h"
#include "third_party/blink/renderer/core/layout/layout_result.h"
#include "third_party/blink/renderer/core/layout/svg/svg_layout_info.h"
+#include "third_party/blink/renderer/core/layout/svg/svg_layout_support.h"
#include "third_party/blink/renderer/core/layout/svg/svg_resources.h"
#include "third_party/blink/renderer/core/layout/svg/transformed_hit_test_location.h"
#include "third_party/blink/renderer/core/paint/paint_layer.h"
@@ -214,6 +215,19 @@
return false;
}
+ if (result.GetHitTestRequest().IsHitTestVisualOverflow()) [[unlikely]] {
+ gfx::RectF bounds =
+ SVGLayoutSupport::ApplyFiltersToRect(*this, DecoratedBoundingBox());
+ if (local_location->Intersects(bounds)) {
+ UpdateHitTestResult(result, PhysicalOffset::FromPointFRound(
+ local_location->TransformedPoint()));
+ if (result.AddNodeToListBasedTestResult(GetElement(), *local_location) ==
+ kStopHitTesting) {
+ return true;
+ }
+ }
+ }
+
// |local_location| already includes the offset of the <foreignObject>
// element, but PaintLayer::HitTestLayer assumes it has not been.
HitTestLocation local_without_offset(*local_location, -PhysicalLocation());
diff --git a/third_party/blink/renderer/core/layout/svg/layout_svg_image.cc b/third_party/blink/renderer/core/layout/svg/layout_svg_image.cc
index 8173888d..d502d06 100644
--- a/third_party/blink/renderer/core/layout/svg/layout_svg_image.cc
+++ b/third_party/blink/renderer/core/layout/svg/layout_svg_image.cc
@@ -34,6 +34,7 @@
#include "third_party/blink/renderer/core/layout/pointer_events_hit_rules.h"
#include "third_party/blink/renderer/core/layout/svg/layout_svg_resource_container.h"
#include "third_party/blink/renderer/core/layout/svg/svg_layout_info.h"
+#include "third_party/blink/renderer/core/layout/svg/svg_layout_support.h"
#include "third_party/blink/renderer/core/layout/svg/svg_resources.h"
#include "third_party/blink/renderer/core/layout/svg/transform_helper.h"
#include "third_party/blink/renderer/core/layout/svg/transformed_hit_test_location.h"
@@ -226,13 +227,21 @@
return false;
}
- if (hit_rules.can_hit_fill || hit_rules.can_hit_bounding_box) {
- if (local_location->Intersects(object_bounding_box_)) {
+ bool is_visual_overflow =
+ result.GetHitTestRequest().IsHitTestVisualOverflow();
+ if (is_visual_overflow || hit_rules.can_hit_fill ||
+ hit_rules.can_hit_bounding_box) {
+ gfx::RectF bounds = object_bounding_box_;
+ if (is_visual_overflow) [[unlikely]] {
+ bounds = SVGLayoutSupport::ApplyFiltersToRect(*this, bounds);
+ }
+ if (local_location->Intersects(bounds)) {
UpdateHitTestResult(result, PhysicalOffset::FromPointFRound(
local_location->TransformedPoint()));
if (result.AddNodeToListBasedTestResult(GetElement(), *local_location) ==
- kStopHitTesting)
+ kStopHitTesting) {
return true;
+ }
}
}
return false;
diff --git a/third_party/blink/renderer/core/layout/svg/layout_svg_root.cc b/third_party/blink/renderer/core/layout/svg/layout_svg_root.cc
index 6ea4388..41706ff 100644
--- a/third_party/blink/renderer/core/layout/svg/layout_svg_root.cc
+++ b/third_party/blink/renderer/core/layout/svg/layout_svg_root.cc
@@ -236,6 +236,17 @@
PhysicalRect(InfiniteIntRect()));
}
+PhysicalRect LayoutSVGRoot::VisualOverflowRectIncludingFilters() const {
+ NOT_DESTROYED();
+ gfx::RectF content_visual_rect =
+ content_.ComputeVisualOverflowRectIncludingFilters();
+ content_visual_rect =
+ local_to_border_box_transform_.MapRect(content_visual_rect);
+ PhysicalRect rect = PhysicalRect::EnclosingRect(content_visual_rect);
+ rect = ApplyFiltersToRect(rect);
+ return Intersection(rect, PhysicalRect(InfiniteIntRect()));
+}
+
void LayoutSVGRoot::PaintReplaced(const PaintInfo& paint_info,
const PhysicalOffset& paint_offset) const {
NOT_DESTROYED();
diff --git a/third_party/blink/renderer/core/layout/svg/layout_svg_root.h b/third_party/blink/renderer/core/layout/svg/layout_svg_root.h
index 6c685a3..af63a278 100644
--- a/third_party/blink/renderer/core/layout/svg/layout_svg_root.h
+++ b/third_party/blink/renderer/core/layout/svg/layout_svg_root.h
@@ -148,6 +148,8 @@
return false;
}
+ PhysicalRect VisualOverflowRectIncludingFilters() const override;
+
void PaintReplaced(const PaintInfo&,
const PhysicalOffset& paint_offset) const override;
diff --git a/third_party/blink/renderer/core/layout/svg/layout_svg_shape.cc b/third_party/blink/renderer/core/layout/svg/layout_svg_shape.cc
index d9cd5ced..e7ffb461 100644
--- a/third_party/blink/renderer/core/layout/svg/layout_svg_shape.cc
+++ b/third_party/blink/renderer/core/layout/svg/layout_svg_shape.cc
@@ -543,9 +543,21 @@
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/paint/box_fragment_painter_test.cc b/third_party/blink/renderer/core/paint/box_fragment_painter_test.cc
index 4024c77..2e0518f 100644
--- a/third_party/blink/renderer/core/paint/box_fragment_painter_test.cc
+++ b/third_party/blink/renderer/core/paint/box_fragment_painter_test.cc
@@ -9,6 +9,7 @@
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/mojom/use_counter/metrics/webdx_feature.mojom-blink.h"
+#include "third_party/blink/renderer/core/input/event_handler.h"
#include "third_party/blink/renderer/core/layout/block_node.h"
#include "third_party/blink/renderer/core/layout/hit_test_location.h"
#include "third_party/blink/renderer/core/layout/inline/inline_cursor.h"
@@ -559,4 +560,28 @@
mojom::blink::WebDXFeature::kGapDecorations));
}
+TEST_P(BoxFragmentPainterTest, NodeAtPointWithFilterOnInline) {
+ SetBodyInnerHTML(R"HTML(
+ <style>
+ body { margin: 0; }
+ #container { position: absolute; left: 0; top: 0; width: 200px; height: 200px; }
+ #target { filter: drop-shadow(100px 100px red); background: green; font-size: 20px; }
+ </style>
+ <div id="container">
+ <span id="target">Text</span>
+ </div>
+ )HTML");
+ UpdateAllLifecyclePhasesForTest();
+
+ Element* target = GetDocument().getElementById(AtomicString("target"));
+ gfx::PointF hit_point(105, 105);
+ HitTestLocation location(hit_point);
+ HitTestRequest::HitTestRequestType hit_type =
+ HitTestRequest::kReadOnly | HitTestRequest::kHitTestVisualOverflow;
+ HitTestResult result =
+ GetDocument().GetFrame()->GetEventHandler().HitTestResultAtLocation(
+ location, hit_type);
+ EXPECT_EQ(result.InnerElement(), target);
+}
+
} // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/simple-occlusion-svg-foreign-object.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/simple-occlusion-svg-foreign-object.html
index 588ec2a..f2a4ac8 100644
--- a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/simple-occlusion-svg-foreign-object.html
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/simple-occlusion-svg-foreign-object.html
@@ -27,7 +27,7 @@
<div id="target"></div>
<svg id="svg" style="display: block">
- <foreignObject>
+ <foreignObject width="100" height="100">
<div id="occluder"></div>
</foreignObject>
</svg>
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/svg-foreign-object-filter-occlusion.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/svg-foreign-object-filter-occlusion.html
new file mode 100644
index 0000000..8095b4f
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/svg-foreign-object-filter-occlusion.html
@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style>
+ #target {
+ position: absolute;
+ left: 10px;
+ top: 10px;
+ width: 100px;
+ height: 100px;
+ background: green;
+ }
+ svg {
+ position: absolute;
+ left: 150px;
+ top: 150px;
+ width: 100px;
+ height: 100px;
+ overflow: visible;
+ }
+ foreignObject {
+ pointer-events: bounding-box;
+ }
+</style>
+<div id="target"></div>
+<svg>
+ <foreignObject id="fo" x="0" y="0" width="10" height="10">
+ <div style="width: 10px; height: 10px; background: blue;"></div>
+ </foreignObject>
+</svg>
+<script>
+async_test(t => {
+ const target = document.getElementById("target");
+ const fo = document.getElementById("fo");
+ let observation_count = 0;
+
+ const observer = new IntersectionObserver(t.step_func(entries => {
+ entries.forEach(entry => {
+ observation_count++;
+ if (observation_count === 1) {
+ assert_true(entry.isVisible, "Initially visible");
+ // Trigger filter change to occlude target
+ fo.style.filter = "drop-shadow(-140px -140px red)";
+ } else if (observation_count === 2) {
+ assert_false(entry.isVisible, "Should be occluded by filter");
+ observer.disconnect();
+ t.done();
+ }
+ });
+ }), {trackVisibility: true, delay: 100});
+
+ observer.observe(target);
+}, "IntersectionObserver observes visibility changes from SVG foreignObject filter changes");
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/svg-group-filter-occlusion.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/svg-group-filter-occlusion.html
new file mode 100644
index 0000000..6c40354
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/svg-group-filter-occlusion.html
@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style>
+ #target {
+ position: absolute;
+ left: 10px;
+ top: 10px;
+ width: 100px;
+ height: 100px;
+ background: green;
+ }
+ svg {
+ position: absolute;
+ left: 150px;
+ top: 150px;
+ width: 100px;
+ height: 100px;
+ overflow: visible;
+ }
+ g {
+ pointer-events: bounding-box;
+ }
+</style>
+<div id="target"></div>
+<svg>
+ <g id="g">
+ <rect x="0" y="0" width="10" height="10" fill="blue"/>
+ </g>
+</svg>
+<script>
+async_test(t => {
+ const target = document.getElementById("target");
+ const g = document.getElementById("g");
+ let observation_count = 0;
+
+ const observer = new IntersectionObserver(t.step_func(entries => {
+ entries.forEach(entry => {
+ observation_count++;
+ if (observation_count === 1) {
+ assert_true(entry.isVisible, "Initially visible");
+ // Trigger filter change to occlude target
+ g.style.filter = "drop-shadow(-140px -140px red)";
+ } else if (observation_count === 2) {
+ assert_false(entry.isVisible, "Should be occluded by filter");
+ observer.disconnect();
+ t.done();
+ }
+ });
+ }), {trackVisibility: true, delay: 100});
+
+ observer.observe(target);
+}, "IntersectionObserver observes visibility changes from SVG group filter changes");
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/svg-image-filter-occlusion.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/svg-image-filter-occlusion.html
new file mode 100644
index 0000000..b9e9faf
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/svg-image-filter-occlusion.html
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style>
+ #target {
+ position: absolute;
+ left: 10px;
+ top: 10px;
+ width: 100px;
+ height: 100px;
+ background: green;
+ }
+ svg {
+ position: absolute;
+ left: 150px;
+ top: 150px;
+ width: 100px;
+ height: 100px;
+ overflow: visible;
+ }
+ image {
+ pointer-events: bounding-box;
+ }
+</style>
+<div id="target"></div>
+<svg>
+ <image id="image" x="0" y="0" width="10" height="10" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="/>
+</svg>
+<script>
+async_test(t => {
+ const target = document.getElementById("target");
+ const image = document.getElementById("image");
+ let observation_count = 0;
+
+ const observer = new IntersectionObserver(t.step_func(entries => {
+ entries.forEach(entry => {
+ observation_count++;
+ if (observation_count === 1) {
+ assert_true(entry.isVisible, "Initially visible");
+ // Trigger filter change to occlude target
+ image.style.filter = "drop-shadow(-140px -140px red)";
+ } else if (observation_count === 2) {
+ assert_false(entry.isVisible, "Should be occluded by filter");
+ observer.disconnect();
+ t.done();
+ }
+ });
+ }), {trackVisibility: true, delay: 100});
+
+ observer.observe(target);
+}, "IntersectionObserver observes visibility changes from SVG image filter changes");
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/svg-reference-filter-occlusion.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/svg-reference-filter-occlusion.html
new file mode 100644
index 0000000..db9b242c
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/svg-reference-filter-occlusion.html
@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style>
+ #target {
+ position: absolute;
+ left: 10px;
+ top: 10px;
+ width: 100px;
+ height: 100px;
+ background: green;
+ }
+ svg {
+ position: absolute;
+ left: 110px;
+ top: 110px;
+ width: 100px;
+ height: 100px;
+ overflow: visible;
+ }
+ rect {
+ pointer-events: bounding-box;
+ }
+</style>
+<div id="target"></div>
+<svg>
+ <defs>
+ <filter id="blur" x="-30%" y="-30%" width="160%" height="160%">
+ <feGaussianBlur stdDeviation="30"/>
+ </filter>
+ </defs>
+ <rect id="rect" x="0" y="0" width="100" height="100" fill="blue"/>
+</svg>
+<script>
+async_test(t => {
+ const target = document.getElementById("target");
+ const rect = document.getElementById("rect");
+ let observation_count = 0;
+
+ const observer = new IntersectionObserver(t.step_func(entries => {
+ entries.forEach(entry => {
+ observation_count++;
+ if (observation_count === 1) {
+ assert_true(entry.isVisible, "Initially visible");
+ // Trigger filter change to occlude target
+ rect.style.filter = "url(#blur)";
+ } else if (observation_count === 2) {
+ assert_false(entry.isVisible, "Should be occluded by filter");
+ observer.disconnect();
+ t.done();
+ }
+ });
+ }), {trackVisibility: true, delay: 100});
+
+ observer.observe(target);
+}, "IntersectionObserver observes visibility changes from SVG reference filter changes");
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/svg-root-filter-occlusion.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/svg-root-filter-occlusion.html
... (truncated)
Original Bug Report
Bypass of PEPC and IntersectionObserver V2 occlusion detection via SVG text filters
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A logic error in Blink’s hit-testing for SVG text elements causes filter-expanded visual bounds to be ignored during occlusion checks. This allows attackers to potentially create opaque overlays that spoof sensitive UI components, such as the permission element, without being detected by clickjacking protections. The issue impacts both the Permission Element and IntersectionObserver V2 trackVisibility.
Affected files:
third_party/blink/renderer/core/paint/box_fragment_painter.ccthird_party/blink/renderer/core/layout/svg/layout_svg_root.ccthird_party/blink/renderer/core/layout/svg/layout_svg_text.ccthird_party/blink/renderer/core/layout/layout_object.ccthird_party/blink/renderer/core/html/html_capability_element_base.cc
Estimated timestamp from git blame: 2021-06-29
Summary
A potential vulnerability has been identified in Blink’s hit-testing logic where SVG <text> elements with the CSS property pointer-events: bounding-box incorrectly calculate their visual area during occlusion checks (HitTestForOcclusion). When a hit-test request includes the kHitTestVisualOverflow flag (used by security-sensitive features like the Permission Element anti-clickjacking and IntersectionObserver V2), the system correctly identifies expanded bounds including filters but then unconditionally overwrites them with the object’s base bounding box. This allows an attacker to use CSS filters (like drop-shadow or feFlood) to create opaque overlays that are invisible to the occlusion detection system.
Technical Details
In BoxFragmentPainter::NodeAtPoint, when processing an SVG <text> fragment with pointer-events: bounding-box, the following occurs:
- At line 2410, if the hit-test request is for visual overflow (
IsHitTestVisualOverflow()), the code correctly expands thebounds_rectusingInkOverflowIncludingFilters(). - Immediately following this, at line 2415, the code checks if
pointer_events_bounding_boxis set. If true, it unconditionally resetsbounds_recttoObjectBoundingBox(), effectively discarding any expansion provided by filters.
Furthermore, LayoutSVGRoot::ComputeContentsVisualOverflow relies on DecoratedBoundingBox(), which for SVG text and shapes does not include filter outsets. This can cause the hit-test engine to skip an entire SVG subtree if the test point only intersects a filter-expanded area, as the root’s visual overflow rect is insufficiently sized.
Potential Attack Scenario (PEPC Bypass)
An attacker could potentially trick a user into granting sensitive permissions (camera, microphone, geolocation) by visually spoofing the <permission> element:
- The attacker places a
<permission type="camera">element (PEPC). - An absolutely-positioned
<svg>is placed over the permission element withpointer-events: noneandvisibility: hidden(to bypass the root’s own hit-test while allowing child testing). - Inside the SVG, a
<text>element is added withpointer-events: bounding-boxand a filter likedrop-shadow(...)that projects an opaque silhouette over the permission button. - Because of the bug in
NodeAtPointand the root overflow calculation, the occlusion hit-test ignores the shadow. IntersectionObserverreports the permission element as fully visible, and the element becomes click-enabled after the security delay.- A user click on the spoofed area passes through the SVG and triggers the permission prompt.
Note: These steps are suggested based on code analysis; our current environment does not support executing a live proof-of-concept.
Suggested Fix
- In
BoxFragmentPainter::NodeAtPoint, the override forpointer_events_bounding_boxshould union theObjectBoundingBox()with the existingbounds_rectifIsHitTestVisualOverflow()is set, rather than unconditionally replacing it. - Update
LayoutSVGText::DecoratedBoundingBox(and potentially other SVG layout objects) to include filter outsets when requested, or ensureLayoutSVGRootaccounts for filters in its contents visual overflow calculation to prevent premature subtree culling.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.