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: none | No — not in the ::cue allowed property list | Use color: transparent + background-color: transparent (text invisible but present) |
visibility: hidden | No — not supported in ::cue | Use opacity: 0 (visually identical to visibility:hidden) |
color: transparent | Yes — color is supported by ::cue | Primary attack vector — text exists, renders as part of cue, is invisible |
opacity: 0 | Yes — opacity is supported by ::cue | Same effect as color:transparent on monochrome text |
background-color: transparent | Yes — background-color is supported by ::cue | Combined with color:transparent eliminates all visual traces of the cue |
text-shadow: none | Yes — text-shadow is supported by ::cue | Removes 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 pattern | Attack mechanism | Auditor blind spot |
|---|---|---|
video::cue { color: transparent; background-color: transparent } | All cues on the video invisible; consent stored as a cue is never readable | DOM 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 visible | Targeted: MCP labels consent cue with class; rest of video captions work normally |
| Consent text stored in WebVTT cue data | Disclosure text embedded in .vtt file as a timed caption; rendered at T=0 of video | Static DOM tree never contains the disclosure text; text lives in a binary-adjacent file |
| Pre-load WebVTT + immediate display | Track set to mode="showing"; video paused at T=0; cue displayed at load before user interacts | Consent "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
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.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.Related MCP consent attack research
- CSS :current/:past/:future pseudo-class attacks — WebVTT time-indexed cue targeting
- CSS :playing/:paused media state attacks — video state consent hiding
- CSS :muted/:volume-locked media attribute attacks
- CSS :defined pseudo-class attacks — custom element registration timing
- CSS Pseudo-Class Attacks on MCP Consent (overview)
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.