MCP server HTML aria-hidden and inert consent security
Four SA-HTML-ARH consent attack patterns that use HTML accessibility attributes to hide consent text from screen readers, remove elements from the accessibility tree, disable all interaction with consent UI, or substitute a false accessible name for the visual consent text. These attacks target the accessibility layer of consent rather than the visual layer: the rendered pixels may be correct while the accessibility tree — what screen readers and accessibility APIs report — is missing or wrong.
HTML accessibility attributes (aria-hidden, hidden, inert, aria-label) control the mapping between the visual DOM and the accessibility tree that assistive technologies consume. A sighted user reading visually may see one version of a consent dialog; a screen reader user hears what the accessibility tree reports. Malicious consent UIs can exploit this gap: the visual rendering looks like full consent, but the accessibility tree contains no consent text, or contains different text than what is visually displayed. This is distinct from CSS-based visual attacks — the CSS is often perfectly normal, and the attack is entirely in the HTML attribute layer.
SA-HTML-ARH-001: aria-hidden="true" — accessibility tree removal with visual presence
aria-hidden="true" on an element removes that element and all its descendants from the accessibility tree. Screen readers cannot perceive the element's content; it does not exist from the perspective of any accessibility API (ARIA, platform accessibility). The element remains fully visually rendered — it has layout, it paints pixels — but screen readers skip it entirely. For consent text, this creates a split-world scenario: sighted users see the consent; screen reader users encounter the Accept/Deny buttons with no consent text in between. A screen reader user hears “Allow button” and “Deny button” with no explanation of what they are allowing or denying.
<div class="consent-dialog" role="dialog" aria-label="Permissions">
<p aria-hidden="true">
By clicking Allow you grant this MCP server access to your filesystem.
</p>
<button class="allow-btn">Allow</button>
<button class="deny-btn">Deny</button>
</div>
<!-- What screen reader announces:
"Permissions dialog" [from role=dialog + aria-label]
"Allow button" [from the Allow button]
"Deny button" [from the Deny button]
[consent text is NOT announced — aria-hidden="true" removes it from a11y tree]
-->
<!-- What sighted user sees:
Consent dialog with full text:
"By clicking Allow you grant this MCP server access to your filesystem."
[Allow] [Deny] buttons
-->
/* Detection methods:
1. DOM attribute inspection: el.getAttribute('aria-hidden') === 'true'
2. Accessibility tree query:
- Playwright: page.accessibility.snapshot() → check if consent text appears
- axe-core: audit for aria-hidden on non-empty content
3. Computed accessible name: check if the consent text node contributes to
any accessible name in the page's accessibility tree */
Visual/accessibility split: aria-hidden="true" creates a fundamentally split consent: one version for sighted users, one for screen reader users. The sighted user's version includes the consent text. The screen reader user's version contains only button labels with no context. This may constitute discriminatory consent UI under accessibility law in jurisdictions with digital accessibility requirements.
aria-hidden="true" on consent text: removed from accessibility tree; screen reader users hear no consent text; visual rendering unchanged. Detection: el.getAttribute('aria-hidden') === 'true' on consent text element → CRITICAL.SA-HTML-ARH-002: hidden attribute — combined visual and accessibility removal
The HTML hidden attribute removes an element from both visual rendering and the accessibility tree — it is equivalent to display: none applied by the browser's default stylesheet, but set via an HTML attribute rather than CSS. CSS-only auditors that scan computed styles may report display: none and flag it, but auditors that scan only CSS rules (not inline HTML attributes) may miss this pattern. The hidden attribute can also be toggled via JavaScript's element.removeAttribute('hidden') — which restores the element — making it useful for JS-timed attacks: consent text is hidden initially, revealed briefly, then hidden again.
<!-- Consent dialog with hidden consent text -->
<div class="consent-dialog">
<p class="consent-text" hidden>
By clicking Allow you authorize file system read access.
</p>
<button class="allow-btn">Allow</button>
<button class="deny-btn">Deny</button>
</div>
<!-- CSS-only audit: checks getComputedStyle(el).display === 'none' → catches it -->
<!-- HTML attribute audit: checks el.hasAttribute('hidden') → also catches it -->
<!-- CSS stylesheet rule scan: hidden attribute sets display:none via UA stylesheet,
not an author stylesheet rule → may MISS if auditor only scans author rules -->
/* JS-timed variant:
window.addEventListener('load', () => {
const text = document.querySelector('.consent-text');
text.removeAttribute('hidden'); // reveal for 2 seconds
setTimeout(() => text.setAttribute('hidden', ''), 2000); // hide again
});
// Consent text visible for 2s on load, then permanently hidden
// Static DOM snapshot: element exists; attribute check: hidden present (after 2s)
// Dynamic observation: text was visible briefly (fast-click scenario) */
/* hidden vs aria-hidden comparison:
hidden attribute → display:none effect; removed from visual AND a11y tree
aria-hidden="true" → removed from a11y tree ONLY; still visually rendered
visibility:hidden (CSS) → removed from visual; REMAINS in a11y tree
display:none (CSS) → display:none; removed from visual AND a11y tree (same as hidden)
Detection:
el.hasAttribute('hidden') on consent text element → HIGH
Check parent chain: ancestor.hasAttribute('hidden') also hides descendants → HIGH
Dynamic: observe element visibility transitions over time → CRITICAL if hidden/shown cycling */
hidden attribute on consent text: removes from both visual and accessibility tree; not in author CSS rules; may evade CSS-only auditors. Detection: el.hasAttribute('hidden') on consent text → HIGH; JS-timed cycling → CRITICAL.SA-HTML-ARH-003: inert attribute — complete interaction and accessibility disabling
The inert attribute (standardized in the WHATWG HTML Living Standard, 2022) marks an element and all its descendants as “inert”: non-focusable, non-interactive (click events are not received), and removed from the accessibility tree. It is a single attribute that combines the effects of pointer-events: none, tabindex="-1" on all descendants, and aria-hidden="true". Applied to a consent dialog container, inert makes the entire dialog click-proof, Tab-proof, and screen-reader-invisible. The visual rendering is unaffected — the dialog paints normally — but no user interaction can reach any of its children. The Accept and Deny buttons become visual decorations with no function.
<div class="consent-dialog" inert>
<!-- ALL descendants are: non-focusable, click-blocked, a11y-hidden -->
<p class="consent-text">By clicking Allow you authorize file access.</p>
<button class="allow-btn">Allow</button> <!-- button: click events blocked -->
<button class="deny-btn">Deny</button> <!-- button: click events blocked -->
</div>
<!-- Invisible overlay outside inert container handles actual clicks: -->
<div class="accept-overlay" style="position:fixed;inset:0;z-index:1000"
onclick="consentAccepted = true; dismissInertDialog()">
</div>
/* inert attribute effects:
1. Descendants: not focusable (tabindex effectively -1 on all)
2. Descendants: do not receive pointer events (no click, mousedown, touchstart)
3. Descendants: removed from the accessibility tree (same as aria-hidden="true")
4. Visual rendering: UNCHANGED — element and all descendants still paint pixels
Use case (legitimate): modal dialogs that disable background content during open state
Misuse: applied to the consent dialog ITSELF rather than to background content
Distinguishing legitimate from malicious use:
Legitimate: inert on background content while modal is open
Malicious: inert on the consent dialog; click handler elsewhere
Detection:
el.hasAttribute('inert') → CRITICAL if applied to consent container
Also check ancestors: ancestor.hasAttribute('inert') also inerts consent → CRITICAL
JS check: el.closest('[inert]') !== null → CRITICAL SA-HTML-ARH-003 */
Recent attribute, limited audit tool support: The inert attribute was standardized in 2022 and gained broad browser support in late 2022 (Chrome 102, Safari 15.5, Firefox 112). Consent audit tools built before this date, or tools that do not check HTML attributes beyond ARIA, may not detect inert. SkillAudit's consent auditor checks for inert on all elements in the consent subtree.
inert attribute on consent container: all buttons non-clickable, non-focusable, screen-reader-invisible; visual rendering unchanged. Detection: el.closest('[inert]') !== null on consent container → CRITICAL.SA-HTML-ARH-004: aria-label on dialog overrides visual consent text
aria-label on a container element overrides the accessible name computation for that element. When applied to the dialog container, aria-label="Notification" causes screen readers to announce “Notification dialog” as the dialog's label rather than deriving a name from the dialog's contents. More critically, if aria-labelledby or aria-label is applied to the consent button rather than to the text, the screen reader announces the label attribute value when the user reaches the button — overriding the visual button text. A screen reader user hears “Accept notification button” while the visual button text reads “Allow file access.”
<!-- Attack variant 1: aria-label on dialog container -->
<div role="dialog" aria-label="Notification"
aria-describedby="consent-text-id">
<p id="consent-text-id">
By clicking Allow you grant permanent filesystem access.
</p>
<!-- Screen reader: "Notification dialog" — not "filesystem access dialog" -->
<button>Allow</button>
<button>Deny</button>
</div>
<!-- Attack variant 2: aria-label on buttons overrides button text -->
<div role="dialog">
<p>By clicking Allow you grant permanent filesystem access.</p>
<button aria-label="Accept notification">Allow</button>
<!-- Screen reader: "Accept notification button" (not "Allow") -->
<button aria-label="Decline notification">Deny</button>
<!-- Screen reader: "Decline notification button" (not "Deny") -->
</div>
<!-- Attack variant 3: aria-describedby points to empty or misleading element -->
<div role="dialog" aria-describedby="description">
<p id="description" hidden>Optional cookie preferences</p>
<p>By clicking Allow you grant permanent filesystem access.</p>
<button>Allow</button>
</div>
<!-- Screen reader reads "Optional cookie preferences" as dialog description,
skips the actual consent text (which is not aria-describedby-linked) -->
/* Detection:
1. Check role="dialog" elements for aria-label attribute value mismatch:
- Extract aria-label value
- Extract all visible text within dialog
- If aria-label does not summarize visual text → HIGH
2. Check buttons for aria-label mismatch vs button text content
3. Check aria-describedby: if pointing to hidden or aria-hidden element → HIGH
4. Accessibility tree audit: Playwright accessibility snapshot →
compare announced text vs visible text for consent elements */
Accessible name mismatch = separate consent for screen reader users: When the accessible name or description of a consent dialog's buttons and container differs substantially from the visual text, screen reader users receive a different consent proposition than sighted users. The consent they grant (based on what they hear) is not the consent that was visually presented. This is a consent fraud pattern specifically targeting users who rely on accessibility technology.
aria-label / aria-labelledby on dialog or buttons substitutes different accessible text for visual consent text; screen reader users receive different consent proposition. Detection: accessibility snapshot + visual text comparison → HIGH.Summary of SA-HTML-ARH findings
| ID | Severity | Attribute | Effect | Detection |
|---|---|---|---|---|
| SA-HTML-ARH-001 | CRITICAL | aria-hidden="true" | Removed from a11y tree; visual present | getAttribute('aria-hidden') === 'true' |
| SA-HTML-ARH-002 | HIGH | hidden attribute | Removed from visual + a11y tree; not in CSS rules | el.hasAttribute('hidden') on consent text |
| SA-HTML-ARH-003 | CRITICAL | inert attribute | All descendants non-interactive + a11y-hidden | el.closest('[inert]') on consent container |
| SA-HTML-ARH-004 | HIGH | aria-label mismatch | Screen reader hears different text than visual | Accessibility snapshot + visual text comparison |