← Blog

MCP server JavaScript DOM manipulation consent security

JavaScript DOM manipulation attacks represent the hardest category for static consent auditors: they have no CSS rule to inspect and no HTML attribute to query at rest. The consent text is present, correctly styled, and completely visible at document load time. The attack is encoded exclusively in JavaScript event listeners that manipulate the DOM at the precise moment of user interaction. Four SA-JS-DOM patterns cover the primary JavaScript-only consent attack vectors: inline style injection at mousedown, DOM node removal at mousedown, JS-applied accessibility attribute manipulation, and MutationObserver-based anti-audit self-removal that defeats dynamic inspection tools.

By SkillAudit  |  30 Oct 2026

SA-JS-DOM-001: element.style.display = 'none' at mousedown

A JavaScript mousedown event listener on the consent container or a parent element sets consentTextEl.style.display = 'none' at the exact moment the user begins clicking. The consent text disappears 100–300ms before the click event fires. At rest: the consent text has no inline style; getComputedStyle(el).display is block; all CSS checks pass. The attack state exists only for the duration between mousedown and mouseup, precisely the temporal window where the user commits to their decision.

// Attack: applied in site JavaScript, not in any CSS rule
document.querySelector('.consent-container').addEventListener('mousedown', () => {
  document.querySelector('.consent-text').style.display = 'none';
  // consent text disappears at mousedown
  // click event fires ~200ms later — after text is hidden
});

/* Static audit state (at document load):
   getComputedStyle(consentTextEl).display → 'block' (correct)
   consentTextEl.style.display → '' (no inline style — nothing to detect)
   → CSS audit: clean

   Dynamic detection:
   1. Simulate mousedown on consent container
   2. After microtask, check getComputedStyle(consentTextEl).display
   3. If display changed to 'none' → CRITICAL SA-JS-DOM-001

   Source analysis (harder, but catches it before runtime):
   Search for addEventListener('mousedown'|'pointerdown')
   that sets .style.display, .style.visibility, .style.opacity on consent text */

Inline style has higher CSS specificity than any stylesheet rule: When the JavaScript sets el.style.display = 'none', this adds an inline style which has specificity (1,0,0,0) — higher than any class or tag selector. No external CSS rule can override it without !important. Even a defensive audit script that runs after the mousedown and tries to set the display back will be in a race with the click event handler. The attack window is the 200ms between mousedown and click.

CRITICAL
SA-JS-DOM-001 — JS style.display = 'none' at mousedown: consent text hidden during click commit window; no CSS rule at rest. Detection: simulate mousedown + observe display change → CRITICAL (dynamic-only).

SA-JS-DOM-002: element.remove() at mousedown — DOM node deletion before click

consentTextEl.remove() called at mousedown deletes the DOM node entirely before the click event fires. Unlike display: none (which hides the element but keeps it in the DOM), remove() severs the element from the document tree. After the click fires (200ms later), the consent text is gone from the DOM: there is no element to query, no styles to check, no text content to inspect. A post-click DOM snapshot would find the consent text absent, but by that point consent has already been given. This is the most destructive DOM attack: it leaves no evidence in the post-interaction DOM state.

// Attack: consent text is removed from DOM at click commitment moment
const consentText = document.querySelector('.consent-text');
document.querySelector('.consent-container').addEventListener('mousedown', () => {
  consentText.remove(); // element is gone from DOM before click fires
});

/* Timeline:
   t=0:        Consent dialog shown; consent text in DOM; all checks pass
   t=T:        User presses mouse button → mousedown fires → consentText.remove()
   t=T+~200ms: Click event fires → accept() called
   t=T+~200ms: Post-click DOM: consentText absent from document tree

   Static audit: DOM queried at t=0 → consent text present → clean
   Dynamic simulation (correct approach):
   1. Record all consent text elements + their textContent BEFORE interaction
   2. Simulate mousedown on consent container
   3. Check if consentTextEl.isConnected → false → CRITICAL SA-JS-DOM-002
   4. (Or: document.querySelector('.consent-text') → null after mousedown)

   Alternative: MutationObserver on the consent container before mousedown:
   const mo = new MutationObserver(records => {
     for (const r of records) {
       for (const removed of r.removedNodes) {
         if (contains consent text) → CRITICAL SA-JS-DOM-002
       }
     }
   });
   mo.observe(consentContainer, { childList: true, subtree: true });
   // Then simulate mousedown → MutationObserver fires → find removed consent text */
CRITICAL
SA-JS-DOM-002 — JS element.remove() at mousedown: consent text deleted from DOM before click; no post-click evidence. Detection: simulate mousedown + check el.isConnected === false, or MutationObserver on consent container → CRITICAL (dynamic-only).

SA-JS-DOM-003: JS-applied aria-hidden="true" at mousedown

JavaScript can apply aria-hidden="true" to a consent text element at mousedown, removing it from the accessibility tree at the exact moment a keyboard or screen reader user activates a consent button. Unlike static aria-hidden (SA-HTML-ARH-001, which is present in the DOM at load time and detectable by static attribute inspection), this variant applies the attribute dynamically. At rest, the consent text has no aria-hidden attribute; a static DOM inspection finds nothing. The attack activates when the user commits to the consent action, removing the consent description from the assistive technology context at the critical moment.

// The static DOM has no aria-hidden attribute on consent text
// The attack is encoded in a JavaScript event listener:
document.querySelector('.consent-container').addEventListener('mousedown', () => {
  document.querySelector('.consent-text').setAttribute('aria-hidden', 'true');
  // consent text removed from accessibility tree at click moment
});

/* Static audit:
   consentTextEl.getAttribute('aria-hidden') → null (attribute absent at rest)
   → No anomaly detected

   Dynamic detection:
   1. Simulate mousedown on consent container
   2. Check consentTextEl.getAttribute('aria-hidden') → 'true' → CRITICAL
   3. Or: simulate keydown Enter (for keyboard activation)
      and observe aria-hidden state change

   This attack is compound: it affects screen reader users who use the keyboard
   (Tab + Enter) to activate buttons, not just mouse users.
   Requires keyboard interaction simulation in addition to mousedown. */

Keyboard and screen reader users are targeted: SA-JS-DOM-003 is an accessibility-layer attack that activates on any activation event (mousedown, keydown). Screen reader users who navigate the consent dialog with Tab and activate with Enter will trigger the aria-hidden removal. The consent text is silently removed from their accessible context at the moment they are about to make a decision.

CRITICAL
SA-JS-DOM-003 — JS setAttribute('aria-hidden', 'true') at mousedown: absent from DOM at rest; applied at click commitment moment. Detection: simulate mousedown + check aria-hidden attribute → CRITICAL (dynamic-only).

SA-JS-DOM-004: MutationObserver self-removal — anti-audit detection

A MutationObserver attached to the consent container or consent text element can be configured to trigger the removal of the consent text when it observes mutations on the element or its ancestors. Dynamic audit tools that inject audit scripts, add class names, modify attributes, or observe the DOM tree may inadvertently trigger the MutationObserver, causing the consent text to remove itself in response to the audit inspection. This creates a scenario where: (1) a human user sees the consent text correctly; (2) a dynamic audit script that modifies or observes the DOM triggers the removal and sees no consent text; (3) the audit reports an anomaly, but the mechanism is the anti-audit MutationObserver, not a simple hiding rule.

// Attacker-controlled JS: watch for mutations and self-remove
const mo = new MutationObserver((mutations) => {
  // Detect if something is observing / modifying the consent area
  for (const m of mutations) {
    if (m.type === 'attributes' || m.addedNodes.length > 0) {
      // Audit tool modified the DOM → remove consent text
      document.querySelector('.consent-text')?.remove();
      mo.disconnect(); // stop watching after triggering
    }
  }
});
mo.observe(document.querySelector('.consent-container'), {
  attributes: true,
  childList: true,
  subtree: true
});

/* Why this matters for audit tools:
   Audit scripts often:
   - Add a class or data-attribute to identified consent elements
   - Inject a style tag to mark audited elements
   - Use TreeWalker/querySelectorAll (read-only, safe)
   - Or: the act of opening DevTools triggers DOM mutations

   Any mutation on the consent container triggers the self-removal.
   The audit reports: "consent text removed during audit" → unclear if attack or audit artifact.

   Detection:
   Source code analysis: look for MutationObserver on consent-related elements
   Dynamic detection: use read-only DOM inspection (no writes to consent container)
   then simulate mousedown and observe removal — distinguishes mousedown-triggered
   removal (SA-JS-DOM-002/003) from mutation-triggered removal (SA-JS-DOM-004)

   MutationObserver forensic detection:
   // Check if a MutationObserver is watching the consent container:
   // MutationObserver instances are NOT enumerable via DOM APIs —
   // the only way to detect them is source code analysis or
   // testing whether mutations trigger observable side effects */

Anti-audit self-removal produces false-clean results: If the MutationObserver triggers before the audit engine queries the consent text, the audit engine queries an empty DOM (consent text already removed) and reports a clean result: “no consent text found, but no anomalies in what was found.” The attack succeeds at two levels: hiding consent from the user and hiding the attack from the auditor.

CRITICAL
SA-JS-DOM-004 — MutationObserver self-removal triggered by audit-tool DOM mutations: consent text removed in response to audit inspection; produces false-clean results. Detection: read-only DOM inspection + source code MutationObserver analysis → CRITICAL.

Dynamic detection framework for JS-DOM attacks

async function auditJsDomAttacks(consentEl, consentContainer) {
  const findings = [];

  // 1. Capture baseline state before any interaction
  const baselinePresent = consentEl.isConnected;
  const baselineDisplay = getComputedStyle(consentEl).display;
  const baselineAriaHidden = consentEl.getAttribute('aria-hidden');

  // 2. Set up MutationObserver before simulating interaction
  let mutationDuringMousedown = null;
  const mo = new MutationObserver(records => {
    mutationDuringMousedown = records;
  });
  mo.observe(consentContainer, { childList: true, subtree: true,
                                  attributes: true, attributeFilter: ['aria-hidden', 'style'] });

  // 3. Simulate mousedown (DO NOT write to DOM before this)
  consentContainer.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
  await new Promise(r => setTimeout(r, 50)); // wait for sync handlers

  mo.disconnect();

  // 4. Check for DOM manipulation effects
  if (!consentEl.isConnected) {
    findings.push({ id: 'SA-JS-DOM-002', severity: 'CRITICAL',
      desc: 'Consent text removed from DOM at mousedown' });
  } else {
    const postDisplay = getComputedStyle(consentEl).display;
    if (postDisplay === 'none' && baselineDisplay !== 'none') {
      findings.push({ id: 'SA-JS-DOM-001', severity: 'CRITICAL',
        desc: 'display:none applied to consent text at mousedown via JS' });
    }
    const postAriaHidden = consentEl.getAttribute('aria-hidden');
    if (postAriaHidden === 'true' && baselineAriaHidden !== 'true') {
      findings.push({ id: 'SA-JS-DOM-003', severity: 'CRITICAL',
        desc: 'aria-hidden="true" applied to consent text at mousedown via JS' });
    }
  }

  return findings;
}

Summary of SA-JS-DOM findings

IDSeverityMechanismStatic CSS auditRequired detection
SA-JS-DOM-001 CRITICAL style.display = 'none' at mousedown Missed Simulate mousedown + observe display change
SA-JS-DOM-002 CRITICAL element.remove() at mousedown Missed Simulate mousedown + check el.isConnected or MutationObserver
SA-JS-DOM-003 CRITICAL setAttribute('aria-hidden','true') at mousedown Missed Simulate mousedown + check aria-hidden attribute change
SA-JS-DOM-004 CRITICAL MutationObserver self-removal on audit mutation Missed Read-only DOM inspection; source analysis for MutationObserver patterns

Related pages