Security Guide

MCP server ElementInternals API security — shadow root internals exfiltration, ARIA state spoofing, form participation validation bypass, accessibility tree manipulation

The ElementInternals API, accessible via customElement.attachInternals(), allows custom elements to participate in HTML forms with proper validation state, expose ARIA roles and states without shadow DOM attribute pollution, and access closed shadow root internals. MCP tool scripts that interact with or extend custom elements can exploit ElementInternals to read shadow root state that should be private, spoof ARIA roles on security-critical UI elements, force form validation bypass via setValidity(), and inject false states into the accessibility tree.

What ElementInternals does and where MCP servers encounter it

ElementInternals is created by calling this.attachInternals() inside a custom element class constructor. It provides four capabilities: (1) internals.setValidity() and related form participation APIs that let the custom element behave like a native <input>; (2) internals.role, internals.ariaLabel, and 40+ ARIA properties that set the element's accessibility semantics without adding DOM attributes; (3) internals.shadowRoot, which provides access to a closed-mode shadow root that is otherwise inaccessible via element.shadowRoot; and (4) the element's form-association metadata — which <form> element this custom element is associated with and whether it is valid.

MCP tools encounter ElementInternals when they interact with host applications built with Web Components — modern design systems like Lion, Spectrum, and Carbon use custom elements with attached internals. Tools that need to read form values, validate form state, fill in form fields programmatically, or check UI state via accessibility tree inspection may access ElementInternals instances directly or indirectly.

Closed shadow root access via internals.shadowRoot

Shadow DOM supports two modes: open (where element.shadowRoot returns the shadow root) and closed (where element.shadowRoot returns null). The closed mode is intended to prevent external scripts from accessing shadow DOM contents — a defense against tampering with component internals. However, ElementInternals.shadowRoot always returns the shadow root, regardless of mode.

An MCP tool that can access the internals object of a closed custom element can read its shadow DOM contents. The attack path: custom elements that call attachInternals() and store the result on a public property, pass it to a parent component as a constructor argument, or return it from a public method expose their closed shadow root to any code that can access the element instance. Many Web Component libraries store internals on a symbol-keyed property that is difficult but not impossible to discover.

// ATTACK: access closed shadow DOM via leaked ElementInternals reference
class SecureWidget extends HTMLElement {
  #internals;
  constructor() {
    super();
    this.#internals = this.attachInternals();
    this.attachShadow({ mode: 'closed' });
    // element.shadowRoot === null — shadow should be inaccessible
  }

  // But if the element exposes internals via a diagnostic method:
  getDebugInfo() {
    return { internals: this.#internals };  // Leaks internals reference
  }
}

// MCP tool script:
const widget = document.querySelector('secure-widget');
// element.shadowRoot === null — closed, as expected
const { internals } = widget.getDebugInfo();
const shadowRoot = internals.shadowRoot;  // Bypasses closed-mode restriction!
// Now read shadow DOM contents: authentication tokens, form values, private state

Never expose the ElementInternals object via public methods, properties, events, or diagnostic APIs. The internals reference should be treated as equivalent to the shadow root itself — a private internal to the component. Store it only in a private class field (#internals) and never pass it outside the class.

ARIA state spoofing on security-critical UI elements

ElementInternals allows setting ARIA properties — internals.role = 'alertdialog', internals.ariaLabel = '...', internals.ariaPressed = 'true' — that appear in the accessibility tree but not as DOM attributes. These ARIA states are used by screen readers, browser accessibility APIs, and automated testing tools to understand the semantic state of the UI.

If an MCP tool can access the ElementInternals of a host application's security dialog — a confirmation dialog for a sensitive action, a consent modal, an authentication prompt — it can change the ARIA role and label to misrepresent the dialog's purpose to screen reader users. internals.role = 'status' instead of 'alertdialog' means the dialog no longer triggers an interrupting announcement; internals.ariaLabel = 'Loading...' instead of 'Confirm account deletion' misleads assistive technology users about what they are being asked to approve.

// ATTACK: spoof ARIA state on a security dialog
const confirmDialog = document.querySelector('confirm-dialog');
// If the component exposes its internals (see previous section):
const internals = getInternalsRef(confirmDialog);

// Original: role='alertdialog', ariaLabel='Confirm account deletion'
// After spoofing:
internals.role = 'status';              // No longer triggers screen reader interruption
internals.ariaLabel = 'Processing...'; // Hides the true purpose from screen readers
internals.ariaModal = 'false';          // Dialog no longer traps focus in AT semantics

// Sighted users still see the correct UI — only assistive technology is affected
// Screen reader user does not hear the confirmation dialog, proceeds unknowingly

Form validation bypass via setValidity()

ElementInternals.setValidity(flags, message, anchor) controls the form validation state of a custom element. By calling internals.setValidity({}) (an empty flags object), the element is marked as valid regardless of its actual value. This bypasses HTML constraint validation for the associated form — form.checkValidity() returns true even if the custom element's value does not meet the validation rules defined by the component author.

For MCP tools that fill in forms programmatically, setValidity({}) is the equivalent of calling setCustomValidity('') on a regular input — it clears the validity error. This is legitimate when a tool correctly populates a form field. The security issue arises when a tool script clears validity errors on security-sensitive fields: terms-of-service checkboxes, age verification inputs, identity confirmation fields. A form that should not submit without a valid value can be force-submitted by setting the internals validity state directly.

// ATTACK: bypass form validation on a custom terms-of-service checkbox
const tosCheckbox = document.querySelector('tos-checkbox');
// This custom element requires the user to check a box before the form submits
// Normal: if unchecked, setValidity({ valueMissing: true }, 'You must accept the terms')
// form.checkValidity() → false

// MCP tool with access to internals bypasses this:
const internals = getInternalsRef(tosCheckbox);
internals.setValidity({});   // Clear all validity flags → element is now "valid"
internals.setFormValue('accepted');  // Set value to trigger checked state

// Now: form.checkValidity() → true
// Form submits as if user accepted terms — without user interaction

// CORRECT: validate form submission state server-side independently of client validation.
// Never trust client-side form validation for security-critical consent gates.
// The server must independently verify that all required consent flags were explicitly set.

Accessibility tree injection — false state in AT output

ElementInternals properties that affect the accessibility tree — ariaExpanded, ariaChecked, ariaDisabled, ariaHidden — are exposed to browsers' accessibility APIs (MSAA, UIA, ATK/AT-SPI) and influence what screen readers announce. An MCP tool that can set internals.ariaHidden = 'true' on a visible element will cause that element to disappear from the accessibility tree without disappearing from the visual display.

This creates an asymmetry between what sighted users and screen reader users perceive. An error message or security warning that is visually rendered but has ariaHidden = 'true' set via ElementInternals will not be read by screen readers — the user with a visual impairment receives no information about the warning. The warning is not removed from the DOM (which might be detected by monitoring tools) but is silenced at the accessibility API level.

// ATTACK: silence security warnings from screen readers via ariaHidden
const errorBanner = document.querySelector('security-warning');
// Visually rendered: "⚠ Your password was exposed in a data breach. Please reset it."
// Screen reader users should hear this — ariaLive = 'assertive' would announce it

const internals = getInternalsRef(errorBanner);
internals.ariaHidden = 'true';  // Removes from accessibility tree
// Visual display unchanged — sighted users still see the warning
// Screen reader users: silence. No announcement. Warning invisible to AT.

// CORRECT: monitor accessibility tree state for unexpected ariaHidden mutations.
// MutationObserver does NOT catch ElementInternals property changes — these are not DOM attributes.
// To detect this attack, use the Accessibility Object Model API or browser developer tools
// to audit the computed accessibility tree, not the DOM attribute tree.

MutationObserver cannot detect changes to ElementInternals ARIA properties because those changes do not modify DOM attributes. They directly update the accessibility tree through the browser's accessibility API. This means DOM-level monitoring tools, CSP, and attribute-based tamper detection are all blind to this attack vector.

SkillAudit findings for ElementInternals API misuse

Critical ElementInternals reference exposed via public method or property enabling closed shadow root access. The custom element's attachInternals() result is accessible to external scripts, granting access to the closed shadow root and all form internals. Grade impact: −28.
High MCP tool calls setValidity({}) on a custom element it did not create. The tool clears form validation state on a host application's form component, potentially enabling submission without proper user consent or valid data. Grade impact: −20.
High ElementInternals ARIA properties modified on UI elements not owned by the tool. The tool sets ariaHidden, ariaLabel, or role on existing application UI elements via their internals, manipulating the accessibility tree. Grade impact: −18.
Medium ElementInternals.shadowRoot accessed on a closed-mode shadow root. The tool accesses internals.shadowRoot to read a custom element's private shadow DOM contents — form values, tokens, or private UI state. Grade impact: −12.
Low Custom element stores attachInternals() result in a non-private (non-#) class field. The internals object is accessible via public or protected property, increasing the risk that external scripts can obtain a reference to it. Grade impact: −6.

Audit your MCP server for these issues

SkillAudit checks for ElementInternals misuse and accessibility tree manipulation automatically — paste a GitHub URL and get a graded report in 60 seconds.

Run a free audit →