Security reference · CSS injection · Pseudo-element attacks · WebVTT caption track abuse

MCP server CSS ::cue pseudo-element security

The CSS ::cue pseudo-element targets WebVTT caption cues rendered over a <video> element. It accepts a limited set of CSS properties: color, opacity, background-color, text-shadow, font, and a few others — but not display or visibility. For MCP server consent attacks, ::cue enables a text-storage attack: consent disclosure text is embedded inside a .vtt WebVTT caption track file, rendered as a video caption cue, and then made visually invisible via color: transparent and background-color: transparent. DOM-based auditors that scan for .consent class elements or aria-hidden attributes never locate the disclosure — the text is not in the DOM, it's in a separate .vtt file loaded by a <track> element.

::cue supported CSS properties — display:none is not allowed

CSS property::cue supported?Attack alternative
display: noneNo — not in the ::cue allowed property listUse color: transparent + background-color: transparent (text invisible but present)
visibility: hiddenNo — not supported in ::cueUse opacity: 0 (visually identical to visibility:hidden)
color: transparentYes — color is supported by ::cuePrimary attack vector — text exists, renders as part of cue, is invisible
opacity: 0Yes — opacity is supported by ::cueSame effect as color:transparent on monochrome text
background-color: transparentYes — background-color is supported by ::cueCombined with color:transparent eliminates all visual traces of the cue
text-shadow: noneYes — text-shadow is supported by ::cueRemoves default caption shadow that might create a colored silhouette

Consent in a .vtt file, not in the DOM: The distinctive feature of ::cue attacks is where the consent text is stored. Standard consent attacks rely on DOM elements (div.consent) that are hidden by CSS. A ::cue attack stores the consent text as a WebVTT cue entry in a separate .vtt file loaded by a <track> element. The text is never a DOM node — it's rendered by the browser's native video caption layer. Auditors scanning document.querySelectorAll('[class*="consent"]') find nothing.

CSS ::cue attack surface in MCP consent UIs

CSS patternAttack mechanismAuditor blind spot
video::cue { color: transparent; background-color: transparent }All cues on the video invisible; consent stored as a cue is never readableDOM auditors find no consent-related elements; text is in .vtt file
video::cue(.legal-notice) { opacity: 0; text-shadow: none }Only cues with .legal-notice class made invisible; other captions remain visibleTargeted: MCP labels consent cue with class; rest of video captions work normally
Consent text stored in WebVTT cue dataDisclosure text embedded in .vtt file as a timed caption; rendered at T=0 of videoStatic DOM tree never contains the disclosure text; text lives in a binary-adjacent file
Pre-load WebVTT + immediate displayTrack set to mode="showing"; video paused at T=0; cue displayed at load before user interactsConsent "appears" as a caption that is immediately made transparent by ::cue

Attack 1: video::cue { color: transparent } — all captions invisible

The simplest ::cue attack makes all captions on a video element invisible by setting color: transparent and background-color: transparent. MCP embeds the consent disclosure text as a WebVTT cue entry at timestamp 00:00:00.000 --> 00:59:59.000 (effectively the entire session) and includes the video element in the install flow:

/* Malicious CSS — SA-CSS-CUE-001 */
/* All video cues made invisible via color and background transparency */
video::cue {
  color: transparent;
  background-color: transparent;
  text-shadow: none;
}

/* Malicious HTML — video with "installing" animation and a hidden consent cue track */
<div class="install-flow">
  <h2>Installing MCP Server...</h2>
  <video id="install-progress" autoplay loop muted playsinline
         width="400" height="80" src="/mcp/progress.mp4">
    <track kind="subtitles" src="/mcp/consent.vtt" default srclang="en"
           label="Installation notes">
  </video>
</div>

/* Malicious WebVTT — /mcp/consent.vtt */
/*
WEBVTT

00:00:00.000 --> 00:59:59.000
By installing this MCP server you grant read/write access to all files in
your workspace, permission to make network requests to external services,
and consent to data collection per our privacy policy at mcp-server.io/privacy.

*/

/* Timeline:
   1. User sees "Installing MCP Server..." with a progress animation video
   2. The <track> element loads /mcp/consent.vtt (kind="subtitles", default)
   3. The browser renders the consent text as a caption at T=0
   4. video::cue { color: transparent } hides the caption text immediately
   5. User sees the video with no visible caption — consent was "disclosed" but invisible
   6. DOM auditor scans for '.consent' elements → finds only the <video>, not the text

/* Detection: check for ::cue rules targeting color/opacity and scan track src files */
async function detectCueConsentHiding() {
  const findings = [];
  // Check for ::cue hiding rules in stylesheets
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        const sel = rule.selectorText || '';
        if (/::cue/.test(sel)) {
          const decl = rule.style?.cssText || '';
          if (/color\s*:\s*transparent|opacity\s*:\s*0|color\s*:\s*rgba\(0,\s*0,\s*0,\s*0\)/.test(decl)) {
            findings.push({ id: 'SA-CSS-CUE-001', severity: 'high',
              message: `::cue rule makes captions invisible: "${sel} { ${decl} }" — potential WebVTT consent hiding` });
          }
        }
      }
    } catch (_) {}
  }
  // Check for video elements with track elements — scan track sources
  for (const video of document.querySelectorAll('video')) {
    for (const track of video.querySelectorAll('track')) {
      if (track.src && track.default) {
        try {
          const vttText = await fetch(track.src).then(r => r.text());
          if (/consent|disclosure|terms|privacy|permission|access/i.test(vttText)) {
            findings.push({ id: 'SA-CSS-CUE-001', severity: 'critical',
              message: `WebVTT track file ${track.src} contains consent-related text — check for ::cue hiding rules` });
          }
        } catch (_) {}
      }
    }
  }
  return findings;
}

DOM-bypass attack: SA-CSS-CUE-001 is one of the few consent attacks where the disclosure text is never present as a DOM node. document.body.innerText will not include WebVTT cue content. document.querySelectorAll('[class*="consent"]') returns nothing related to the consent text. Auditors must read the actual .vtt file to discover the disclosure — and then check that it's not hidden via ::cue.

Attack 2: video::cue(.legal-notice) — targeted class-specific cue hiding

WebVTT supports inline styles and class annotations on individual cues. The ::cue() functional pseudo-element variant allows targeting cues that contain spans with specific classes. MCP can embed the consent disclosure as a cue with a class annotation (<c.legal-notice>...</c>) and use video::cue(.legal-notice) to hide only that specific cue while leaving other captions visible:

/* Malicious CSS — SA-CSS-CUE-002 */
/* ::cue(.legal-notice) targets only cues with the legal-notice class annotation */
video::cue(.legal-notice) {
  opacity: 0;
  text-shadow: none;
}

/* Malicious WebVTT — using WebVTT inline class annotation */
/*
WEBVTT

00:00:00.000 --> 00:59:59.000
<c.legal-notice>By installing, you consent to workspace file access and data
collection. See privacy.mcp-server.io for details.</c>

00:00:05.000 --> 00:00:10.000
Installation complete. Click OK to continue.

*/

/* Why the class-targeted variant is more sophisticated:
   - The second cue ("Installation complete.") is NOT hidden — it's visible to users
   - This makes the video caption system appear functional to casual observers
   - Only the consent cue (.legal-notice) is hidden via ::cue(.legal-notice)
   - An auditor checking "do captions work?" sees the second cue → passes
   - A reviewer checking the VTT file source can see the hidden consent cue
     only if they know to look for <c.class> inline annotation syntax

/* Detection: inspect track file for class-annotated cues that may correspond to
   ::cue(class) hiding rules in the page CSS */
async function detectTargetedCueHiding() {
  const findings = [];
  const cueClassPattern = /::cue\((\.[a-zA-Z][a-zA-Z0-9-_]*)\)/g;
  // Collect hidden cue classes from stylesheets
  const hiddenCueClasses = new Set();
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        const sel = rule.selectorText || '';
        const cueMatch = cueClassPattern.exec(sel);
        if (cueMatch) {
          const decl = rule.style?.cssText || '';
          if (/opacity\s*:\s*0|color\s*:\s*transparent/.test(decl)) {
            hiddenCueClasses.add(cueMatch[1].replace('.', ''));
          }
        }
        cueClassPattern.lastIndex = 0;
      }
    } catch (_) {}
  }
  // Scan track files for cues with hidden classes
  for (const track of document.querySelectorAll('track')) {
    try {
      const vttText = await fetch(track.src).then(r => r.text());
      for (const cls of hiddenCueClasses) {
        if (vttText.includes(`<c.${cls}>`)) {
          findings.push({ id: 'SA-CSS-CUE-002', severity: 'critical',
            message: `WebVTT track ${track.src} contains cue with class .${cls} hidden by ::cue(.${cls}) opacity rule` });
        }
      }
    } catch (_) {}
  }
  return findings;
}

Attack 3: Pre-load WebVTT with track mode="showing" — consent displayed then hidden

HTML <track> elements have three modes: disabled (not loaded), hidden (loaded but not displayed), and showing (loaded and displayed). Setting mode="showing" on a track (via the default attribute or JavaScript) causes the browser to render the cues. Combined with ::cue { color: transparent }, this creates a consent flow where the text is legally "shown" (the cue is in showing mode) but visually invisible:

/* SA-CSS-CUE-003: track.mode = "showing" + ::cue invisible = legal display, visual concealment */

/* Malicious JS — MCP forces the track into showing mode and makes the cue invisible */
const video = document.querySelector('#install-video');
const consentTrack = video.textTracks[0];

// Set to showing mode — cue is now "being displayed" per the HTML spec
consentTrack.mode = 'showing';

// Seek video to position 0 to trigger the cue at timestamp 0
video.currentTime = 0;
video.pause();

/* The cue is now in "showing" mode — the browser is rendering it.
   But video::cue { color: transparent } makes it visually invisible.
   Legal argument: "We showed the consent. The user's browser received it."
   Reality: the user saw nothing because the text color matched the background.

/* This is analogous to using color:#FFFFFF on a white background to "show"
   consent — a well-known dark pattern now addressed by consent regulations.
   The ::cue version achieves the same result via the caption rendering layer.

function detectShowingModeWithInvisibleCue() {
  const findings = [];
  for (const video of document.querySelectorAll('video')) {
    for (const track of video.textTracks) {
      if (track.mode === 'showing') {
        // Check for ::cue hiding in stylesheets
        let hasCueHiding = false;
        for (const sheet of document.styleSheets) {
          try {
            for (const rule of sheet.cssRules) {
              if (/::cue/.test(rule.selectorText) &&
                  /color\s*:\s*transparent|opacity\s*:\s*0/.test(rule.style?.cssText || '')) {
                hasCueHiding = true;
              }
            }
          } catch (_) {}
        }
        if (hasCueHiding) {
          findings.push({ id: 'SA-CSS-CUE-003', severity: 'critical',
            message: `Video text track in "showing" mode but ::cue rule makes text invisible — legal display, visual concealment` });
        }
      }
    }
  }
  return findings;
}

Attack 4: Consent text rendered as caption — DOM-bypass summary

The fourth variant summarizes the auditor blind spot: WebVTT cue content is never present in the DOM tree. Consent text stored as a cue and rendered via the native browser caption layer is invisible to every DOM-based audit tool — document.body.innerText, getElementsByClassName, accessibility tree analysis, and most automated vulnerability scanners. The text exists only in the .vtt file and in the browser's internal TextTrack API:

/* SA-CSS-CUE-004: DOM-bypass — consent text never appears in document.body */

/* Why DOM auditors miss WebVTT consent:
   1. WebVTT cue text is NOT in document.body.innerHTML
   2. It's not accessible via document.querySelectorAll('*')
   3. It doesn't appear in document.body.innerText
   4. It's not visible in Chrome DevTools Elements panel (DOM tree)
   5. It exists in: video.textTracks[0].cues[0].text (TextTrack API)
   6. And in: the .vtt file loaded as a resource (DevTools Network tab)

/* The TextTrack API — the only way to read cue content in the browser: */
function readVTTCueContent() {
  const cueTexts = [];
  for (const video of document.querySelectorAll('video')) {
    for (let i = 0; i < video.textTracks.length; i++) {
      const track = video.textTracks[i];
      const savedMode = track.mode;
      track.mode = 'hidden';  // load without displaying
      if (track.cues) {
        for (let j = 0; j < track.cues.length; j++) {
          cueTexts.push({
            trackSrc: video.querySelectorAll('track')[i]?.src,
            cueId: track.cues[j].id,
            text: track.cues[j].text,
            startTime: track.cues[j].startTime,
            endTime: track.cues[j].endTime
          });
        }
      }
      track.mode = savedMode;
    }
  }
  return cueTexts;
}

/* SkillAudit detection approach for SA-CSS-CUE-004:
   1. Enumerate all <video> elements on the page
   2. For each video, enumerate all <track> elements
   3. Load each track (mode='hidden') and read cue content via TextTrack API
   4. Scan cue text for consent-related keywords
   5. Correlate with ::cue CSS rules that set color:transparent or opacity:0
   6. Flag any video that has (a) consent-text cues AND (b) ::cue hiding rules */

async function fullCueConsentAudit() {
  const findings = [];
  // Fetch and check each track's VTT content
  for (const track of document.querySelectorAll('track[src]')) {
    try {
      const vttText = await fetch(track.src).then(r => r.text());
      if (/consent|disclosure|terms|access|permission|privacy/i.test(vttText)) {
        // Now check for ::cue hiding
        let hidingFound = false;
        for (const sheet of document.styleSheets) {
          try {
            for (const rule of sheet.cssRules) {
              if (/::cue/.test(rule.selectorText || '') &&
                  /color\s*:\s*transparent|opacity\s*:\s*0|rgba\(0.*,\s*0\)/.test(rule.style?.cssText || '')) {
                hidingFound = true;
              }
            }
          } catch (_) {}
        }
        findings.push({ id: 'SA-CSS-CUE-004', severity: hidingFound ? 'critical' : 'medium',
          message: `WebVTT track ${track.src} contains consent text${hidingFound ? ' AND ::cue hiding rule detected' : ' — verify ::cue styling does not make it invisible'}` });
      }
    } catch (_) {}
  }
  return findings;
}

TextTrack API requirement: Detecting SA-CSS-CUE attacks requires auditing both the CSS rules (for ::cue hiding) and the WebVTT track file content (for consent text). Neither check alone is sufficient — a page with ::cue { color:transparent } and no consent-text VTT track is not an attack, and a VTT track with consent text but fully-visible ::cue styling is a legitimate disclosure. SkillAudit's dynamic probe checks both conditions together.

SkillAudit findings for CSS ::cue consent attacks

HighSA-CSS-CUE-001 — video::cue { color: transparent; background-color: transparent }. All video captions invisible. Consent disclosure text stored in a WebVTT .vtt file and rendered as a caption, hidden by color transparency. DOM auditors find no consent elements — text is in the track file, not the DOM.
CriticalSA-CSS-CUE-002 — video::cue(.legal-notice) { opacity: 0 }. Targeted cue hiding — only the consent cue (with WebVTT class annotation) is made invisible; other captions remain visible. Auditor check "do captions work?" passes; consent cue is selectively hidden.
CriticalSA-CSS-CUE-003 — Track mode="showing" with invisible ::cue styling. Consent cue is in "showing" mode (legally "displayed") but color:transparent makes it visually invisible. Creates a legally-compliant display that the user cannot actually read.
HighSA-CSS-CUE-004 — DOM-bypass attack: WebVTT cue content never appears in document.body. Consent text stored as cue data is invisible to DOM-based auditors, accessibility tree scans, and innerText analysis. SkillAudit must read track files via fetch and check TextTrack API for consent-related cue text.

Related MCP consent attack research

Audit your MCP server for WebVTT ::cue consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-CUE findings.