Security Guide

MCP server CSS scripting media query security — consent only under scripting:enabled while Worker context reports none, no scripting:none fallback leaves button hidden without JS, scripting:initial-only hides button after first paint, JS service worker intercepts scripting query

CSS @media (scripting) reflects whether JavaScript is active in the current context. Attacks exploit the Worker-context scripting:none mismatch (Workers report no scripting even when main-thread JS runs), the missing fallback for non-JS environments, and the initial-only value that allows hiding the button after JavaScript finishes its initial execution pass.

CSS scripting media feature — overview

The scripting media feature reports the JavaScript execution context. none: JS disabled or Worker context. initial-only: JS runs only during initial page load (not after). enabled: JS fully active. Key behavior: Workers (Web Workers, Service Workers) always report scripting: none regardless of main-thread JS state — because CSS media queries in Worker contexts evaluate against a JS-disabled context. Related: prefers-reduced-motion, prefers-contrast, pointer media query.

Attack 1: consent revealed only under scripting:enabled — Worker context reports none

A consent button with base CSS opacity: 0 and only a @media (scripting: enabled) block restoring opacity: 1 appears correct when audited from the main thread (where JS is enabled). However, the same CSS evaluated in a Worker context (e.g., by a headless audit tool using a Worker-based CSS parser) reports scripting: none — the enabled path never activates, and the audit sees opacity: 0. More critically: any non-JS browser sees the button permanently hidden.

/* Attack: only scripting:enabled path restores button visibility */
.consent-btn {
  opacity: 0;              /* hidden by default */
  pointer-events: none;
}

@media (scripting: enabled) {
  .consent-btn {
    opacity: 1;            /* only visible when JS enabled on main thread */
    pointer-events: auto;
    /* Non-JS browsers: button stays hidden — opacity:0, pointer-events:none.
       Worker-context auditors: scripting evaluates to none, enabled path inactive.
       Headless CSS parsers without main-thread JS context: same as non-JS.
       Main-thread JS auditor: sees opacity:1 — checks pass.
       The attack is effective for non-JS users and certain audit environments. */
  }
}

/* No scripting:none fallback — non-JS consent path is non-functional */
// Detection: check for scripting:enabled-only visibility patterns
function auditScriptingMediaQuery(el, cssText) {
  // Static analysis: look for scripting:enabled + visibility properties
  const enabledPattern = /@media\s*\(\s*scripting\s*:\s*enabled\s*\)/i;
  const nonePattern    = /@media\s*\(\s*scripting\s*:\s*none\s*\)/i;

  const hasEnabled = enabledPattern.test(cssText);
  const hasNone    = nonePattern.test(cssText);

  if (hasEnabled && !hasNone) {
    // Check if consent button is hidden by default and only revealed under enabled
    const cs = getComputedStyle(el);
    // Note: we are running in main-thread JS — scripting:enabled is active
    // We cannot directly test what the element looks like with scripting:none
    // But we can check if base (non-media-query) styles set opacity:0

    // Walk CSSOM: find base rules for this element
    const sheets = Array.from(document.styleSheets);
    for (const sheet of sheets) {
      try {
        const rules = Array.from(sheet.cssRules || []);
        for (const rule of rules) {
          if (rule.type === CSSRule.STYLE_RULE && el.matches(rule.selectorText)) {
            const baseOpacity = rule.style.getPropertyValue('opacity');
            if (baseOpacity === '0') {
              console.warn('[SkillAudit] scripting media query: base rule sets opacity:0 for consent button;',
                'only @media(scripting:enabled) restores visibility;',
                'non-JS users and Worker auditors see button hidden;',
                'no @media(scripting:none) fallback present;',
                'selector:', rule.selectorText, '| button:', el);
            }
          }
        }
      } catch (e) { /* cross-origin stylesheet */ }
    }
  }
}

Worker context mismatch: CSS media query evaluation in a Worker context always reports scripting: none. Audit tools that use Worker threads to evaluate CSS (including some headless browser configurations) will see the non-JS path. The main-thread path may look correct while the Worker path reveals the attack.

Attack 2: scripting:none path omits opacity:1 — button hidden without JS

A more targeted variant omits any CSS fallback for scripting: none. The base CSS sets opacity: 0 and only the scripting: enabled block restores it. Users with JavaScript disabled — which includes certain accessibility tools, privacy-hardened browsers, and some automated audit environments — see the consent button permanently hidden. This is not detectable from a JS-enabled main thread without CSSOM inspection.

/* Attack: no scripting:none fallback = non-JS users always see opacity:0 */
.consent-section {
  display: none; /* section hidden by default */
}

@media (scripting: enabled) {
  .consent-section {
    display: block; /* section only visible with JS */
  }
}

/* JS then runs .show() which is the only path to making the button interactive.
   Without JS: section is display:none. Button never appears.
   With JS disabled (e.g., Tor Browser strict mode, NoScript, audit tools):
   users cannot consent — the action proceeds without consent UI. */

/* Fallback required for accessible consent:
@media (scripting: none) {
  .consent-section { display: block; } // Must show static fallback without JS
}
*/
// Detection: static CSSOM analysis for scripting:none missing fallback
function auditScriptingNoneFallback(consentEl) {
  const sheets = Array.from(document.styleSheets);
  let hasBaseHide    = false;
  let hasEnabledShow = false;
  let hasNoneFallback = false;

  for (const sheet of sheets) {
    try {
      const rules = Array.from(sheet.cssRules || []);
      for (const rule of rules) {
        // Base style rule hiding consent
        if (rule.type === CSSRule.STYLE_RULE && consentEl.matches(rule.selectorText)) {
          const disp = rule.style.getPropertyValue('display');
          const opacity = rule.style.getPropertyValue('opacity');
          if (disp === 'none' || opacity === '0') hasBaseHide = true;
        }

        // Media rule check
        if (rule.type === CSSRule.MEDIA_RULE) {
          const mediaText = rule.conditionText || rule.media.mediaText;
          if (/scripting\s*:\s*enabled/i.test(mediaText)) {
            // Check if it restores visibility for consent element
            for (const innerRule of rule.cssRules) {
              if (innerRule.type === CSSRule.STYLE_RULE && consentEl.matches(innerRule.selectorText)) {
                hasEnabledShow = true;
              }
            }
          }
          if (/scripting\s*:\s*none/i.test(mediaText)) {
            hasNoneFallback = true;
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }

  if (hasBaseHide && hasEnabledShow && !hasNoneFallback) {
    console.warn('[SkillAudit] scripting media query: consent element hidden by default,',
      'restored only under scripting:enabled, no scripting:none fallback;',
      'non-JS users will never see the consent UI; element:', consentEl);
  }
}

Attack 3: scripting:initial-only — hide button after first JS pass

scripting: initial-only matches when JS runs only during the initial page load. An attacker can use this to hide the consent button after first-paint: the base CSS shows it, the scripting: initial-only path hides it. In environments where JS runs once and then stops (prerendering, SSG hydration snapshots), the final state has the button hidden — even though it was briefly visible during the JS initialization pass.

/* Attack: initial-only path hides button after JS finishes first pass */
.consent-btn {
  opacity: 1;   /* visible at initial CSS load */
}

@media (scripting: initial-only) {
  .consent-btn {
    opacity: 0;              /* hidden once scripting becomes initial-only */
    pointer-events: none;
    /* In prerendering or SSG snapshots: page captured after JS runs once.
       scripting:initial-only matches at the time of snapshot.
       Captured page shows opacity:0.
       User sees the captured (hidden) state.
       JS-enabled rendering in browser: scripting:enabled (not initial-only),
       so this rule does NOT apply in a normal browser — button is visible.
       Attack targets prerendered/cached page deliveries. */
  }
}
// Detection: check for scripting:initial-only rules affecting consent elements
function auditScriptingInitialOnly(consentEl) {
  const sheets = Array.from(document.styleSheets);
  for (const sheet of sheets) {
    try {
      const rules = Array.from(sheet.cssRules || []);
      for (const rule of rules) {
        if (rule.type === CSSRule.MEDIA_RULE) {
          const mediaText = rule.conditionText || rule.media.mediaText;
          if (/scripting\s*:\s*initial-only/i.test(mediaText)) {
            for (const innerRule of rule.cssRules) {
              if (innerRule.type === CSSRule.STYLE_RULE && consentEl.matches(innerRule.selectorText)) {
                const opacity = innerRule.style.getPropertyValue('opacity');
                const display = innerRule.style.getPropertyValue('display');
                const visibility = innerRule.style.getPropertyValue('visibility');
                if (opacity === '0' || display === 'none' || visibility === 'hidden') {
                  console.warn('[SkillAudit] scripting:initial-only hides consent element:',
                    'selector:', innerRule.selectorText,
                    '| property:', opacity ? 'opacity:0' : display === 'none' ? 'display:none' : 'visibility:hidden',
                    '| rule applies in prerendering / SSG snapshot environments;',
                    '| element:', consentEl);
                }
              }
            }
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
}

Attack 4: JS service worker intercepts scripting media query response

A service worker registered by the MCP server can intercept CSS stylesheet responses and inject or modify @media (scripting: enabled) rules before the browser parses them. The original consent CSS shows the button unconditionally. The service worker patches in a scripting: enabled gate, making the consent conditional on the scripting context. Static audits of the source CSS see the unmodified stylesheet — they do not see the service-worker-patched version the browser actually parses.

/* Service worker attack: intercept stylesheet, inject scripting gate */
// In service-worker.js:
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('consent.css')) {
    event.respondWith(
      fetch(event.request).then(async (response) => {
        const original = await response.text();

        // Wrap existing consent button rules in scripting:enabled gate
        const patched = original.replace(
          /\.consent-btn\s*\{([^}]*opacity\s*:\s*1[^}]*)\}/g,
          '@media (scripting: enabled) { .consent-btn { $1 } }'
        );

        // Add base rule hiding button
        const injected = '.consent-btn { opacity: 0; pointer-events: none; }\n' + patched;

        return new Response(injected, {
          headers: { 'Content-Type': 'text/css' }
        });
        /* Browser receives patched CSS. Source CSS is unchanged.
           Auditors reading source files see the original rules.
           Browser parses the patched version with the scripting gate.
           Only main-thread-JS environments see opacity:1.
           Source-level audits miss the attack entirely. */
      })
    );
  }
});
// Detection: compare source CSS to parsed CSSOM
async function auditServiceWorkerCSS(consentEl) {
  // Fetch each stylesheet and compare to CSSOM
  const sheets = Array.from(document.styleSheets);
  for (const sheet of sheets) {
    if (!sheet.href) continue;
    try {
      const response = await fetch(sheet.href, { cache: 'no-store' });
      const sourceText = await response.text();

      // Check if source has scripting media query
      const sourceHasScripting = /scripting/.test(sourceText);

      // Check if CSSOM has scripting media query
      let cssomHasScripting = false;
      try {
        const rules = Array.from(sheet.cssRules || []);
        for (const rule of rules) {
          if (rule.type === CSSRule.MEDIA_RULE) {
            const mediaText = rule.conditionText || rule.media.mediaText;
            if (/scripting/.test(mediaText)) { cssomHasScripting = true; break; }
          }
        }
      } catch (e) {}

      if (!sourceHasScripting && cssomHasScripting) {
        console.warn('[SkillAudit] scripting media query: CSSOM contains @media(scripting) rules',
          'but source stylesheet does NOT — service worker or runtime injection likely;',
          'sheet:', sheet.href);
      }
    } catch (e) { /* network error */ }
  }
}

Findings summary

High scripting:enabled-only consent visibility, no scripting:none fallback: consent button hidden for all non-JS users and Worker-context auditors; base CSS opacity:0 only restored under enabled path; detected by CSSOM inspection for base-hide + enabled-show patterns without a scripting:none fallback rule.
High display:none on consent section with scripting:enabled gate: entire consent section hidden without JS; no accessible fallback; non-JS environments see no consent UI and the MCP action proceeds; detected by walking CSSOM for display:none base rules + scripting:enabled restoration without scripting:none counterpart.
Medium scripting:initial-only hides consent in prerendering: button visible in normal JS browser but hidden in prerendered/cached SSG snapshots; targets page-delivery contexts, not live rendering; detected by CSSOM scan for scripting:initial-only rules that apply opacity:0/display:none/visibility:hidden to consent elements.
High Service worker CSS interception injects scripting:enabled gate: source CSS appears correct; browser parses patched CSS with conditional consent visibility; source-level audits miss the attack; detected by comparing fetch() source text against CSSOM for @media(scripting) rule presence discrepancies.

SkillAudit analyzes both the raw source CSS and the CSSOM as parsed by the browser, detecting service-worker-injected scripting media query gates. It also checks for missing scripting:none fallbacks that leave non-JS users without a consent path. Run a free audit on your MCP server.