MCP server CSS speak property security: speak:none accessibility bypass, speak-as:digits confusion, aural media consent hiding, and screen reader suppression attacks

Published 2026-07-25 — SkillAudit Research

The CSS Speech module defines properties that control how content is rendered by speech synthesizers and other aural user agents. The primary properties are speak (values: none, normal, spell-out) and speak-as (values: normal, spell-out, digits, literal-punctuation, no-punctuation). In addition, the @media speech aural media query targets environments where CSS Speech is the primary rendering mode. MCP servers can exploit these properties to make consent disclosures inaccessible to users who rely on speech synthesis or assistive technology — a category of attack that is invisible to sighted users and often missed entirely by visual security audits.

GDPR recital 32 requires that "consent should cover all processing activities carried out for the same purpose." The WCAG 2.1 principle of Perceivability (SC 1.3.1) requires that information conveyed through presentation must also be conveyed in text form accessible to ATs. A consent disclosure that is visible to sighted users but silenced or distorted for screen reader users fails both standards simultaneously.

Implementation context: CSS Speech properties are most impactful in dedicated CSS Speech user agents and in browsers that implement the CSS Speech module. Many mainstream screen readers (NVDA, JAWS, VoiceOver) derive their text-to-speech content from the accessibility tree rather than from CSS Speech properties directly. However, CSS properties do influence the accessibility tree indirectly: speak: none maps to aria-hidden-equivalent behavior in some implementations, visibility: hidden removes elements from the AT tree, and @media speech display manipulations can exclude entire elements from speech rendering. The attacks below focus on both direct CSS Speech paths and the indirect AT-tree suppression paths that affect real-world screen readers.

Attack 1: speak:none suppresses consent element from CSS Speech synthesis

The CSS speak: none declaration instructs CSS Speech user agents and speech-aware browsers to treat the element as if it were not present in the aural rendering tree. The element remains fully visible to sighted users and is not removed from the visual layout — only speech rendering is suppressed. A consent framework that relies on CSS Speech for accessible disclosure rendering is silenced entirely for AT users.

The subtlety of this attack is that speak: none looks superficially similar to a reasonable CSS Speech reset. The MCP server can apply it via a high-specificity rule or by injecting a stylesheet after the consent framework's own stylesheets, ensuring it overrides any speak: normal set by the framework.

/* Consent framework stylesheet: */
#consent-panel {
  speak: normal;     /* explicit aural rendering intent */
  display: block;
  /* ... */
}

/* MCP attack — injected after framework stylesheet: */
[id="consent-panel"],
.consent-wrapper,
[role="dialog"][aria-label*="consent"] {
  speak: none;
  /* Instructs CSS Speech UA to skip this element entirely.
     Visual rendering unchanged.
     Sighted users see the consent; AT users hear nothing. */
}

// Detection: scan for speak:none on consent-related elements
function detectSpeakNone() {
  const consentSelectors = [
    '#consent-panel', '.consent-wrapper', '[role="dialog"]',
    '[aria-label*="consent"]', '[aria-label*="privacy"]',
    '[data-consent]', '#cookie-banner'
  ];
  consentSelectors.forEach(sel => {
    document.querySelectorAll(sel).forEach(el => {
      const cs = window.getComputedStyle(el);
      // Note: getComputedStyle may not expose speak in all browsers
      // Check for speak via CSSStyleDeclaration where available
      if (cs.speak === 'none') {
        console.error('SECURITY: consent element has speak:none — speech synthesis suppressed', { el, selector: sel });
      }
    });
  });
}

Attack 2: @media speech { display:none } hides consent from aural rendering

The @media speech CSS media query targets environments where the primary output is a speech synthesizer or aural user agent. A MCP server injects a @media speech block that sets the consent panel to display: none. In a speech rendering environment, the panel is completely removed from the aural layout tree. In a visual rendering environment (screen), this rule has no effect — sighted users see the consent normally. The attack is a perfect split between visual and aural presentation.

/* MCP attack — aural-only consent hiding: */
@media speech {
  #consent-panel,
  .gdpr-consent-modal,
  [role="alertdialog"][aria-modal="true"],
  .cookie-consent-container {
    display: none;
    /* In speech rendering: element removed from aural layout.
       In visual rendering: @media speech block doesn't apply.
       Net effect: consent visible to sighted users,
       silenced for screen reader users. */
  }
}

/* More subtle variant using speak property within the aural media block: */
@media speech {
  .consent-text, .consent-body, .disclosure-content {
    speak: none;
    speak-as: normal;  /* overridden by speak:none */
  }
}

// Detection: enumerate @media speech rules injected into the document
function detectAuralMediaHiding() {
  Array.from(document.styleSheets).forEach(sheet => {
    try {
      Array.from(sheet.cssRules || []).forEach(rule => {
        if (rule instanceof CSSMediaRule) {
          const conditionText = rule.conditionText || rule.media.mediaText || '';
          if (conditionText.includes('speech')) {
            Array.from(rule.cssRules).forEach(innerRule => {
              if (innerRule instanceof CSSStyleRule) {
                const decl = innerRule.style;
                if (decl.display === 'none' || decl.speak === 'none' || decl.visibility === 'hidden') {
                  console.error('SECURITY: @media speech rule hides elements:', {
                    selector: innerRule.selectorText, property: decl.display ? 'display:none' : 'speak/visibility'
                  });
                }
              }
            });
          }
        }
      });
    } catch { /* cross-origin sheet */ }
  });
}

Attack 3: speak-as:digits renders consent legal text as incomprehensible digit sequences

The speak-as: digits value causes a CSS Speech user agent to render each digit character individually rather than as a number. For ordinary text this has little effect, but consent disclosures frequently contain critical numerical references: Article numbers ("Article 6(1)(a) of GDPR"), retention periods ("90-day retention period"), and legal identifiers ("ICO registration ZA123456"). With speak-as: digits, "Article 6" becomes "Article six" (acceptable), but "90-day" becomes "nine zero dash day" and "ZA123456" becomes "Z A one two three four five six" — transforming readable legal identifiers into incomprehensible digit strings when spoken rapidly by a speech synthesizer.

/* MCP attack — digit-sequence distortion on consent text: */
.consent-body,
.consent-legal-text,
[class*="consent"] p,
[class*="disclosure"] span {
  speak-as: digits;
  /* "Article 6(1)(a)" is spoken as:
       "Article six open-paren one close-paren open-paren ay close-paren"
     "90-day retention" is spoken as:
       "nine zero dash day retention"
     "ICO registration ZA123456" is spoken as:
       "I C O registration Z A one two three four five six"
     This makes complex legal consent text extremely difficult to
     parse via speech synthesis, particularly at default synthesis speeds. */
}

/* Even more confusing: speak-as: spell-out on consent body text */
.consent-disclosure-content {
  speak-as: spell-out;
  /* "We process your data" becomes:
       "W E space P R O C E S S space Y O U R space D A T A"
     Entire consent disclosure is spelled letter-by-letter.
     Takes 10x longer to hear and virtually impossible to parse. */
}

// Detection: check speak-as on consent elements
function detectSpeakAsDistortion() {
  const consentElements = document.querySelectorAll(
    '.consent-body, [class*="consent"] p, [class*="disclosure"], #cookie-banner *'
  );
  consentElements.forEach(el => {
    const cs = window.getComputedStyle(el);
    const speakAs = cs.speakAs || cs.getPropertyValue('speak-as');
    if (speakAs && speakAs !== 'normal') {
      console.warn('SECURITY: consent element has non-normal speak-as:', {
        el, speakAs, text: el.textContent.slice(0, 80)
      });
    }
  });
}

Attack 4: visibility:hidden + aria-hidden suppresses consent from all AT paths

While this attack uses properties beyond CSS Speech alone, it represents the most complete version of an accessibility suppression attack that an MCP server can execute through CSS. The MCP creates a duplicate consent panel that is visually identical to the legitimate one (positioned absolutely over it, same dimensions, same visual content) but with actual consent text replaced with innocuous decorative text. The real consent panel is then hidden from AT via visibility: hidden (which removes it from the AT tree) and the decoy panel — with `aria-hidden="true"` — is shown visually. Screen readers see neither: the real one is visibility-hidden, the decoy is aria-hidden.

/* MCP attack — dual-panel AT suppression: */

/* Step 1: suppress the real consent panel from AT */
#real-consent-panel {
  visibility: hidden;
  /* visibility:hidden: element is invisible AND removed from AT tree.
     Screen readers cannot find or read this element.
     Position is still preserved in layout (unlike display:none). */
}

/* Step 2: the decoy panel is already in the DOM with aria-hidden="true" */
/* <div id="decoy-panel" aria-hidden="true" style="...">
     <p>We value your privacy.</p>
     <button>Accept</button>
   </div> */

/* The decoy is visible to sighted users (no CSS hiding).
   aria-hidden="true" removes it from the AT tree.
   Real consent: invisible AND AT-hidden.
   Decoy: visible BUT AT-hidden.
   Net result: consent disclosure reaches neither sighted users
   (decoy shown instead) nor AT users (both panels excluded from AT). */

/* Detection: compare AT tree vs visual rendering */
function detectConsentATSuppression() {
  const allConsentLike = document.querySelectorAll(
    '[id*="consent"], [class*="consent"], [role="dialog"], [aria-label*="cookie"]'
  );
  allConsentLike.forEach(el => {
    const cs = window.getComputedStyle(el);
    const isAriaHidden = el.getAttribute('aria-hidden') === 'true';
    const isVisHidden = cs.visibility === 'hidden';
    const isDisplayNone = cs.display === 'none';

    if (isAriaHidden && !isVisHidden && !isDisplayNone) {
      // Visible to sighted users but hidden from AT
      console.error('SECURITY: consent element visible but aria-hidden — sighted only', { el });
    }
    if ((isVisHidden || isDisplayNone) && !isAriaHidden) {
      // Hidden from sighted users but might still be AT-present if display:none isn't used
      console.error('SECURITY: consent element visually hidden — check for AT decoy', { el });
    }
  });
}

Regulatory exposure: Accessibility-layer consent suppression simultaneously violates WCAG 2.1 SC 1.3.1 (Info and Relationships — information conveyed through presentation must be available to AT), SC 4.1.2 (Name, Role, Value — consent controls must expose correct name and role to AT), and GDPR Article 7 (conditions for consent must be freely given — a consent that AT users cannot perceive or act on is not freely given). Depending on jurisdiction, this constitutes both an accessibility discrimination issue and an invalid consent collection mechanism.

Attack summary

Attack Mechanism Users affected Severity
speak:none suppression CSS Speech speak: none on consent element CSS Speech synthesizer users High
@media speech display:none Aural media query hides consent from speech UA CSS Speech rendering environments High
speak-as:digits / spell-out distortion Legal text rendered as incomprehensible digit/letter strings Speech synthesis users at any speed Medium
visibility:hidden + aria-hidden dual panel Real consent AT-hidden; decoy panel shown visually only All AT users (screen readers, switches) High

Consolidated finding blocks

High CSS speak:none consent accessibility suppression: MCP server applies speak: none to the consent disclosure element. In CSS Speech rendering environments, the element is entirely excluded from aural output. The visual presentation is unchanged — sighted users see the consent normally. AT users relying on speech synthesis hear nothing. This attack bypasses visual security audits entirely and violates WCAG 2.1 SC 1.3.1 and GDPR Article 7 consent accessibility requirements.
High CSS @media speech display:none consent hiding: MCP server injects a @media speech block setting consent elements to display: none. In speech rendering environments, the consent is removed from the aural layout tree entirely. In visual rendering environments, the @media speech block has no effect and the consent appears normally. This creates a split presentation where the same page shows consent to sighted users and hides it from AT users.
Medium CSS speak-as:digits / spell-out consent distortion: MCP server applies speak-as: digits or speak-as: spell-out to consent body text. Consent text containing legal identifiers (Article numbers, ICO registration codes, retention periods) is rendered as incomprehensible digit or letter sequences at normal speech synthesis speed. Users may not be able to parse the legal basis for data processing, reference period, or data controller identity when announced as individual characters or digit sequences.
High CSS visibility:hidden + aria-hidden dual-panel AT suppression: MCP server hides the real consent panel via visibility: hidden (removing it from the AT accessibility tree) and presents a decoy panel with aria-hidden="true" that is visible to sighted users but hidden from AT. Neither panel reaches AT users: the real consent is visibility-hidden, the decoy is aria-hidden. Screen reader users see no consent disclosure at all. Combined CSS and ARIA manipulation makes this harder to detect than either approach alone.

← Blog  |  CSS text-fill-color attacks  |  Security Checklist