CVE-2026-78987
Overview
Background
- Canvas origin-clean flag
- a 2D canvas becomes “tainted” once privacy-sensitive content is drawn into it. After that, read-back APIs such as
getImageData()throw aSecurityError(the behavior the regression test checks for). - `ctx.filter` vs `ctx.beginLayer({filter})`
- two ways to apply CSS filter strings to 2D canvas drawing. Both accept
url(...)references to SVG<filter>elements. - Tainted filter primitives
- under the Filter Effects spec, some SVG filter inputs are privacy-sensitive, for example
feImageof a cross-origin image or of a document element, orfeFloodwithcurrentColor. The commit message says these can reveal cross-origin pixels or colors that show whether a site was visited, so a filter that uses them must taint whatever it paints into. - `FilterEffect::OriginTainted()` / `SetOriginTaintedByContent()`
OriginTainted()reports whether the filter graph built byfilter_effect_builder.BuildFilterEffect(...)contains tainted inputs.SetOriginTaintedByContent()marks the canvas as no longer origin-clean.
Root Cause Analysis
In canvas_2d_recorder_context.cc, the code that resolves the filter for beginLayer({filter}) built the FilterEffect and passed it straight into paint_filter_builder::Build(...) inline, so it never looked at whether the resulting filter graph was origin-tainted. The commit message says ctx.filter already handled this in CanvasRenderingContext2DState::GetFilter(), but that logic was missing for the beginLayer path. A layer filter that referenced a tainted SVG filter could therefore paint sensitive content (cross-origin image pixels, element snapshots, currentColor-derived colors) into a canvas that stayed origin-clean. The violated invariant is that any tainted input reaching the canvas bitmap must mark the canvas not origin-clean.
The fix stores the FilterEffect* in a local and, when filter_effect && filter_effect->OriginTainted() && !origin_tainted_by_content_, calls SetOriginTaintedByContent() before building the paint filter, which makes the later getImageData() throw. The enclosing function’s name is not shown in the hunk. Going by the commit title and the test, it is the filter-resolution logic used by beginLayer.
ctx.filter), and the parallel beginLayer({filter}) path dropped the filter’s taint bit. The fix checks FilterEffect::OriginTainted() on the layer path too and taints the canvas to match.Attack Path
- Prepare a tainted SVG filter
The attacker’s page defines an SVG
<filter>whose primitives are privacy-sensitive, for examplefeImagepointing at a cross-origin image or at a page element, orfeFloodwithflood-color="currentColor", as in the regression test. - Apply it through a canvas layer
The page calls
ctx.beginLayer({filter: 'url(#...)'}), draws, and callsctx.endLayer(). The filter output is composited into the canvas bitmap. - Taint not propagated
Before the fix, the
beginLayerfilter path never consultedOriginTainted(), so the canvas stayed origin-clean. - Read back sensitive pixels
The page calls
getImageData()(or another read-back API) and gets pixels derived from cross-origin or otherwise sensitive content instead of aSecurityError. - Derive information The attacker infers cross-origin image content or visited-link and other style-dependent colors from the pixel values (inferred from the commit message’s description of what such filters can contain).
Impact Assessment
CanvasRenderingContext2D.beginLayer() must be available in the build. The patch does not say whether that API is enabled by default. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3.Files Changed
third_party/blink/web_tests/wpt_internal/html/canvas/canvas-filter-url-reference-tainting.htmlthird_party/blink/renderer/modules/canvas/canvas2d/canvas_2d_recorder_context.cc
Audit Directions
- Parallel entry points sharing a security ruleWherever one API path enforces taint, CORS or origin checks (here
CanvasRenderingContext2DState::GetFilter()), list every sibling path that reaches the same sink, such asOffscreenCanvasand worker contexts,CanvasPattern,drawImagewith filters, andcreateImageBitmap, and confirm each one propagates the taint bit. - Taint dropped when results are built inlineLook for code that builds an object carrying security metadata (like
FilterEffect::OriginTainted()) and immediately converts it to a lower-level representation (PaintFilter,cc::PaintRecord), losing the metadata before anyone checks it. - Other consumers of CSS `url()` filtersReview other places where CSS filter strings with SVG references feed readable surfaces, such as WebGL or WebGPU texture uploads from canvas,
toDataURL/toBlob, andcaptureStream, to confirm they respect the origin-clean state of filter outputs.
Patch
From dcb779a5c5ebc3a3ba5062f874c98e76c25fe0bd Mon Sep 17 00:00:00 2001
From: Jean-Philippe Gravel <jpgravel@chromium.org>
Date: Fri, 24 Jul 2026 16:24:20 -0700
Subject: [PATCH] [canvas] Propagate taint from beginLayer reference filters
The 2D canvas supports image filtering, either via `ctx.filter` or
`ctx.beginLayer({filter})`. Both approach supports CSS filter strings,
which supports `url(...)` references to SVG filters. As detailed in [1],
filters can potentially contain privacy-sensitive information, like
cross-origin images, or colors indicating whether a remote site was
visited. Such filters are tainted and that taint should propagate to the
canvas to prevent the page from reading back the image.
For ctx.filter, CanvasRenderingContext2DState::GetFilter() handles this
taint propagation. This logic was missing for ctx.beginLayer({filter}).
[1] https://drafts.csswg.org/filter-effects/#tainted-filter-primitives
Fixed: 501594511
Change-Id: Ie025d867d5b684b11627f247c4c47412a5f38149
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8143839
Reviewed-by: Vasiliy Telezhnikov <vasilyt@chromium.org>
Commit-Queue: Jean-Philippe Gravel <jpgravel@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1668214}
---
diff --git a/third_party/blink/renderer/modules/canvas/canvas2d/canvas_2d_recorder_context.cc b/third_party/blink/renderer/modules/canvas/canvas2d/canvas_2d_recorder_context.cc
index 133ae40..f5fe406 100644
--- a/third_party/blink/renderer/modules/canvas/canvas2d/canvas_2d_recorder_context.cc
+++ b/third_party/blink/renderer/modules/canvas/canvas2d/canvas_2d_recorder_context.cc
@@ -104,6 +104,7 @@
#include "third_party/blink/renderer/platform/graphics/blend_mode.h"
#include "third_party/blink/renderer/platform/graphics/canvas_2d_resource_provider.h"
#include "third_party/blink/renderer/platform/graphics/color.h"
+#include "third_party/blink/renderer/platform/graphics/filters/filter_effect.h"
#include "third_party/blink/renderer/platform/graphics/filters/paint_filter_builder.h"
#include "third_party/blink/renderer/platform/graphics/gpu/webgpu_mailbox_texture.h"
#include "third_party/blink/renderer/platform/graphics/graphics_context.h"
@@ -472,10 +473,14 @@
1.0f, // Deliberately ignore zoom on the canvas element.
Color::kBlack, mojom::blink::ColorScheme::kLight);
- filter = paint_filter_builder::Build(
- filter_effect_builder.BuildFilterEffect(std::move(filter_operations),
- !OriginClean()),
- kInterpolationSpaceSRGB);
+ FilterEffect* filter_effect = filter_effect_builder.BuildFilterEffect(
+ std::move(filter_operations), !OriginClean());
+ if (filter_effect && filter_effect->OriginTainted() &&
+ !origin_tainted_by_content_) {
+ SetOriginTaintedByContent();
+ }
+ filter =
+ paint_filter_builder::Build(filter_effect, kInterpolationSpaceSRGB);
}
}
diff --git a/third_party/blink/web_tests/wpt_internal/html/canvas/canvas-filter-url-reference-tainting.html b/third_party/blink/web_tests/wpt_internal/html/canvas/canvas-filter-url-reference-tainting.html
new file mode 100644
index 0000000..50b2a8c2
--- /dev/null
+++ b/third_party/blink/web_tests/wpt_internal/html/canvas/canvas-filter-url-reference-tainting.html
@@ -0,0 +1,84 @@
+<!doctype html>
+<title>CSS url() filter passed to beginLayer may taint the canvas</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/common/get-host-info.sub.js"></script>
+
+<svg style="display: block; width: 0; height: 0">
+ <defs>
+ <!-- Not tainted. -->
+ <filter id="floodLime">
+ <feFlood flood-color="lime"/>
+ </filter>
+ <filter id="sameOriginImage">
+ <feImage href="/images/green.svg"/>
+ </filter>
+
+ <!-- Tainted. -->
+ <filter id="floodCurrentColor">
+ <feFlood flood-color="currentColor"/>
+ </filter>
+ <filter id="elementImage">
+ <feImage href="#circle"/>
+ </filter>
+ <filter id="crossOriginImage">
+ <feImage>
+ <script>
+ document.currentScript.parentElement.setAttribute(
+ 'href', `${get_host_info().REMOTE_ORIGIN}/images/green.svg`);
+ </script>
+ </feImage>
+ </filter>
+ <circle id="circle" r="100" fill="lime"/>
+ </defs>
+</svg>
+
+<script type="module">
+await new Promise(resolve => window.addEventListener('load', resolve));
+
+// SVG reference filters taint the canvas according to
+// https://drafts.csswg.org/filter-effects/#tainted-filter-primitives
+
+// Not tainted.
+for (let filter of [
+ `url(${location.href}#floodLime)`,
+ `url(${location.href}#sameOriginImage)`,
+]) {
+ test(() => {
+ const ctx = document.createElement('canvas').getContext('2d');
+ ctx.filter = filter;
+ ctx.fillRect(0, 0, 300, 150);
+ assert_array_equals(ctx.getImageData(1, 1, 1, 1).data, [0, 255, 0, 255]);
+ }, `Setting filter to '${filter}' should not taint the canvas.`);
+
+ test(() => {
+ const ctx = document.createElement('canvas').getContext('2d');
+ ctx.beginLayer({filter});
+ ctx.fillRect(0, 0, 300, 150);
+ ctx.endLayer();
+ assert_array_equals(ctx.getImageData(1, 1, 1, 1).data, [0, 255, 0, 255]);
+ }, `Using a layer with filter '${filter}' should not taint the canvas.`);
+}
+
+// Tainted.
+for (let filter of [
+ `url(${location.href}#floodCurrentColor)`,
+ `url(${location.href}#elementImage)`,
+ `url(${location.href}#crossOriginImage)`,
+]) {
+ test(() => {
+ const ctx = document.createElement('canvas').getContext('2d');
+ ctx.filter = filter;
+ ctx.fillRect(0, 0, 300, 150);
+ assert_throws_dom("SecurityError", () => ctx.getImageData(1, 1, 1, 1));
+ }, `Setting filter to '${filter}' should taint the canvas.`);
+
+ test(() => {
+ const ctx = document.createElement('canvas').getContext('2d');
+ ctx.beginLayer({filter});
+ ctx.fillRect(0, 0, 300, 150);
+ ctx.endLayer();
+ assert_throws_dom("SecurityError", () => ctx.getImageData(1, 1, 1, 1));
+ }, `Using a layer with filter '${filter}' should taint the canvas.`);
+}
+</script>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/wpt_internal/html/canvas/canvas-filter-url-reference-tainting.html b/third_party/blink/web_tests/wpt_internal/html/canvas/canvas-filter-url-reference-tainting.html
new file mode 100644
index 0000000..50b2a8c2
--- /dev/null
+++ b/third_party/blink/web_tests/wpt_internal/html/canvas/canvas-filter-url-reference-tainting.html
@@ -0,0 +1,84 @@
+<!doctype html>
+<title>CSS url() filter passed to beginLayer may taint the canvas</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/common/get-host-info.sub.js"></script>
+
+<svg style="display: block; width: 0; height: 0">
+ <defs>
+ <!-- Not tainted. -->
+ <filter id="floodLime">
+ <feFlood flood-color="lime"/>
+ </filter>
+ <filter id="sameOriginImage">
+ <feImage href="/images/green.svg"/>
+ </filter>
+
+ <!-- Tainted. -->
+ <filter id="floodCurrentColor">
+ <feFlood flood-color="currentColor"/>
+ </filter>
+ <filter id="elementImage">
+ <feImage href="#circle"/>
+ </filter>
+ <filter id="crossOriginImage">
+ <feImage>
+ <script>
+ document.currentScript.parentElement.setAttribute(
+ 'href', `${get_host_info().REMOTE_ORIGIN}/images/green.svg`);
+ </script>
+ </feImage>
+ </filter>
+ <circle id="circle" r="100" fill="lime"/>
+ </defs>
+</svg>
+
+<script type="module">
+await new Promise(resolve => window.addEventListener('load', resolve));
+
+// SVG reference filters taint the canvas according to
+// https://drafts.csswg.org/filter-effects/#tainted-filter-primitives
+
+// Not tainted.
+for (let filter of [
+ `url(${location.href}#floodLime)`,
+ `url(${location.href}#sameOriginImage)`,
+]) {
+ test(() => {
+ const ctx = document.createElement('canvas').getContext('2d');
+ ctx.filter = filter;
+ ctx.fillRect(0, 0, 300, 150);
+ assert_array_equals(ctx.getImageData(1, 1, 1, 1).data, [0, 255, 0, 255]);
+ }, `Setting filter to '${filter}' should not taint the canvas.`);
+
+ test(() => {
+ const ctx = document.createElement('canvas').getContext('2d');
+ ctx.beginLayer({filter});
+ ctx.fillRect(0, 0, 300, 150);
+ ctx.endLayer();
+ assert_array_equals(ctx.getImageData(1, 1, 1, 1).data, [0, 255, 0, 255]);
+ }, `Using a layer with filter '${filter}' should not taint the canvas.`);
+}
+
+// Tainted.
+for (let filter of [
+ `url(${location.href}#floodCurrentColor)`,
+ `url(${location.href}#elementImage)`,
+ `url(${location.href}#crossOriginImage)`,
+]) {
+ test(() => {
+ const ctx = document.createElement('canvas').getContext('2d');
+ ctx.filter = filter;
+ ctx.fillRect(0, 0, 300, 150);
+ assert_throws_dom("SecurityError", () => ctx.getImageData(1, 1, 1, 1));
+ }, `Setting filter to '${filter}' should taint the canvas.`);
+
+ test(() => {
+ const ctx = document.createElement('canvas').getContext('2d');
+ ctx.beginLayer({filter});
+ ctx.fillRect(0, 0, 300, 150);
+ ctx.endLayer();
+ assert_throws_dom("SecurityError", () => ctx.getImageData(1, 1, 1, 1));
+ }, `Using a layer with filter '${filter}' should taint the canvas.`);
+}
+</script>
Original Bug Report
SOP Bypass via missing OriginTainted check in Canvas2D beginLayer filter
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 without the Chrome Security team.
Overview: The experimental Canvas2D beginLayer API applies filters but fails to check if the resulting filter graph is origin-tainted. An attacker can use an SVG <feImage> filter to render a cross-origin image onto the canvas, which fails to taint the canvas origin and allows the pixels to be extracted via getImageData().
Affected files:
third_party/blink/renderer/modules/canvas/canvas2d/canvas_2d_recorder_context.cc
Estimated timestamp from git blame: 2024-08-22
Summary
A potential Same-Origin Policy (SOP) bypass exists in the experimental beginLayer API for Canvas2D. The implementation applies filter effects to the canvas layer but fails to check if those filters contain cross-origin data. This allows an attacker to read cross-origin image data via getImageData(), bypassing the Same-Origin Policy.
Technical Description
In third_party/blink/renderer/modules/canvas/canvas2d/canvas_2d_recorder_context.cc, the beginLayerImpl function resolves a filter option into a FilterEffect graph. This graph is immediately converted to a cc::PaintFilter and applied to the layer. However, the code fails to check if the resulting filter graph is origin-tainted.
// canvas_2d_recorder_context.cc : Canvas2DRecorderContext::beginLayerImpl
479: filter = paint_filter_builder::Build(
480: filter_effect_builder.BuildFilterEffect(std::move(filter_operations),
481: !OriginClean()),
482: kInterpolationSpaceSRGB);
The FilterEffect* returned by BuildFilterEffect contains an OriginTainted() flag. For comparison, the standard ctx.filter implementation in CanvasRenderingContext2DState::GetFilter() correctly checks this flag and taints the canvas:
// canvas_rendering_context_2d_state.cc : CanvasRenderingContext2DState::GetFilter
624: if (last_effect->OriginTainted())
625: context->SetOriginTainted();
Because beginLayerImpl neglects to call SetOriginTainted() on the context, the canvas’s OriginClean flag remains true.
An attacker can exploit this by passing an SVG filter (url(#f)) to beginLayer. If the SVG filter uses an <feImage> element pointing to a cross-origin URL, the image is fetched in kNoCors mode. SVGFEImageElement::TaintsOrigin() correctly identifies this and taints the internal FilterEffect graph. However, because beginLayerImpl ignores the tainted status, the cross-origin pixels are rasterized into the canvas backing store when endLayer() is called, while the canvas remains “clean”. The attacker can then use getImageData(), toDataURL(), or toBlob() to extract the sensitive cross-origin pixel data.
Potential Steps to Reproduce
Note: Our tooling agent does not have the ability to run code, but the following steps are highly likely to reproduce the issue.
- Launch Chrome with
--enable-experimental-web-platform-featuresor enable the#canvas-2d-layersflag. - Host a page with the following HTML/JS on an attacker-controlled origin:
<svg width="0" height="0">
<filter id="f" x="0" y="0" width="100%" height="100%">
<feImage href="https://victim.example/authenticated-image.png" width="300" height="150"/>
</filter>
</svg>
<canvas id="c" width="300" height="150"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
// Wait for the cross-origin image to load, then:
setTimeout(() => {
ctx.beginLayer({filter: 'url(#f)'});
ctx.fillRect(0, 0, 1, 1); // Triggers layer application
ctx.endLayer();
try {
const data = ctx.getImageData(0, 0, c.width, c.height);
console.log("Pixels extracted successfully:", data.data);
} catch (e) {
console.log("Caught SecurityError:", e);
}
}, 1000);
</script>
Suggested Fix
Update Canvas2DRecorderContext::beginLayerImpl() to capture the FilterEffect* returned by BuildFilterEffect. Check its OriginTainted() status, and call SetOriginTainted() on the context if the filter is tainted:
FilterEffect* filter_effect = filter_effect_builder.BuildFilterEffect(
std::move(filter_operations), !OriginClean());
if (filter_effect && filter_effect->OriginTainted()) {
SetOriginTainted();
}
filter = paint_filter_builder::Build(filter_effect, kInterpolationSpaceSRGB);
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.