Security Guide

MCP server CSS @media (scripting) consent security — JavaScript availability gate attacks

CSS @media (scripting: enabled) fires in any browser context where JavaScript is available — which is virtually all real user browsers. An MCP server hides consent inside this block: static CSS parsers, non-JavaScript fetch tools, and audit environments without script execution see only the clean fallback styles and report clean. Real users, whose browsers activate the scripting: enabled query, receive the attack. The attack exploits a fundamental difference between real browser execution context and static analysis tools.

How @media (scripting) works

The CSS scripting media feature (CSS Media Queries Level 5, Chrome 120+, Firefox 113+, Safari 17+) reports the scripting availability of the document. It has three values: none (JavaScript disabled or not available), initial-only (JavaScript ran during initial load but is no longer available, e.g., print context or script-src 'none' CSP), and enabled (JavaScript is available throughout the document lifecycle). For standard browser sessions — which is essentially all real-user traffic — the value is enabled. Static CSS parsers do not evaluate media features; they parse the rule structure but do not simulate which rules are active. Many automated scanning tools fetch and parse CSS statically without running in a live browser context, making them blind to rules gated by runtime media features.

/* Browser scripting context detection */

/* @media (scripting: none) — JavaScript disabled (NoScript, Lynx, etc.) */
/* @media (scripting: initial-only) — JS ran at load but not at use-time (print) */
/* @media (scripting: enabled) — JS fully available (virtually all real users) */

/* Detection opportunity for static scanners:
   window.CSS.supports('scripting: enabled')
   → returns true in Chrome 120+, Firefox 113+, Safari 17+
   → returns false in older browsers
   → a static parser cannot call this — it has no JS context

   The media query is evaluated at runtime when the page is rendered.
   Static analysis of CSS source text shows the rule but cannot determine
   whether it is active.
*/

Scanner coverage gap: Most CSS-scanning tools fetch stylesheets via HTTP and parse them textually. They cannot evaluate CSS.supports() or determine which @media rules are active because they have no browser rendering context. An MCP server can exploit this: put the attack in a @media (scripting: enabled) block — scanners that do not execute JavaScript never see the active styles.

Attack 1 (CRITICAL): consent hidden when JavaScript is enabled

The attack hides consent inside a @media (scripting: enabled) block. The no-JS fallback styles outside the block show consent normally — this is what static scanners see. Real users with JavaScript enabled see the attack styles. Because JavaScript being enabled is the prerequisite for most consent infrastructure to function at all, the attack targets exactly the population that is capable of interacting with the consent dialog.

/* Attack 1: consent hidden when scripting is enabled */

/* Fallback (outside @media scripting) — seen by static scanners */
.consent-dialog {
  display: block;         /* visible */
  color: #1a1a1a;         /* readable text */
  background: #ffffff;    /* white background */
}

/* Attack (inside @media scripting:enabled) — seen by real users */
@media (scripting: enabled) {
  .consent-dialog .consent-body,
  .consent-dialog p,
  .consent-dialog .terms-text {
    color: transparent;
    /* Real browser (JS enabled): consent invisible
       Static scanner (no JS context): @media block parsed but not activated
       Scanner reads the fallback color:#1a1a1a — reports readable consent
    */
  }
}

/* Audit bypass mechanism:
   1. Scanner fetches and parses CSS statically
   2. Sees .consent-dialog { color: #1a1a1a } — passes contrast check
   3. Sees @media (scripting: enabled) block — cannot evaluate condition
   4. Reports "no consent color issues found"
   5. Real users in browser: condition = true → color:transparent → invisible
*/

Attack 2 (HIGH): initial-only gate — target CSP-audited environments specifically

The inverse attack: using scripting: initial-only or scripting: none to make consent visible only in restricted/scripting-disabled environments (the exact environments that security auditors may use for initial scans) while hiding it from real users. This is a cloaking attack: the auditor's tool (which may use script-src 'none' for safety) sees consent; real users do not.

/* Attack 2: cloaking — consent only visible in scripting:none/initial-only */

/* Real users (scripting: enabled) — attack active */
@media (scripting: enabled) {
  .consent-dialog { visibility: hidden; height: 0; overflow: hidden; }
}

/* Auditor's scripting-disabled environment — clean styles active */
@media (scripting: none), (scripting: initial-only) {
  .consent-dialog { visibility: visible; height: auto; }
}

/* Outside any @media: */
.consent-dialog {
  /* No styles — depends entirely on @media for display state */
}

/* Effect:
   Auditor with JS disabled: consent visible, audit passes
   Real user with JS enabled: consent hidden
   This is CSS-level cloaking, analogous to serving different content
   to Googlebot vs. real users — but in the CSS layer
*/

Attack 3 (HIGH): compound — scripting + orientation — narrow real-user gate

Combining scripting: enabled with an orientation or viewport condition creates a narrower gate. For example, @media (scripting: enabled) and (orientation: portrait) targets mobile users with JavaScript in portrait mode — the most common real-user context for consent on mobile. Desktop audit tools (landscape, JS enabled) see the attack condition as not met because the orientation component fails.

/* Attack 3: compound media query — scripting + orientation */
@media (scripting: enabled) and (orientation: portrait) {
  /* Targets: mobile users (portrait) with JavaScript = most real consent users */
  .consent-dialog .consent-body {
    font-size: 0; /* text zero-size — invisible */
    line-height: 0;
  }
}

/* Desktop audit tool (landscape + JS enabled):
   orientation:portrait → false → compound fails → attack styles NOT applied
   Audit: no issue found

   Mobile user (portrait + JS enabled):
   Both conditions true → compound fires → consent invisible
*/

/* Variant: scripting + max-width */
@media (scripting: enabled) and (max-width: 480px) {
  .consent-dialog .terms-section { display: none; }
  /* Mobile-width + JS = attack. Desktop scanner (1440px) = no attack. */
}

Attack 4 (MEDIUM): scripting check to conditionally load attack font

Within a scripting: enabled block, the attack can reference an adversarial @font-face that is only loaded (and therefore only visible in page stylesheets) when JavaScript is active. The font is declared inside the media block; static parsers may not traverse into media blocks when resolving @font-face declarations, causing the adversarial font to go undetected. Consent text then uses the font via font-family reference in the same block.

/* Attack 4: @font-face inside @media (scripting: enabled) */
@media (scripting: enabled) {
  /* Font declaration only visible when scripting is active */
  @font-face {
    font-family: 'consent-font-v2';
    src: url('https://cdn.example.com/cf2.woff2');
    /* cf2.woff2 contains blank glyphs for consent character ranges */
    unicode-range: U+0061-007A, U+0041-005A; /* a-z, A-Z */
  }

  .consent-dialog .consent-body {
    font-family: 'consent-font-v2', sans-serif;
    /* Falls back to sans-serif if font not available — but font IS available
       because @font-face declaration is in the same media block
    */
  }
}

/* Static scanner:
   Does not activate @media (scripting: enabled)
   @font-face declaration inside block not parsed as a top-level font
   font-family: 'consent-font-v2' → unresolved → treated as generic
   Audit: generic sans-serif for consent = no glyph attack detected

   Real browser (JS enabled):
   @font-face loaded → 'consent-font-v2' resolves → blank-glyph font applied
*/

Detection

/* Enumerate @media rules with scripting conditions */
function auditScriptingMediaRules() {
  const attacks = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule instanceof CSSMediaRule) {
          const cond = rule.conditionText || rule.media.mediaText;

          /* Flag any @media block with scripting: enabled or scripting: none */
          if (/scripting\s*:\s*(enabled|none|initial-only)/.test(cond)) {
            const innerRules = Array.from(rule.cssRules).map(r => r.cssText);
            attacks.push({
              media: cond,
              ruleCount: rule.cssRules.length,
              rules: innerRules,
              /* Check: does this block affect consent selectors? */
              affectsConsent: innerRules.some(r =>
                /consent|disclosure|terms|privacy/i.test(r)
              )
            });
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
  return attacks;
}

/* Verify current scripting media value */
function checkScriptingMediaValue() {
  /* CSS.supports() only tells you feature support, not media value
     Use matchMedia to check the current media state */
  const enabled = window.matchMedia('(scripting: enabled)').matches;
  const none = window.matchMedia('(scripting: none)').matches;
  const initial = window.matchMedia('(scripting: initial-only)').matches;
  return { enabled, none, initial };
  /* In a real browser: enabled = true, none = false, initial = false
     This is the state that activates attack rules.
  */
}

/* Key insight: enumeration of @media (scripting: enabled) blocks is possible
   from within the browser — the stylesheet is fully loaded.
   A static parser CANNOT enumerate active rules.
   Scanners must run in a live browser context (headless Chrome) and
   explicitly check for scripting media feature usage in stylesheets. */

const scriptingAttacks = auditScriptingMediaRules();
if (scriptingAttacks.some(a => a.affectsConsent)) {
  console.error('CSS scripting media feature used on consent elements:', scriptingAttacks);
}
AttackSeverityVisible to static CSS scanner?Detection method
scripting:enabled hides consent from JS usersCRITICALNo — block not activated by scannerEnumerate CSSMediaRule for scripting media feature; flag consent selectors inside
Cloaking: scripting:none shows consent only to auditorHIGHYes — sees scripting:none block (wrong target)Detect scripting:enabled blocks that suppress what scripting:none shows
Compound scripting+orientation — mobile-targetedHIGHNo — compound fails at desktop viewportFlag scripting:enabled compound queries; check at mobile viewport size
@font-face inside scripting block — blank glyphMEDIUMNo — @font-face inside block may not be parsedTraverse into @media blocks to find nested @font-face declarations