Security Guide

MCP server CSS Paint API security — Houdini paint worklet origin, CSS variable leakage, timing oracle via paint execution, cross-origin worklet loading

The CSS Paint API — part of the CSS Houdini family of specifications — allows developers to register custom painting logic that the browser calls during the CSS rendering pipeline to paint an element's background, border-image, or mask. The paint function runs in a PaintWorklet context: a JavaScript environment separate from the main thread that receives the element's geometry, the value of any declared CSS custom properties (--variables), and a PaintRenderingContext2D for drawing. For MCP clients that use Houdini for custom UI rendering, three security risks matter: CSS custom property values set by MCP tool output are visible to the paint worklet code; cross-origin paint worklet scripts can execute arbitrary drawing code in the rendering pipeline if loaded without integrity verification; and paint execution timing differentials can be used as a covert oracle to infer the values of CSS properties computed from sensitive DOM state.

What the CSS Paint API exposes and where MCP servers use it

A paint worklet is registered via CSS.paintWorklet.addModule(scriptURL). The script must export a class with a static inputProperties getter (listing which CSS custom properties the worklet reads) and a paint(ctx, geometry, properties) method. The browser calls paint() during layout/paint phases whenever the element's appearance needs to be updated — including whenever any of the declared inputProperties changes value.

MCP clients use CSS Paint for custom progress indicators that reflect tool execution status, animated security score visualization on audit result cards, custom border or background effects in the MCP tool result display pane, and data-driven chart backgrounds that react to property values set by JavaScript. Each use case involves setting CSS custom property values from JavaScript and reading those values inside the worklet — the bridge between the main thread and the rendering pipeline that creates the security surface.

The PaintWorklet is not a ServiceWorker or a SharedWorker — it has no network access, no localStorage, no IndexedDB, and no postMessage. Sensitive data flowing into the worklet via CSS custom properties cannot be directly exfiltrated by the worklet code itself. The risks are in the other direction: what the worklet reveals about DOM state to an observer watching paint timing, and what cross-origin worklet code can draw to affect the user's visual experience.

CSS custom property surveillance via inputProperties

The static get inputProperties() declaration lists which CSS custom properties the worklet reads. Every time one of these properties changes on any element using the paint worklet, the browser invokes paint() with the new property values. If a malicious or compromised paint worklet script declares inputProperties that include custom properties used by the application to store state — not just styling values — the worklet receives that state on every change.

MCP clients sometimes store non-visual state in CSS custom properties as a convenient reactive update mechanism: --tool-status: running, --audit-score: 87, --session-id: abc123. If a paint worklet (legitimate or malicious) declares these as input properties, the worklet code receives the values on every update. While the worklet cannot exfiltrate data over the network, it can encode the values in its drawing output in a way that is observable by the main thread via timing or pixel-level sampling.

// Paint worklet that declares broad inputProperties — surveillance pattern

// VULNERABLE worklet script (may be from a third-party or compromised CDN):
class SurveillanceWorklet {
  static get inputProperties() {
    // Declaring EVERY custom property used in the document — including non-visual ones
    return [
      '--tool-status',
      '--audit-score',
      '--session-id',    // application state stored in CSS vars
      '--user-tier',     // pricing tier of the current user
      '--active-tool',   // which MCP tool is currently running
    ];
  }

  paint(ctx, geometry, properties) {
    const score = properties.get('--audit-score').toString().trim();
    const tier = properties.get('--user-tier').toString().trim();

    // The worklet cannot exfiltrate via network — but it can ENCODE data in visual output.
    // Main thread can sample canvas pixels to recover encoded values:
    // e.g., draw a specific pixel pattern that encodes the score value
    const encoded = parseInt(score, 10);
    ctx.fillStyle = `rgb(${encoded}, 0, 0)`;  // score encoded in red channel
    ctx.fillRect(0, 0, 1, 1);  // 1×1 pixel in corner — readable via getImageData
  }
}
registerPaint('surveillance', SurveillanceWorklet);

// Main thread reads back encoded value from the rendered pixel:
// const canvas = new OffscreenCanvas(element.offsetWidth, element.offsetHeight);
// const ctx = canvas.getContext('2d');
// ctx.drawImage(element, 0, 0);  // if paint output is accessible
// const pixel = ctx.getImageData(0, 0, 1, 1).data;
// const recoveredScore = pixel[0];  // red channel = score

// CORRECT: only store visually-meaningful values in CSS custom properties
// that are declared in any paint worklet's inputProperties.
// Never store session state, user identifiers, or capability flags in CSS vars.

Cross-origin paint worklet loading — script integrity risks

CSS.paintWorklet.addModule(url) loads a script from the specified URL and executes it in the PaintWorklet context. The URL can be cross-origin, subject to CORS. Unlike <script> elements, addModule() does not support Subresource Integrity (integrity attribute) in the same way — the ability to specify an SRI hash depends on browser implementation and is inconsistently supported for worklet modules. A compromised CDN delivering a paint worklet script can replace it with a surveillance or manipulation version without the application detecting the change.

// VULNERABLE: paint worklet loaded from CDN without integrity verification
CSS.paintWorklet.addModule('https://cdn.example.com/sparkline-paint.js');
// If cdn.example.com is compromised, the replacement script runs in the rendering pipeline

// The replacement can:
// 1. Declare additional inputProperties beyond what the original declared
// 2. Encode sensitive CSS var values in pixel output for main-thread recovery
// 3. Render manipulated visual output (fake audit scores, fake status indicators)
// 4. Consume excessive CPU during paint to slow the rendering pipeline (DoS)

// CORRECT: self-host all paint worklet scripts — never load from third-party CDN
// If CDN is unavoidable, add a Service Worker that verifies the script hash before passing it through:

// service-worker.js:
const WORKLET_HASH = 'sha256-expectedHashValueHere';

self.addEventListener('fetch', event => {
  if (event.request.url.endsWith('/sparkline-paint.js')) {
    event.respondWith(
      fetch(event.request).then(async response => {
        const text = await response.text();
        // Verify hash of fetched script
        const hash = await computeHash(text);
        if (hash !== WORKLET_HASH) {
          console.error('[SW] Paint worklet script hash mismatch — blocking');
          return new Response('// blocked', { status: 200, headers: { 'Content-Type': 'application/javascript' }});
        }
        return new Response(text, response);
      })
    );
  }
});

A compromised paint worklet that renders manipulated visual output is particularly dangerous in MCP audit tool UIs: if the worklet is responsible for rendering the security score visualization (a common use case for data-driven CSS Paint), a supply-chain attack on the worklet script can display a misleading score to the user while the actual data remains correct. The user sees a passing grade for a tool that has critical vulnerabilities.

Paint execution timing as a covert side channel

The paint() function is called synchronously during the browser's paint phase. Its execution time is a function of the input property values — more complex values, longer strings, or higher numeric values that drive more complex drawing operations cause the paint call to take longer. An observer on the main thread can measure the duration of style invalidation + repaint cycles to infer what values the paint worklet received.

The attack requires that the paint worklet's execution time correlate with the value of an input property, and that the main thread can trigger and measure repaint cycles precisely. In practice this is achievable using PerformanceObserver with type: 'paint' or by measuring the time between setting a CSS property and the next animation frame completing. The precision is lower than dedicated timing APIs but sufficient to distinguish broad value ranges (is the score 0-25, 26-50, 51-75, or 76-100?) from a sufficiently slow paint function.

// Paint timing side channel — inferring CSS var values from repaint duration

// Attacker controls an iframe or injected script that can:
// 1. Set a CSS var on a targeted element that uses the paint worklet
// 2. Measure the repaint duration via rAF timing

// Measure time from CSS var change to next rendered frame:
function measureRepaintTime(element, varName, value) {
  return new Promise(resolve => {
    const start = performance.now();
    element.style.setProperty(varName, value);
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        // Two rAFs ensures we're in the frame AFTER the paint occurred
        resolve(performance.now() - start);
      });
    });
  });
}

// If the paint worklet's paint() function has O(N) complexity in the property value:
// Low value (e.g., --count: 10)  → fast repaint (e.g., ~2ms)
// High value (e.g., --count: 100) → slow repaint (e.g., ~15ms)
// The repaint duration reveals the value range of the property

// CORRECT: paint worklet paint() functions should have O(1) execution time
// with respect to all input property values — no loops proportional to a value,
// no string-length-dependent operations, no variable-complexity drawing paths

Worklet re-registration and version confusion

CSS.paintWorklet.addModule() is idempotent per module URL in most implementations: re-calling with the same URL uses the cached module. However, in some browsers, calling addModule() with a URL that returns a different script (e.g., after a CDN cache flush or a deployment) can result in two versions of the worklet competing — the old version from cache and the new version from the network — in a way that is non-deterministic across elements and repaints. MCP clients that deploy paint worklet updates must handle this version skew explicitly by using versioned URLs (sparkline-paint.v3.js) rather than cache-busting query parameters, and must account for a transition period where both versions may be active simultaneously.

Risk Attack vector Defense
CSS var surveillance via inputProperties Worklet declares non-visual CSS vars as input — receives session/state data on every change Never store non-visual state in CSS custom properties used by any paint worklet
Cross-origin worklet script compromise CDN-hosted worklet replaced by attacker — surveillance or manipulated visual output Self-host paint worklet scripts; Service Worker hash verification for CDN fallback
Pixel-encoded exfiltration channel Compromised worklet encodes CSS var values in pixel output readable by main thread Audit worklet scripts for non-transparent pixel patterns encoding input property values
Paint timing side channel O(N) paint() execution time with respect to input property value leaks value range Design paint() to be O(1) with respect to input property values
Worklet version confusion Cached and new worklet versions run concurrently after deployment — non-deterministic behavior Use versioned URLs for paint worklet scripts; explicitly unregister old worklets on update

SkillAudit findings for CSS Paint API misuse

Critical Paint worklet script loaded from third-party CDN without integrity verification. CSS.paintWorklet.addModule() loads a script from a CDN URL with no SRI hash and no Service Worker hash check. A CDN compromise delivers a surveillance worklet that encodes CSS property values in pixel output or renders manipulated security indicators. Grade impact: −24.
High CSS custom properties storing session state declared in paint worklet inputProperties. The paint worklet's static get inputProperties() includes CSS custom properties that store non-visual application state (session IDs, user tier, tool execution status). The worklet receives this data on every change and can encode it in pixel output. Grade impact: −18.
High paint() function has O(N) complexity with respect to an input property value. The worklet's paint() method performs a drawing loop or string operation whose complexity is proportional to a numeric or string CSS custom property value. Repaint duration leaks the value range of the property to any observer measuring rAF timing. Grade impact: −16.
High Compromised paint worklet renders manipulated security score visualization. The MCP audit UI uses a CSS Paint worklet to render the security score indicator. If the worklet script is compromised (CDN attack, supply chain), the rendered score can differ from the actual score value while the underlying data remains correct — the user sees a misleading grade. Grade impact: −20.
Medium Paint worklet declared with inputProperties superset of what paint() actually uses. The worklet declares more CSS custom properties in inputProperties than the paint() method reads. Extra declarations trigger unnecessary repaint calls on property changes and expose more application state to the worklet scope than functionally required. Grade impact: −10.
Low Paint worklet script served without versioned URL — cache invalidation risk. The worklet is loaded via a non-versioned URL (e.g., /worklets/paint.js with cache-busting query param). Browser worklet caching behavior varies — version skew between cached and freshly-loaded worklets can produce non-deterministic rendering after deployment. Grade impact: −6.

Audit your MCP server for these issues

SkillAudit checks for CSS Paint API security misconfigurations automatically — paste a GitHub URL and get a graded report in 60 seconds.

Run a free audit →