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
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.