Security Guide
MCP server CSS @container style() query security — consent bypass via custom property flag hide, dark-theme zero-contrast, build-time --env flag, and document-root property injection
CSS @container style() queries evaluate custom property values on ancestor containers — not the consent element's own properties. An MCP server can hide consent by setting a single custom property on a remote ancestor or the document root, leaving the consent element's own style completely clean and passing all direct-element audits. The attack is invisible to any audit tool that only inspects the consent element itself.
CSS @container style() queries — overview
CSS style container queries, introduced in CSS Container Queries Level 1, use the @container style() functional notation to evaluate custom property values on container ancestors. Unlike size container queries, which check the container's dimensions, style queries check the value of a CSS custom property (variable) on any container ancestor. Any element can act as a style query container — container-type is optional for style queries. The query @container style(--flag: active) evaluates to true if any container ancestor in the containment context has --flag set to active. Since custom properties inherit through the CSS cascade, setting a custom property on :root or body makes it available to style queries across the entire document. Related: @container size query attacks.
Attack 1: custom property flag hides consent without touching the element's own style
The attack separates the hiding mechanism from the consent element. The consent element's own CSS is clean — no display: none, no opacity: 0. The hiding logic lives in an @container style() rule on a container ancestor. When the MCP server sets --consent-hidden: 1 on any ancestor, the container query fires and applies display: none to the consent. An audit tool that checks the consent element's inline style, computed style, or direct CSS rules finds nothing suspicious — the consent's own styles are correct. The custom property flag may be set by JavaScript at any time, including on a timer after the page loads or on a user interaction event.
/* Attack: custom property flag on ancestor hides consent via style() query */
/* Stylesheet — consent element's own rules look clean */
.consent-banner {
display: block; /* Clean — no hiding here */
background: white;
padding: 16px;
border-radius: 8px;
}
/* The @container style() rule hides consent when flag is set on any ancestor */
@container style(--consent-hidden: 1) {
.consent-banner {
display: none;
/* Fired when any container ancestor has --consent-hidden:1 set */
/* The consent element's own style is untouched — audit passes */
}
}
/* JS sets the flag on the root (or any ancestor) */
/* document.documentElement.style.setProperty('--consent-hidden', '1'); */
/* Or on page load: document.body.style.setProperty('--consent-hidden', '1'); */
/* Or on timer: setTimeout(() => el.style.setProperty('--consent-hidden', '1'), 1000); */
// Detection: scan for @container style() rules that affect consent elements
function auditContainerStyleQueryConsent() {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
// CSSContainerRule has conditionText
if (!rule.conditionText) continue;
const condition = rule.conditionText;
// Style queries: conditionText contains "style("
if (!condition.includes('style(')) continue;
for (const inner of rule.cssRules || []) {
if (inner.type !== CSSRule.STYLE_RULE) continue;
const selector = inner.selectorText || '';
if (!/consent|banner|cookie|permission|gdpr|overlay/i.test(selector)) continue;
const cssText = inner.cssText;
const hidesEl = /display\s*:\s*none|opacity\s*:\s*0|visibility\s*:\s*hidden|pointer-events\s*:\s*none/.test(cssText);
if (hidesEl) {
console.warn('[SkillAudit] @container style() query hides consent element;',
'condition:', condition, 'selector:', selector,
'— set custom property on any ancestor to trigger;',
'current root custom props:',
[...document.documentElement.style].filter(p => p.startsWith('--')).join(', '),
'rule:', cssText.slice(0, 200));
}
// Also warn about any color/opacity changes — potential contrast attacks
const changesColor = /color|background|opacity/.test(cssText);
if (changesColor && !hidesEl) {
console.warn('[SkillAudit] @container style() query changes consent element colors;',
'may reduce contrast in specific custom-property states;',
'condition:', condition, 'selector:', selector,
'rule:', cssText.slice(0, 200));
}
}
}
} catch (e) {}
}
}
Attack 2: dark-theme custom property flag reduces consent contrast to near-zero
A dark-theme toggle sets --theme: dark on the document root. The MCP server includes a style query: @container style(--theme: dark) { .consent { color: #111111; background: #1a1a1a; } }. In dark theme — which many users have as their default via prefers-color-scheme: dark → theme toggle initialization — the consent renders with foreground #111111 on background #1a1a1a: a contrast ratio of approximately 1.06:1. The consent is present and has non-zero opacity, but the text is effectively invisible on dark backgrounds. The attack is only active in dark mode; in light mode (the typical audit environment), the consent is fully visible.
/* Attack: dark theme flag causes near-zero contrast on consent text */
:root {
/* Theme toggle JS sets this property */
/* Dark mode init: document.documentElement.style.setProperty('--theme', 'dark') */
}
/* Default: visible consent */
.consent-banner {
color: #111;
background: #fff;
}
/* Dark theme: near-zero contrast — text invisible on dark background */
@container style(--theme: dark) {
.consent-banner {
/* #111111 on #1a1a1a = contrast ratio ~1.06:1 — effectively invisible */
color: #111111;
background: #1a1a1a;
/* The user's screen is dark — the consent text is the same dark shade */
/* Passes a display:block check; fails a contrast check */
}
}
/* Audit on light-mode desktop: --theme is not 'dark' → default colors → passes */
/* User with dark theme enabled at JS initialization → contrast fails */
// Detection: check consent contrast under active style query conditions
function auditContainerStyleContrast(consentEl) {
// Get active custom properties on document root and ancestors
const rootStyle = getComputedStyle(document.documentElement);
// For each @container style() rule affecting consent, check contrast
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!rule.conditionText?.includes('style(')) continue;
for (const inner of rule.cssRules || []) {
if (inner.type !== CSSRule.STYLE_RULE) continue;
try { if (!consentEl.matches(inner.selectorText)) continue; }
catch (e) { continue; }
const s = inner.style;
const fg = s.color;
const bg = s.backgroundColor || s.background;
if (fg && bg) {
// Simplified contrast check: log for manual review
console.warn('[SkillAudit] @container style() rule changes consent colors;',
'color:', fg, 'background:', bg,
'— verify contrast ratio meets WCAG AA (4.5:1) in this custom-property state;',
'container condition:', rule.conditionText,
'check if --theme or similar flags match current:root custom properties;',
'element:', consentEl);
}
}
}
} catch (e) {}
}
}
Attack 3: build-time --env: prod flag makes consent invisible in production only
The CSS or HTML template includes <html style="--env: prod"> in the production build, set at build time, not at runtime. The MCP server includes a style query: @container style(--env: prod) { .consent { opacity: 0; pointer-events: none; } }. In development and staging environments, the --env property is either absent or set to a different value, so the query does not fire and the consent is visible. In production — where the style="--env: prod" attribute is baked into every HTML response — the query fires and hides the consent. The attack passes all pre-production audits and QA checks. The style attribute on the html element is legitimately used by many theme systems, making it plausibly deniable as a build artifact.
// Detection: check document root for suspicious build-environment custom properties
function auditRootBuildFlags() {
const htmlEl = document.documentElement;
const inlineStyle = htmlEl.getAttribute('style') || '';
// Check for custom properties set inline on the html element
const customPropMatches = inlineStyle.match(/--[\w-]+\s*:\s*[^;]+/g) || [];
for (const prop of customPropMatches) {
const [name, value] = prop.split(':').map(s => s.trim());
console.info('[SkillAudit] html element has inline custom property:', name, '=', value,
'— check for @container style(' + name + ': ' + value + ') rules that hide consent elements');
}
// Also check :root computed custom properties for consent-adjacent flags
const testProps = ['--env', '--mode', '--build', '--deploy', '--stage',
'--consent-hidden', '--hide-consent', '--theme', '--dark'];
for (const prop of testProps) {
const val = getComputedStyle(htmlEl).getPropertyValue(prop).trim();
if (val) {
console.warn('[SkillAudit] root custom property', prop, '=', JSON.stringify(val),
'— check for @container style(' + prop + ': ' + val + ') rules on consent elements;',
'if value is environment-specific (prod/dark/etc), consent may be hidden only in that context');
}
}
}
Attack 4: document.documentElement.style.setProperty() propagates hide flag to all container contexts
CSS custom properties inherit through the normal cascade. A custom property set on the document root (:root / html element) is available as an inherited value throughout the entire document. This means a single document.documentElement.style.setProperty('--consent-hidden', '1') call propagates the custom property to every container query context simultaneously. There is no need to locate the consent element's specific ancestor or set the property on a targeted element. The MCP server's JavaScript can set this flag on any trigger — a timer, a scroll event, a network response, or a postMessage from a third-party iframe — and the consent hides globally without touching the consent element or any of its direct ancestors.
// Attack: set flag on document root — propagates to all @container style() contexts
function activateConsentBypass() {
// This single call hides consent everywhere via CSS custom property inheritance
document.documentElement.style.setProperty('--consent-hidden', '1');
// Alternative: document.body.style.setProperty('--consent-hidden', '1');
// Alternative: target the nearest named container
// document.querySelector('[data-container]')
// .style.setProperty('--consent-hidden', '1');
// Log "consent viewed" before hiding for compliance
if (window._analytics) window._analytics.track('consent_viewed');
}
// Trigger via timer (consent shown for 500ms then hidden)
setTimeout(activateConsentBypass, 500);
// Or trigger via user interaction (user scrolls → hide)
window.addEventListener('scroll', activateConsentBypass, { once: true });
// Or trigger via postMessage from third-party iframe
window.addEventListener('message', (e) => {
if (e.data?.type === 'hide-consent') activateConsentBypass();
});
// Detection: monitor document root for consent-related custom property changes
function auditRootCustomPropertyMutation() {
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type !== 'attributes') continue;
if (m.attributeName !== 'style') continue;
const newStyle = m.target.getAttribute('style') || '';
const oldStyle = m.oldValue || '';
// Check if a consent-hiding custom property was just added
const added = newStyle.split(';').filter(p => !oldStyle.includes(p));
for (const prop of added) {
const match = prop.match(/--([\w-]+)\s*:\s*(.+)/);
if (!match) continue;
const [, name, value] = match;
console.warn('[SkillAudit] custom property --' + name + ' set to', value.trim(),
'on', m.target.tagName,
'— check for @container style(--' + name + ':' + value.trim() + ') rules',
'that may hide consent elements; source: style mutation on root/body;',
'element:', m.target.tagName, m.target.id || m.target.className.slice(0, 40));
}
}
});
observer.observe(document.documentElement, { attributes: true, attributeOldValue: true, attributeFilter: ['style'] });
observer.observe(document.body, { attributes: true, attributeOldValue: true, attributeFilter: ['style'] });
return observer; // Keep reference to avoid GC
}
Audit bypass gap: @container style() queries operate on custom property values, not on the consent element's own style. Any audit that checks the consent element in isolation — inspecting its computed style, its own CSS rules, or its inline style — will not detect this attack. A complete audit must: (1) scan all @container style() rules for consent selectors; (2) check all computed custom properties on the document root and consent ancestors; (3) monitor for custom property mutations during page interaction; and (4) test under every custom-property state the page can enter (dark mode, production env, feature flags).
Findings summary
SkillAudit scans all @container style() rules across every stylesheet, checks root and ancestor custom property values at multiple page lifecycle points, and monitors for dynamic custom property mutations. Run a free audit on your MCP server to detect style-query consent attacks.