CVE-2026-3941
Overview
Background
- `%c` console formatter
- DevTools lets
console.log('%c...', css)style a console message. The frontend applies only a filtered subset of properties (ALLOWED_PROPERTY_PREFIXESsuch asbackground,border,color) throughupdateStyleinConsoleFormat.ts. - Data-URL-only policy
- the formatter’s own comment says it only allows
data:URLs insideurl(). Any property whose value contains a non-data:URL is skipped, so console styling cannot make DevTools load remote resources. - CSS escapes and `image-set()`
- CSS identifiers and function names are case-insensitive and may be written with backslash or hex escapes (e.g.
ur\6c ().image-set()can also take bare string URLs withouturl(). - `if()` and unparsed values
- when a value contains a substitution function such as
if(),getPropertyValuereturns the author’s raw token text (escapes and casing intact) rather than a normalized serialization, as the new test cases show.
Root Cause Analysis
updateStyle writes each declaration into a scratch element (buffer.style), reads it back with getPropertyValue, and skips the property if any URL captured by URL_REGEX is not a data: URL. Before the patch, URL_REGEX was /url\([\'\"]?([^\)]*)/g, which matches only lowercase, unescaped url(. Any URL reference spelled differently got past the check. Examples include urL(, ur\6c (, \u\r\l(, and an image-set() (escaped or not) that takes a bare string such as "http://localhost/a.png". The if(else:...) wrapper in the new tests keeps those spellings verbatim in the value that getPropertyValue returns, while the style engine still resolves them to a real image fetch.
The patch adds cssEscapeRegex, which builds a case-insensitive pattern that also accepts backslash and hex escapes, and uses it to rebuild URL_REGEX, now with the gi flags and a lookahead so overlapping matches are found. It also adds IMAGESET_REGEX/GOOD_IMAGESET_REGEX. If any image-set() contains an entry other than url("..."), type("...") or a resolution, the whole property is skipped with continue before the existing data: scheme check runs.
The fix tightens the filter: it rejects more inputs than before.
data:-only URL check relied on a literal, case-sensitive url( regex, but CSS allows URL references to be spelled in other ways: different casing, escape sequences, and bare strings inside image-set(). The fix makes the matcher escape-aware and case-insensitive, and rejects any image-set() that does not wrap every image in url().Attack Path
- Page logs styled message
A web page calls
console.logwith a%cstyle string whose allowed-prefix property (e.g.background-image) points to a remote URL. - Disguise the URL reference
The URL is written so the old
URL_REGEXdoes not match it, for exampleif(else:ur\6c (http://...)),urL(...)orimage-set("http://..." 2x), as in the new test cases. - Filter accepts the property
updateStylefinds no non-data:URL and keeps the property, so it is applied to the message element in the DevTools frontend. - DevTools fetches the resource When the console renders the message, the DevTools frontend loads the attacker’s remote image. This lets the page learn that DevTools is open and inspecting it, along with the request metadata of that fetch (inferred; the diff shows only the filter bypass).
Impact Assessment
data:-only policy. Any consequence beyond that network fetch, such as DevTools detection or fingerprinting, is (inferred).Files Changed
AUTHORSfront_end/panels/console/ConsoleFormat.test.tsfront_end/panels/console/ConsoleFormat.ts
Audit Directions
- Regex-based CSS sanitizersCheck other DevTools or WebUI code that filters CSS text with regexes (
url(,expression,@import,image-set,src()) for case-sensitivity, escape handling, and URL-bearing functions they don’t cover. - Sanitize-after-serialize assumptionsCode that validates the output of
getPropertyValue/cssTextassumes the value is normalized. Values containing substitution functions (if(),var(),attr()) keep raw tokens, so the check should run on parsed values, not on strings. - Allowlisted property prefixes with URL sinksReview every property allowed by
ALLOWED_PROPERTY_PREFIXES(e.g.border-image-source,background, andfontshorthands) for URL-taking syntaxes that the current matcher does not model.
Patch
From a98136b97706f7972cbc99b69e283398d90a442c Mon Sep 17 00:00:00 2001
From: Lyra Rebane <rebane2001@gmail.com>
Date: Thu, 22 Jan 2026 10:31:20 +0200
Subject: [PATCH] [console] Improve URL filtering for console.log %c formatter
This CL improves upon the URL regex of the %c formatter to prevent some
new `url()` and `image-set()` edge-cases in modern CSS. Specifically, it
covers cases where said CSS functions are escaped, and those where
`image-set()` is used without using `url()`;
The CL also adds the corresponding test cases.
Bug: 474670215
Change-Id: I21909a2650c985eac4f2ec714aea561e5d840003
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/7484749
Commit-Queue: Simon Zünd <szuend@chromium.org>
Reviewed-by: Simon Zünd <szuend@chromium.org>
Reviewed-by: Philip Pfaffe <pfaffe@chromium.org>
---
diff --git a/AUTHORS b/AUTHORS
index ff02b4a..5a301e4 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -64,6 +64,7 @@
Liam DeBeasi <ldebeasi@gmail.com>
Luke Swiderski <luke.swiderski@gmail.com>
Luke Warlow <luke@warlow.dev>
+Lyra Rebane <rebane2001@gmail.com>
Marijn Haverbeke <marijnh@gmail.com>
Max 😎 Coplan <mchcopl@gmail.com>
Michael Brüning <michael.bruning@qt.io>
diff --git a/front_end/panels/console/ConsoleFormat.test.ts b/front_end/panels/console/ConsoleFormat.test.ts
index 3a3b78a..4b963ed 100644
--- a/front_end/panels/console/ConsoleFormat.test.ts
+++ b/front_end/panels/console/ConsoleFormat.test.ts
@@ -444,19 +444,47 @@
styles,
'background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAAAAABzHgM7AAAAF0lEQVR42mM4Awb/wYCBYg6EgghRzAEAWDWBGQVyKPMAAAAASUVORK5CYII=), url(http://localhost/a.png)'); // Multiple URLs
assert.isFalse(styles.has('background-image'));
+
+ Console.ConsoleFormat.updateStyle(
+ styles, 'background-image:if(supports():"url(data:";else:url(http://localhost/a.png))');
+ assert.isFalse(styles.has('background-image'));
+
+ Console.ConsoleFormat.updateStyle(styles, 'background-image:if(else:urL(http://localhost/a.png))');
+ assert.isFalse(styles.has('background-image'));
+
+ Console.ConsoleFormat.updateStyle(styles, 'background-image:if(else:ur\\6c (http://localhost/a.png))');
+ assert.isFalse(styles.has('background-image'));
+
+ Console.ConsoleFormat.updateStyle(styles, 'background-image:if(else:\\u\\r\\l(http://localhost/a.png))');
+ assert.isFalse(styles.has('background-image'));
+
+ Console.ConsoleFormat.updateStyle(
+ styles, 'background-image:if(else:image\\-set("data:" 1x, "http://localhost/a.png" 2x))');
+ assert.isFalse(styles.has('background-image'));
+
+ Console.ConsoleFormat.updateStyle(
+ styles, 'background-image:if(else:image-se\\74 ("data:" 1x, "http://localhost/a.png" 2x))');
+ assert.isFalse(styles.has('background-image'));
+
+ Console.ConsoleFormat.updateStyle(styles, 'background-image:image-set("data:" 1x, "http://localhost/a.png" 2x)');
+ assert.isFalse(styles.has('background-image'));
});
it('allows data urls in values', () => {
const dataUrl =
- 'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAAAAABzHgM7AAAAF0lEQVR42mM4Awb/wYCBYg6EgghRzAEAWDWBGQVyKPMAAAAASUVORK5CYII=)';
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAAAAABzHgM7AAAAF0lEQVR42mM4Awb/wYCBYg6EgghRzAEAWDWBGQVyKPMAAAAASUVORK5CYII=';
const styles = new Map();
- Console.ConsoleFormat.updateStyle(styles, `background-image:${dataUrl}`);
+ Console.ConsoleFormat.updateStyle(styles, `background-image:url(${dataUrl})`);
assert.include(styles.get('background-image').value, 'data:image/png;base64');
- Console.ConsoleFormat.updateStyle(styles, `border-image-source:${dataUrl}`);
+ Console.ConsoleFormat.updateStyle(styles, `border-image-source:url(${dataUrl})`);
assert.include(styles.get('border-image-source').value, 'data:image/png;base64');
+
+ Console.ConsoleFormat.updateStyle(
+ styles, `background-image:image-set( "${dataUrl}" 1.5x , url("${dataUrl}") type( "image/png" ) )`);
+ assert.include(styles.get('background-image').value, 'data:image/png;base64');
});
});
});
diff --git a/front_end/panels/console/ConsoleFormat.ts b/front_end/panels/console/ConsoleFormat.ts
index 3526590..bfc8b65 100644
--- a/front_end/panels/console/ConsoleFormat.ts
+++ b/front_end/panels/console/ConsoleFormat.ts
@@ -199,11 +199,34 @@
return {tokens, args: args.slice(argIndex)};
};
+/**
+ * This function converts a string into a partial regex string that
+ * case-insensitively matches it in CSS, even if CSS escapes are used.
+ *
+ * @param cssString the target string.
+ * @returns a partial regex matching the string in CSS.
+ */
+const cssEscapeRegex = (cssString: string): string => {
+ return [...cssString]
+ .map(char => {
+ const charCodes = new Set([char.toLowerCase(), char.toUpperCase()].map(c => c.charCodeAt(0).toString(16)));
+ const charCodeRegex =
+ [...charCodes].map(charCode => `\\\\0{0,${6 - charCode.length}}${charCode}[ \\n\\t]?`).join('|');
+ return `\\\\?(?:${charCodeRegex}|${char})`;
+ })
+ .join('');
+};
+
export const updateStyle = (currentStyle: Map<string, {value: string, priority: string}>, styleToAdd: string): void => {
const ALLOWED_PROPERTY_PREFIXES = ['background', 'border', 'color', 'font', 'line', 'margin', 'padding', 'text'];
// We only allow data URLs with the `url()` CSS function.
// The capture group is not intended to grab the whole URL exactly, just enough so we can check the scheme.
- const URL_REGEX = /url\([\'\"]?([^\)]*)/g;
+ // The regex also covers CSS hex-escaped variations of `url()`.
+ const URL_REGEX = new RegExp(`(?=${cssEscapeRegex('url')}\\(['"]?([^\\)]*))`, 'gi');
+ // We greedily capture all `image-set()`s to make sure that all of
+ // them properly use `url()`s to enforce the data URL check later.
+ const IMAGESET_REGEX = new RegExp(`(?=(${cssEscapeRegex('image-set')}\\(.*))`, 'gi');
+ const GOOD_IMAGESET_REGEX = /^image-set\((?:(?:(?:url|type)\("[^\\"]*"\)|[\d.]+(?:x|dpi|dpcm|dppx)),?\s*)+\)/i;
currentStyle.clear();
/* eslint-disable-next-line @devtools/no-imperative-dom-api --
@@ -218,9 +241,15 @@
continue;
}
+ const value = buffer.style.getPropertyValue(property);
+ // We make sure every `image-set()` only uses `url()`s for its images.
+ // If any of them seem malformed, we skip the whole property.
+ const imageSets = [...value.matchAll(IMAGESET_REGEX)];
+ if (imageSets.some(match => !GOOD_IMAGESET_REGEX.test(match[1]))) {
+ continue;
+ }
// There could be multiple `url()` functions, so we check them all.
// If any of them is not a `data` URL, we skip the whole property.
- const value = buffer.style.getPropertyValue(property);
const potentialUrls = [...value.matchAll(URL_REGEX)].map(match => match[1]);
if (potentialUrls.some(
potentialUrl => !Common.ParsedURL.schemeIs(potentialUrl as Platform.DevToolsPath.UrlString, 'data:'))) {
Original Bug Report
Bypass for console.log %c formatter url filter
VULNERABILITY DETAILS
The mitigations for Issue 40056332 and Issue 40060475 can be bypassed by using specific CSS syntax to load an external url. This leads to minor data exfil in DevTools (ip/ua/headers, whether the console was opened, css media queries etc).
The vulnerable regex is at: https://source.chromium.org/chromium/_/chromium/devtools/devtools-frontend/+/main:front_end/panels/console/ConsoleFormat.ts;l=206;drc=8cf3f6e6e069efefa0ae8ef9557214f62e856f81
VERSION
Chrome Version: 145.0.7587.6 Dev, 145.0.7626.0 Canary
Operating System: Windows, Mac, Linux, ChromeOS
REPRODUCTION CASE
PoC:
console.log("%c\t", `background-image:if(supports(_): "url(data:"; else: url("https://google.com/favicon.ico"));`)
Running the above, the Google favicon will appear in the DevTools console log.
CREDIT INFORMATION
Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited?
Reporter credit: Lyra Rebane (rebane2001)