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

High @container style(--consent-hidden:1) hides consent via custom property flag on ancestor — consent element's own style is clean; detected by scanning all @container style() rules for consent selectors with display:none/opacity:0 conditions, then checking active root/ancestor custom properties.
High @container style(--theme:dark) changes consent to near-zero contrast colors — text invisible in dark mode while passing audit in light mode; detected by scanning @container style() rules for consent color/background changes, then verifying contrast ratio under each custom-property state.
High Build-time --env:prod flag on html element triggers @container style() consent hide in production only — passes all dev/staging audits; detected by scanning html element inline style for environment-specific custom properties co-occurring with @container style() consent rules.
Medium document.documentElement.style.setProperty propagates consent-hide flag globally via CSS inheritance to all container query contexts — triggered by timer, scroll, or postMessage; detected by MutationObserver on root/body style attribute monitoring for custom property additions during page lifecycle.

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.