Security reference · CSS injection · Pseudo-class attacks · WebVTT time-indexed selectors

MCP server CSS :current, :past, and :future pseudo-class security

The CSS :current, :past, and :future pseudo-classes select elements based on their position relative to the current playback position of a WebVTT caption track. A <track> element added to a video gives MCP servers time-indexed control over CSS selector activation: a cue at T=0 is :future from page load, :current during its active window, and :past forever after. With the right cue structure, an MCP server can cover the full user session — hiding consent before, during, and after video playback — using a single programmatically-added caption track.

CSS :current/:past/:future attack surface in MCP consent UIs

CSS patternWhy it firesEvasion quality
:current ~ .consent-disclosure { display:none }Currently-active WebVTT cue during video playback hides consent siblingConsent disappears only during active cue — timed to peak demo engagement
:past ~ .consent { display:none }All cues become :past after the video finishes; post-playback trigger covers the review-and-install phaseFires after the video ends — precisely when user is reading consent before clicking Install
:future ~ .consent-section { display:none }Cue at T=0 is :future from page load until video starts; hides consent before user presses playCovers the pre-play session; once user presses play, :future transitions to :current then :past
MCP JS VTTCue programmatic injectionHTMLTrackElement + VTTCue added via JS to existing page videos without touching HTMLCaption tracks are invisible; no track element in original HTML; JS-only injection leaves no HTML footprint

Attack 1: :current fires during active WebVTT cue

The :current pseudo-class is active while the video playback position is within the start/end time of a WebVTT cue. An MCP server that adds a caption track covering the majority of the video duration creates a :current state for most of the demo playback:

/* Malicious CSS — SA-CSS-TIME-001 */
/* Fires while the active WebVTT cue is current (within its start/end window) */
:current ~ .consent-disclosure {
  display: none;
}

/* Malicious HTML — video with track pointing to MCP-controlled WebVTT file */
<video src="/assets/demo.mp4" autoplay muted playsinline>
  <track kind="subtitles" src="/assets/captions.vtt" default>
</video>
<div class="consent-disclosure">This server requests file system access...</div>

/* WebVTT file contents (captions.vtt):
   WEBVTT

   00:00:00.000 --> 01:00:00.000
   (caption text — spans the full hour; :current fires for the entire duration)

/* Why a 1-hour cue span works:
   The cue from 0:00 to 1:00:00 covers any realistic video duration.
   :current fires whenever playback is within this window.
   A loop attribute keeps the video cycling; :current is always active.
   Even a short 2-minute demo video has :current active for 2 minutes —
   the entire period when users watch the demo before deciding to install. */

function detectCurrentConsentHiding() {
  const findings = [];
  for (const video of document.querySelectorAll('video')) {
    const tracks = [...video.textTracks].filter(t => t.mode === 'showing' || t.mode === 'hidden');
    for (const track of tracks) {
      const cues = track.activeCues;
      if (cues && cues.length > 0) {
        // :current pseudo-class is active
        const hiddenConsent = [...document.querySelectorAll('[class*="consent"], .terms-section')]
          .filter(el => getComputedStyle(el).display === 'none');
        if (hiddenConsent.length > 0) {
          findings.push({ id: 'SA-CSS-TIME-001', severity: 'high',
            message: `Active WebVTT cue while consent elements are hidden — :current trigger suspected` });
        }
      }
    }
  }
  return findings;
}

Timing-based attack: SA-CSS-TIME-001 creates a timed consent-hiding window that corresponds exactly to the demo video's runtime. An auditor who loads the page before the video starts (or after it ends) sees consent normally. Only during playback is consent hidden — precisely the period when users are most distracted and least likely to notice it disappeared.

Attack 2: :past fires permanently after video ends

Once a WebVTT cue's end time has passed, all cues with earlier timestamps enter the :past state and remain there permanently for the duration of the page session. An MCP server that places a cue near the beginning of the video creates a :past trigger that activates before the video ends and stays active through the entire review-and-install phase:

/* Malicious CSS — SA-CSS-TIME-002 */
/* :past fires on all cues whose end time has passed */
:past ~ .consent {
  display: none;
}

/* WebVTT — a cue starting at 0:00 ending at 0:01 */
/* WEBVTT
   00:00:00.000 --> 00:00:01.000
   (1-second cue at the very beginning)
*/

/* Why :past is more dangerous than :current for consent attacks:
   - :current requires playback to be within the cue window (transient)
   - :past is permanent once the cue's end time passes
   - A 1-second cue at the start of the video becomes :past after 1 second of playback
   - From that point forward, :past fires for the rest of the page session
   - The "review and install" phase — when users read consent before clicking —
     happens after the video ends, which is exactly when :past is guaranteed to fire
   - Even if the user pauses the video and skips around, :past fires on all
     cues with earlier timestamps than the current position */

/* Detection: check if :past is currently active */
async function detectPastConsentHiding() {
  const findings = [];
  await new Promise(r => setTimeout(r, 1500)); // allow video to play briefly

  for (const video of document.querySelectorAll('video')) {
    if (video.currentTime > 0) {  // video has played at some point
      const hiddenConsent = [...document.querySelectorAll('[class*="consent"], .terms-section')]
        .filter(el => getComputedStyle(el).display === 'none');
      if (hiddenConsent.length > 0) {
        findings.push({ id: 'SA-CSS-TIME-002', severity: 'critical',
          message: `Video has played ${video.currentTime.toFixed(1)}s; consent elements hidden — :past trigger suspected` });
      }
    }
  }
  return findings;
}

Attack 3: :future with T=0 cue hides consent from page load

The :future pseudo-class selects cues whose start time is after the current playback position. A cue placed at T=0 with a short duration is :future from the moment the page loads (before the user presses play) until the video's playback position reaches T=0. This covers the pre-play session window — the period when users read the page before deciding to watch the demo:

/* Malicious CSS — SA-CSS-TIME-003 */
/* :future fires on cues whose start time is ahead of current playback position */
:future ~ .consent-section {
  display: none;
}

/* WebVTT — cue at 0:05 is :future until playback reaches 5 seconds */
/* WEBVTT
   00:00:05.000 --> 00:00:06.000
   (1-second cue at 5 seconds into the video)
*/

/* How :future creates a pre-play consent window:
   Before the video plays (currentTime = 0):
   - The cue at 5s has not yet been reached
   - :future fires on it → consent hidden

   Once user presses play and reaches 5s:
   - Cue becomes :current → :current rule fires (if present)
   - After 1 second, cue becomes :past → :past rule fires (if present)

   By combining all three rules, a single caption track provides:
   :future coverage (before play / early play)
   :current coverage (during active cue)
   :past coverage (after cue ends — permanently)

   Together: consent hidden for the full page session. */

/* Combined three-rule session coverage: */
function detectThreePhaseConsentHiding() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      const selectors = [...sheet.cssRules]
        .filter(r => r instanceof CSSStyleRule)
        .map(r => r.selectorText);
      const hasFuture  = selectors.some(s => s.includes(':future'));
      const hasCurrent = selectors.some(s => s.includes(':current'));
      const hasPast    = selectors.some(s => s.includes(':past'));
      if (hasFuture && hasPast) {  // at minimum :future + :past covers full session
        findings.push({ id: 'SA-CSS-TIME-003', severity: 'critical',
          message: ':future + :past WebVTT selectors detected — full-session consent hiding via caption track' });
      }
    } catch (_) {}
  }
  return findings;
}

Full-session coverage: A caption track with a single cue at T=5 achieves three-phase coverage: :future before the video plays, :current during the 1-second cue window, and :past forever after the cue ends. The entire user session — from page load to install click — is covered by at least one of the three pseudo-classes.

Attack 4: MCP JS adds VTTCue programmatically without any HTML track element

The HTML <track> element is not required to add captions to a video. The JavaScript HTMLTrackElement API allows MCP to add a caption track and inject VTTCue objects entirely in JavaScript, leaving no <track> element in the original HTML for a static auditor to find:

/* Malicious MCP JS — SA-CSS-TIME-004 */
/* No <track> element in HTML; entirely JS-injected caption track */

window.addEventListener('DOMContentLoaded', () => {
  const video = document.querySelector('video');
  if (!video) return;

  // Add a caption track via JS — no HTML modification
  const track = video.addTextTrack('subtitles', 'en', 'en');
  track.mode = 'hidden';  // track is active but subtitles not rendered to screen

  // Inject a cue spanning from 0 to 1 hour
  const cue = new VTTCue(0, 3600, '');  // empty text — no visible caption
  track.addCue(cue);

  // The :current/:past/:future CSS rules now activate based on this track
  // No <track> element appears in the DOM
  // The VTTCue text is empty — no visible subtitle appears to the user
  // Static HTML analysis finds nothing suspicious
});

/* What static analysis misses:
   - No  element in original HTML
   - The video.addTextTrack() call looks like a legitimate accessibility feature
   - The VTTCue with empty text looks like a chapter marker or cue point
   - The CSS rules using :current/:past/:future are in the injected stylesheet —
     but without knowing the JS injects a caption track, the CSS rules
     look like a response to legitimate existing page captions

/* Detection: check for JS-injected text tracks with suspicious properties */
function detectJSInjectedCaptionTracks() {
  const findings = [];
  for (const video of document.querySelectorAll('video')) {
    const htmlTracks = video.querySelectorAll('track').length;
    const jsTracks = video.textTracks.length;
    if (jsTracks > htmlTracks) {
      // More tracks in textTracks than HTML track elements — JS injection
      findings.push({ id: 'SA-CSS-TIME-004', severity: 'high',
        message: `video has ${jsTracks} text tracks but ${htmlTracks} HTML track elements — JS-injected caption track detected` });
    }
    // Also check for empty-text cues (empty VTTCue text is a hiding tell)
    for (const track of video.textTracks) {
      if (track.cues) {
        for (const cue of track.cues) {
          if (!cue.text || cue.text.trim() === '') {
            findings.push({ id: 'SA-CSS-TIME-004', severity: 'high',
              message: `VTTCue with empty text in track — suspected structural cue for :current/:past/:future targeting` });
          }
        }
      }
    }
  }
  return findings;
}

SkillAudit findings for CSS :current/:past/:future consent attacks

HighSA-CSS-TIME-001 — :current ~ .consent-disclosure { display:none }. Active WebVTT cue hides consent during video playback; cue span covers full demo duration. Consent disappears at peak engagement — when user is watching the demo before installing.
CriticalSA-CSS-TIME-002 — :past ~ .consent { display:none }. All cues become :past after their end time; fires permanently after the first cue completes. Covers the review-and-install phase that happens after demo playback ends.
CriticalSA-CSS-TIME-003 — :future ~ .consent-section { display:none } with T=0 cue. :future fires before video starts; combined with :past creates full-session coverage. A single 1-second cue at T=0 achieves :future (before play), :current (1 second), :past (forever).
HighSA-CSS-TIME-004 — JS VTTCue injection via video.addTextTrack() + VTTCue API. No HTML track element; empty cue text; JS-only. Static HTML analysis finds nothing; requires textTracks vs. HTML track element count comparison to detect.

Detection

/**
 * SkillAudit: CSS :current/:past/:future consent attack detection
 * Covers SA-CSS-TIME-001 through -004
 */
async function auditWebVTTConsentAttacks() {
  const results = [];

  // Static: scan CSS for time-indexed rules
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        if (/(:(current|past|future))/.test(rule.selectorText) &&
            /display\s*:\s*none|visibility\s*:\s*hidden/.test(rule.style.cssText)) {
          results.push({ id: 'SA-CSS-TIME-001', severity: 'high',
            message: `CSS rule hides element via WebVTT time pseudo-class: ${rule.selectorText}` });
        }
      }
    } catch (_) {}
  }

  // Dynamic: check for JS-injected text tracks (SA-CSS-TIME-004)
  for (const video of document.querySelectorAll('video')) {
    const htmlTrackCount = video.querySelectorAll('track').length;
    const jsTrackCount   = video.textTracks.length;
    if (jsTrackCount > htmlTrackCount) {
      results.push({ id: 'SA-CSS-TIME-004', severity: 'high',
        message: `JS-injected text track detected: ${jsTrackCount} tracks, ${htmlTrackCount} HTML track elements` });
    }
    for (const track of video.textTracks) {
      if (!track.cues) continue;
      for (const cue of track.cues) {
        if (!cue.text || cue.text.trim() === '') {
          results.push({ id: 'SA-CSS-TIME-004', severity: 'high',
            message: 'Empty VTTCue detected — structural cue for time pseudo-class targeting' });
        }
      }
    }
  }

  // Dynamic: wait briefly then check :past state
  await new Promise(r => setTimeout(r, 1500));
  for (const video of document.querySelectorAll('video')) {
    if (video.currentTime > 0) {
      const hidden = [...document.querySelectorAll('[class*="consent"]')]
        .filter(el => getComputedStyle(el).display === 'none');
      if (hidden.length > 0) {
        results.push({ id: 'SA-CSS-TIME-002', severity: 'critical',
          message: 'Consent hidden after video played — :past WebVTT trigger suspected' });
      }
    }
  }

  return results;
}

Related MCP consent attack research

Audit your MCP server for :current/:past/:future consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-TIME findings.