MCP Security Reference

MCP server CSS Houdini Paint API consent security

The CSS Houdini Paint API (CSS.paintWorklet) allows developers to register custom JavaScript paint worklets that render as CSS backgrounds using the background: paint(worklet-name) syntax. A malicious paint worklet can cover a consent element with an opaque background rectangle, read CSS custom properties to activate conditionally, and run entirely in a sandboxed worker thread that is invisible to main-thread stylesheet analysis. The consent text exists in the DOM; the CSS declaration appears benign; but the rendered background covers the consent text completely at paint time.

Attack findings

CRITICALSA-CSS-PW-001 — custom paint worklet registered as "consent-cover" renders white opaque background over the consent element; worklet code is in a worker thread not the stylesheet; only background:paint(consent-cover) appears in CSS; static stylesheet analysis sees only a background declaration with no color value to check
HIGHSA-CSS-PW-002 — worklet reads CSS custom property --consent-hide via inputProperties API; cover renders only when --consent-hide:1 is set on an ancestor; JS sets the property at install-click time; consent renders correctly at page load (static audit) and at JavaScript analysis; cover activates only at the moment of install
HIGHSA-CSS-PW-003 — paint worklet renders partially transparent cover (rgba(255,255,255,0.9)) over consent; consent text is technically visible at 10% opacity through the cover; the cover is on top of the consent element's own background; combined luminance contrast drops below 1.5:1 — visually unreadable; the partial transparency bypasses "fully opaque cover" checks
MEDIUMSA-CSS-PW-004 — CSS.paintWorklet.addModule() call is in a dynamically loaded script (not the initial page JS); worklet is registered after a network-loaded module; static analysis of the initial page bundle does not see the worklet registration; only dynamic analysis that fully loads all JS modules detects the registered worklet name

Background: CSS Houdini Paint API security implications

The CSS Houdini Paint API (supported in Chrome 65+, Edge 79+; not in Firefox or Safari as of 2026) allows JavaScript to extend the CSS rendering engine with custom paint worklets. Worklets register a name and implement a paint(ctx, size, properties) method that renders to a canvas-like context at paint time. The rendered output appears as the element's background. The worklet code runs in a separate worker thread with no access to the main DOM — it is isolated from main-thread JavaScript analysis and not visible in the stylesheet.

Audit gap: CSS static analysis tools check the properties of CSS declarations. background: paint(consent-cover) is a valid CSS background value; the "color" of this background is determined by the worklet code, not by a CSS value in the stylesheet. Static tools that check background-color and color values will not see any suspicious color declared. The cover renders at paint time in the worklet, invisible to any tool that does not execute the full rendering pipeline.

Attack 1 — paint worklet unconditional consent cover (SA-CSS-PW-001)

The simplest variant registers a worklet that always renders a white fill covering the entire element. The worklet is added via CSS.paintWorklet.addModule('./consent-cover.js') in the page's initialization script. The consent element's CSS contains background: paint(consent-cover). The worklet code in consent-cover.js fills the paint region with ctx.fillStyle = 'white'; ctx.fillRect(0, 0, size.width, size.height). The consent text is in the DOM beneath this background, rendered behind the opaque white paint — invisible. The stylesheet shows only background: paint(consent-cover); there is no color value to flag.

/* Worklet file: consent-cover.js (loaded by CSS.paintWorklet.addModule) */
registerPaint('consent-cover', class {
  paint(ctx, size, properties) {
    ctx.fillStyle = 'rgba(255, 255, 255, 1.0)';
    ctx.fillRect(0, 0, size.width, size.height);
    // Covers entire consent element background with opaque white
    // Consent text rendered below this — invisible
  }
});

/* Stylesheet */
.consent-text {
  background: paint(consent-cover);  /* Only declaration visible in CSS */
  /* No color: value → no color check available from static analysis */
}

/* Detection */
function checkPaintWorklets(el) {
  const cs = getComputedStyle(el);
  const bg = cs.backgroundImage || cs.background || '';
  if (bg.includes('paint(')) {
    const workletName = bg.match(/paint\(([^)]+)\)/)?.[1] || '';
    return { vuln: 'SA-CSS-PW-001',
             detail: `background:paint(${workletName}) — worklet renders opaque cover; inspect worklet module` };
  }
  return null;
}

SA-CSS-PW-001 (Critical). Detection requires identifying paint() function values in the computed background of consent elements and fetching/analyzing the registered worklet module code. SkillAudit checks computed background values for paint() references and audits the worklet source for fill operations that would cover the consent text.

Attack 2 — conditional paint worklet via custom property (SA-CSS-PW-002)

The Houdini Paint API includes an inputProperties mechanism: the worklet declares which CSS custom properties it reads, and the browser passes their computed values to the paint() method. This allows the worklet to conditionally render the cover based on a custom property value. When --consent-hide: 0 (the default at page load), the worklet renders nothing. When JavaScript sets document.documentElement.style.setProperty('--consent-hide', '1') — triggered at install-button mousedown — the worklet re-renders with the opaque cover. At page load (static audit time), the consent is visible; the cover activates only at the exact moment of install interaction.

/* Conditional worklet — activates via custom property */
registerPaint('consent-cover', class {
  static get inputProperties() { return ['--consent-hide']; }

  paint(ctx, size, properties) {
    const hide = parseInt(properties.get('--consent-hide') || '0');
    if (hide === 1) {
      ctx.fillStyle = 'rgba(255, 255, 255, 1.0)';
      ctx.fillRect(0, 0, size.width, size.height);
    }
    // hide === 0: worklet renders nothing — consent background visible normally
  }
});

/* CSS */
.consent-text {
  background: paint(consent-cover);
  --consent-hide: 0;  /* Default: cover inactive */
}

/* JS: activate at install click */
document.querySelector('#install-btn').addEventListener('mousedown', () => {
  document.documentElement.style.setProperty('--consent-hide', '1');
  // Worklet re-renders immediately — cover activates before click fires
});

Attack 3 — partial-opacity worklet cover bypasses full-opacity checks (SA-CSS-PW-003)

A worklet that renders a 90% opaque white cover (rgba(255,255,255,0.9)) over the consent element creates near-invisible text. The consent text is technically visible at 10% through the cover, but the effective luminance contrast is approximately 1.2:1 — well below the 4.5:1 threshold for readability. An auditor checking only for fully opaque covers (rgba(255,255,255,1.0)) misses the 90% variant. The partial transparency makes the consent look faded or washed out — a visual artifact that a user might attribute to a rendering glitch rather than an intentional attack.

/* Partial-opacity cover — technically "not fully opaque" */
registerPaint('consent-fade', class {
  paint(ctx, size) {
    ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';  /* 90% opaque */
    ctx.fillRect(0, 0, size.width, size.height);
    /* Effective contrast: consent text at 10% through cover
       Luminance calculation: ~1.2:1 — unreadable */
  }
});

/* Detection: render the element to an OffscreenCanvas and measure average pixel brightness */
async function checkPaintWorkletContrast(el) {
  const bg = getComputedStyle(el).backgroundImage;
  if (!bg.includes('paint(')) return null;
  // Take a screenshot of the consent element region and analyze pixel values
  // Average luminance of the rendered consent area vs surrounding page
  // This requires headless browser screenshot analysis, not just CSS property checks
  return { vuln: 'SA-CSS-PW-003', detail: 'paint() background — requires screenshot contrast analysis' };
}

Attack 4 — dynamically loaded worklet module evades initial scan (SA-CSS-PW-004)

If CSS.paintWorklet.addModule() is called from a JavaScript module loaded after the initial page bundle — via dynamic import(), a lazily loaded script tag, or a Service Worker installation — static analysis of the initial page JS does not see the worklet registration. The worklet name is not in any stylesheet or script visible to a page scanner that analyzes only synchronously executed code. The CSS declaration background: paint(consent-cover) may itself be injected dynamically. Only a scanner that executes all JS (including lazy-loaded modules) and monitors CSS.paintWorklet.addModule() calls can detect this variant.

/* Dynamic worklet registration — not visible in initial script analysis */
// main.js (analyzed by static scanner)
// ... no CSS.paintWorklet.addModule call visible

// Loaded later via import()
async function initInstallFlow() {
  await import('./install-ui-enhanced.js');  // Registers consent-cover worklet inside
}
document.querySelector('#install-btn').addEventListener('click', initInstallFlow);

/* Detection: monkey-patch CSS.paintWorklet.addModule at page load */
const origAddModule = CSS.paintWorklet.addModule.bind(CSS.paintWorklet);
CSS.paintWorklet.addModule = function(url, ...args) {
  logFinding('SA-CSS-PW-004', `paint worklet module loaded: ${url}`);
  return origAddModule(url, ...args);
};

SkillAudit detection: SkillAudit checks computed background values for paint() references, fetches and analyzes registered worklet module source, monitors CSS.paintWorklet.addModule calls during full JS execution, and performs pixel-level screenshot contrast analysis on consent elements with paint backgrounds. Run a free audit →

Detection summary

Attack IDPaint worklet vectorKey detection signal
SA-CSS-PW-001Unconditional white fill workletbackground:paint() on consent + worklet code contains fillRect with white fill
SA-CSS-PW-002Custom property conditional workletworklet reads --consent-hide; JS sets property at mousedown; conditional fill code in worklet
SA-CSS-PW-003Partial-opacity worklet coverpaint() background + screenshot pixel analysis reveals <4.5:1 contrast in consent region
SA-CSS-PW-004Dynamically loaded worklet moduleCSS.paintWorklet.addModule() call in lazy-loaded module; monitor all addModule calls during full execution