Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in Canvas
DescriptionInformation leak in Canvas
ComponentCanvas
Bug ClassLogic Error
Tracker501594511
Fix commitdcb779a5c5eb (chromium/src) +93/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Files Changed

  • third_party/blink/renderer/modules/canvas/canvas2d/canvas_2d_recorder_context.cc
  • third_party/blink/web_tests/wpt_internal/html/canvas/canvas-filter-url-reference-tainting.html
From dcb779a5c5ebc3a3ba5062f874c98e76c25fe0bd Mon Sep 17 00:00:00 2001
From: Jean-Philippe Gravel <[email protected]>
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 <[email protected]>
Commit-Queue: Jean-Philippe Gravel <[email protected]>
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>
Loading diff…

Regression Test / PoC

shipped with the fix
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>
Loading diff…

Original Bug Report

reported by [email protected]

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.

  1. Launch Chrome with --enable-experimental-web-platform-features or enable the #canvas-2d-layers flag.
  2. 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.

View on issue tracker