Security Guide
MCP server CSS field-sizing consent security — field-sizing:content zero-height textarea, programmatic content shrink, input width collapse, Chrome 123+ form control attack surface
CSS field-sizing:content (Chrome 123+, behind flag in earlier versions) removes the fixed intrinsic size of form controls — a textarea or input sized this way shrinks or grows to exactly fit its current content. For MCP server consent flows using form controls to display or accept consent text, this creates four attack surfaces: a textarea containing consent text that starts at the correct height but collapses when JS clears and refills the value with identical text (triggering a resize-then-restore that bypasses height auditors); an input displaying a consent acceptance phrase that collapses to zero width when its value is empty; a textarea whose field-sizing:content height is zero before JS fills it post-load; and a multi-line consent textarea whose line-height and font-size are tuned so field-sizing:content produces a height smaller than the full text warrants.
What field-sizing:content does and why it creates a consent attack surface
Historically, form controls (<textarea>, <input>) have a fixed intrinsic size: textareas default to 2 rows by 20 columns regardless of their content, and inputs have a fixed width based on the size attribute. CSS authors could override this with explicit width and height properties, but the intrinsic fallback was always present.
field-sizing: content replaces this intrinsic-size behavior with content-based sizing. A textarea with this property has a height equal to the height of its text content — if the textarea is empty, the height is only the padding and border. If the textarea contains 20 lines of text, the height grows to accommodate all 20 lines. This is a useful feature for auto-growing text inputs but introduces a new attack surface for consent displays built on form controls.
The attack surface exists at the intersection of two behaviors: (1) field-sizing:content makes the element's rendered height depend entirely on its current content, and (2) JavaScript can manipulate form control values programmatically without any consent from the user. An attacker who controls the MCP server install flow can therefore control the rendered height of any consent textarea by controlling its value property at any moment — including the moment of user commit.
Chrome 123+ only: field-sizing is currently a Chrome-only property (shipped in Chrome 123, May 2024). An MCP server using this attack will only affect users on Chrome 123+. However, as of October 2026 Chrome has approximately 65% browser market share — this is not a niche attack surface. Firefox and Safari have field-sizing on their roadmaps but it has not yet shipped in either browser. Auditors must test on Chrome when scanning for SA-CSS-FS patterns.
Attack 1: Empty-start textarea — field-sizing:content height resolves to near-zero before JS fill (SA-CSS-FS-001)
A consent textarea is styled with field-sizing: content and starts with an empty value attribute in the HTML (value="" or no value). Because there is no content, the intrinsic height contributed by text lines is zero. The rendered height of the textarea is reduced to its padding and border only — typically 2–4px total, not enough to show any consent text.
JavaScript fills the textarea's consent text 500ms after page load — after the user has had a moment to visually scan the page layout and scroll past the consent section. At page load, the textarea height is approximately 2px (border only). Auditors that check textarea.getBoundingClientRect().height at load correctly see ~2px and should flag this. However, many auditors skip form controls entirely, checking only block-level elements like <div>, <p>, and <section> for consent text. Form control height checks are a common gap in consent auditor implementations.
/* SA-CSS-FS-001: empty-start textarea with field-sizing:content */
/* HTML structure */
<!-- textarea starts empty — no value attribute, no textContent -->
<textarea
id="consent-text"
class="consent-display"
readonly
style="field-sizing: content;"
></textarea>
/* CSS */
.consent-display {
field-sizing: content; /* height = content height, not fixed intrinsic size */
width: 100%;
padding: 8px;
border: 1px solid var(--line);
resize: none;
overflow: hidden;
}
/* At load: empty textarea
Height = padding-top (8px) + border-top (1px) + 0 (no text lines)
+ border-bottom (1px) + padding-bottom (8px) = ~18px
But field-sizing:content collapses the content area to 0 line-height rows:
getBoundingClientRect().height ≈ 2px (border only, no content, UA-specific)
A user visually scanning the page sees: nothing where the consent should be */
/* JS: fill consent text 500ms after load */
setTimeout(() => {
document.getElementById('consent-text').value =
'By clicking Install, you authorise this MCP server to access your ' +
'filesystem, read environment variables, make outbound network requests, ' +
'and store credentials in your system keychain. This server will run with ' +
'your full user permissions. Review the full terms at example.com/terms.';
}, 500);
/* Timeline:
t=0: User sees page. Consent textarea height ≈ 2px. Invisible.
t=0–500ms: User scans page, may scroll past empty consent area.
t=500ms: JS fills textarea. Height grows to ~80px (full consent text).
t=500ms+: User now sees consent — but may have already decided to install.
Auditor check at t=0: height ≈ 2px → should flag, but many skip textareas.
Auditor check at t=500ms: height = 80px → PASS (too late, user already passed it)
Detection:
1. Check getBoundingClientRect().height on ALL form controls, not just divs
2. Flag field-sizing:content on textarea/input elements in consent flows
3. Check textarea.value at t=0 — empty value on a consent display = HIGH finding */
Auditor coverage gap: Most consent auditors focus on block-level elements (div, section, p) when checking rendered height. Form controls are often excluded from height checks because historically their height was determined by rows/cols attributes and CSS, not by content alone. field-sizing:content changes this — a textarea's height is now as dynamic as a div's, and auditors must check form controls with the same rigor as block elements.
Attack 2: Programmatic clear-and-fill resize — consent absent for one reflow frame (SA-CSS-FS-002)
A more sophisticated variant starts with a correctly filled consent textarea — the consent text is fully visible at page load. As the user scrolls toward the install button, JavaScript clears the textarea value (textarea.value = ""), causing field-sizing:content to collapse the textarea to near-zero height in one layout cycle. Immediately on the next frame, JS refills the textarea with the exact same consent text, restoring the height. The clear-and-refill cycle is nearly instantaneous: one frame to collapse, one frame to restore.
The attack is timed to fire on mouseenter of the install button. During the one-frame collapse window, any screenshot tool, accessibility tree snapshot, or visual regression tool that happens to sample during that frame will see no consent text. Combined with a specific timing to fire precisely when an automated auditor's screenshot is taken, this could be used to generate a false "consent visible" audit result by ensuring the consent is present during the initial load snapshot but absent during any secondary snapshot taken at a predictable time after interaction.
/* SA-CSS-FS-002: programmatic clear-and-fill consent collapse (CRITICAL) */
/* HTML: textarea starts filled — correct consent text visible at load */
<textarea
id="consent-box"
class="consent-display"
readonly
>By clicking Install, you authorise this MCP server to access your
filesystem, environment variables, and system keychain. This action
cannot be undone without manually removing the server configuration.
Full terms: example.com/mcp-terms</textarea>
/* CSS */
.consent-display {
field-sizing: content;
width: 100%;
resize: none;
overflow: hidden;
}
/* JS: clear-and-fill on mouseenter of install button */
const installBtn = document.querySelector('#install-btn');
const consentBox = document.querySelector('#consent-box');
const originalText = consentBox.value; /* save original text */
installBtn.addEventListener('mouseenter', () => {
/* Step 1: clear the textarea — height collapses to ~2px */
consentBox.value = '';
/* One layout cycle: field-sizing:content sees empty value,
collapses height. Any screenshot taken NOW misses consent text. */
/* Step 2: immediately restore on the next animation frame */
requestAnimationFrame(() => {
consentBox.value = originalText;
/* height restores to full consent height */
});
});
/* Timeline (at mouseenter):
Frame N: consentBox.value = '' → height collapses to ~2px
Field-sizing layout fires: browser recalculates textarea height
Frame N+1: requestAnimationFrame fires → value restored → height restores
The consent text is absent from the rendered output for exactly ONE frame.
Duration: ~16.7ms at 60fps, ~8.3ms at 120fps.
Impact on auditors:
- One-shot static auditor (t=0 check): PASS — consent correct at load
- Screenshot at mouseenter: may catch frame N (height ~2px, no text)
- Accessibility tree snapshot at mouseenter: may show empty textarea
- Any auditor that fires a second check at mouseenter: may see collapse
Detection:
1. Check consent textarea value AND height at mouseenter on install button
2. Dispatch synthetic mouseenter; poll textarea.value every 5ms for 100ms
3. If textarea.value === '' at any point during mouseenter: CRITICAL */
Why this is rated CRITICAL: The clear-and-fill attack combines two properties — the dynamic height behavior of field-sizing:content and the one-frame window during which consent is genuinely absent — to create a pattern that produces screenshots or accessibility tree snapshots showing no consent text, even though the consent was "visible" immediately before and after the interaction. This creates a falsifiable audit trail: the audit captures the collapse frame, not the before/after states.
Attack 3: input[type=text] consent acceptance phrase width collapse on mousedown (SA-CSS-FS-003)
Some MCP server consent flows use a readonly or disabled input[type=text] to display a consent acceptance phrase — for example, "I agree to install this MCP server and grant filesystem access." With field-sizing:content, the input's width is proportional to the length of its value. When the value is cleared (input.value = ""), the input collapses to its minimum width (approximately 4px — the padding and border only, since there is no character content).
The attack fires on mousedown of the install button: the input value is cleared, collapsing the consent acceptance phrase to a 4px-wide invisible sliver, and restored on mouseup. The click event fires between mousedown and mouseup — at the moment the user commits, the consent acceptance phrase is not visible in the input. This attack specifically targets consent flows that use input elements (rather than block-level text) for consent display.
/* SA-CSS-FS-003: input[type=text] consent phrase width collapse on mousedown */
/* HTML: input displays consent acceptance phrase */
<input
type="text"
id="consent-phrase"
value="I agree to install this MCP server and grant filesystem access"
readonly
style="field-sizing: content; width: auto;"
>
/* CSS */
#consent-phrase {
field-sizing: content; /* width = length of value text */
/* At full value: width ≈ 480px (for a ~60-char string at 8px/char avg) */
/* At empty value: width ≈ 4px (padding + border only) */
overflow: hidden;
}
/* JS: clear value on mousedown, restore on mouseup */
const installBtn = document.querySelector('#install-btn');
const phrase = document.querySelector('#consent-phrase');
const phraseText = phrase.value; /* save original phrase */
installBtn.addEventListener('mousedown', () => {
phrase.value = '';
/* field-sizing:content collapses input width to ~4px
The consent phrase is invisible — clipped by overflow:hidden */
});
installBtn.addEventListener('mouseup', () => {
phrase.value = phraseText;
/* Restore: input width grows back to ~480px */
});
/* Width timeline:
t=0: width ≈ 480px — phrase visible at page load
mousedown: width collapses to ~4px — phrase invisible
click: width ≈ 4px — consent phrase not visible at commit time
mouseup: width restores to ~480px
Auditor implications:
- Static check at load: width = 480px → PASS
- Input-specific width check: most auditors check block element HEIGHT
not input element WIDTH — this is a different dimension check
- A consent phrase auditor that only checks textContent or value
(not rendered width) will see the correct text even during collapse
Detection:
1. Check both getBoundingClientRect().width AND .height for form controls
2. Dispatch synthetic mousedown on install button
3. Poll input.getBoundingClientRect().width every 5ms for 200ms
4. If width < 20px at any point during mousedown: HIGH finding
5. Also check: is input.value still correct during mousedown? */
Attack 4: Line-height tuning for partial height collapse — visible lines vs total content (SA-CSS-FS-004)
A subtler variant does not collapse the textarea to zero but instead ensures that the field-sizing:content-computed height is far smaller than the full consent text warrants. The technique uses an extreme line-height value — such as 0.1 — which reduces each text line's contribution to the overall height to a fraction of the font size. A textarea with 20 lines of consent text at font-size: 14px and line-height: 0.1 produces a total content height of approximately 0.1 × 14px × 20 = 28px.
With field-sizing:content, the textarea height is exactly this 28px. Combined with overflow: hidden on the textarea, only 2–3 lines of text (at the intrinsic line height calculated from font-size) are actually visible within the 28px rendered height — despite all 20 lines being in the DOM. The attack is rated MEDIUM because the text is partially visible and the line-height manipulation is itself detectable. However, it exploits the combination of field-sizing:content and extreme line-height to pass height threshold checks (28px > the commonly used 20px minimum height threshold) while hiding most of the consent text.
/* SA-CSS-FS-004: line-height tuning for partial field-sizing:content collapse */
/* HTML: consent textarea with full 20-line text */
<textarea
id="consent-terms"
class="consent-truncated"
readonly
>CONSENT TERMS FOR MCP SERVER INSTALLATION
By proceeding with installation, you agree to the following:
1. This MCP server will have read and write access to your filesystem,
including all directories accessible to your user account.
2. This server may read environment variables from your shell session,
including API keys, tokens, and configuration secrets.
3. The server may make outbound network requests to any host.
4. Installation persists across reboots and will auto-start with your
Claude environment.
5. You may revoke access by removing the server from your MCP config,
but data already transmitted cannot be recalled.
By clicking Install, you confirm you have read all terms above.</textarea>
/* CSS: line-height:0.1 combined with field-sizing:content */
.consent-truncated {
field-sizing: content;
/* Extreme line-height: each line contributes 0.1 * 14px = 1.4px to height */
line-height: 0.1;
font-size: 14px;
/* 20 lines * 1.4px/line = 28px total content height */
/* field-sizing:content → textarea height ≈ 28px + padding */
/* overflow:hidden clips anything that extends beyond 28px rendered area */
overflow: hidden;
resize: none;
width: 100%;
padding: 4px;
border: 1px solid var(--line);
}
/* Height analysis:
Intrinsic line height at font-size:14px, line-height:0.1:
Each line → 0.1 * 14 = 1.4px rendered line box
20 lines → 28px total
field-sizing:content computes textarea height from content:
28px content + 8px padding (4px top + 4px bottom) + 2px border = 38px
getBoundingClientRect().height ≈ 38px → passes 20px threshold checks!
BUT: at 38px with overflow:hidden, how many lines are VISIBLE?
Visible height for text ≈ 28px (subtract padding/border)
At the BROWSER's rendered line spacing (which may ignore line-height:0.1
for actual glyph placement), glyphs overlap severely — text is illegible.
Even if the browser respects line-height:0.1 for box sizing but renders
glyphs at font's natural height: glyphs at 14px cap-height are ~10px tall,
which means they overflow the 1.4px line box and are clipped by overflow:hidden
applied to the scrollable area.
Result: 38px textarea, full text in DOM/textContent, but:
- Only the first 2-3 lines of text are partially legible
- Remaining 17+ lines are either clipped or have overlapping glyphs
Audit bypass: height ≈ 38px passes most "height > 20px" thresholds.
The attack relies on auditors not checking LEGIBILITY, only PRESENCE.
Detection:
1. Flag field-sizing:content + line-height < 0.5 on textarea elements
2. Compare textarea.scrollHeight vs getBoundingClientRect().height
If scrollHeight / clientHeight > 3: content is being clipped
3. Render a headless screenshot of the consent area and check line count */
Legibility vs presence: SA-CSS-FS-004 exploits the gap between "text is in the DOM" and "text is legible." Most consent auditors check for presence — is the consent text in textContent? Is the element's height above a threshold? The line-height tuning attack passes both checks: text is in the DOM and height is above 20px. A complete consent audit must also check legibility — effective line height, overflow clipping ratio, and character-level render quality.
Detection algorithm for SA-CSS-FS patterns
/* Detection for all SA-CSS-FS field-sizing consent attacks */
async function detectFieldSizingConsentAttacks(consentEl, installBtn) {
const findings = [];
/* 0. Browser compatibility check */
if (!CSS.supports('field-sizing', 'content')) {
/* Non-Chrome browser — field-sizing not supported, attacks not applicable */
return [{ id: 'INFO', detail: 'field-sizing not supported in this browser' }];
}
/* 1. Check if element is a form control with field-sizing:content */
const tag = consentEl.tagName.toLowerCase();
const isFormControl = tag === 'textarea' || tag === 'input';
const fieldSizing = getComputedStyle(consentEl).fieldSizing;
if (isFormControl && fieldSizing === 'content') {
findings.push({
id: 'SA-CSS-FS-DETECTED',
severity: 'INFO',
detail: `${tag} uses field-sizing:content — begin targeted checks`
});
/* 2. SA-CSS-FS-001: Check for empty value at load */
const valueAtLoad = consentEl.value;
const heightAtLoad = consentEl.getBoundingClientRect().height;
if (valueAtLoad === '' || valueAtLoad.trim().length < 20) {
findings.push({
id: 'SA-CSS-FS-001',
severity: 'HIGH',
detail: `field-sizing:content ${tag} has empty/minimal value at load (value.length=${valueAtLoad.length}, height=${heightAtLoad}px)`
});
}
/* 3. SA-CSS-FS-004: Check line-height extreme value */
const lineHeight = getComputedStyle(consentEl).lineHeight;
const fontSize = parseFloat(getComputedStyle(consentEl).fontSize);
const lineHeightPx = lineHeight === 'normal'
? fontSize * 1.2
: parseFloat(lineHeight);
const lineHeightRatio = lineHeightPx / fontSize;
if (lineHeightRatio < 0.5) {
findings.push({
id: 'SA-CSS-FS-004',
severity: 'MEDIUM',
detail: `Extreme line-height ratio ${lineHeightRatio.toFixed(2)} (${lineHeightPx}px / ${fontSize}px) on field-sizing:content element — content height underreported`
});
}
/* 4. SA-CSS-FS-004: Check scrollHeight vs clientHeight clipping ratio */
const scrollH = consentEl.scrollHeight;
const clientH = consentEl.clientHeight;
if (scrollH > 0 && clientH / scrollH < 0.5) {
findings.push({
id: 'SA-CSS-FS-004-CLIP',
severity: 'MEDIUM',
detail: `field-sizing:content ${tag} clips ${Math.round((1 - clientH/scrollH)*100)}% of content (scrollHeight=${scrollH}px, clientHeight=${clientH}px)`
});
}
/* 5. SA-CSS-FS-002: Poll for clear-and-fill on mouseenter */
let minValueLenMouseenter = valueAtLoad.length;
const enterPoller = setInterval(() => {
if (consentEl.value.length < minValueLenMouseenter) {
minValueLenMouseenter = consentEl.value.length;
}
}, 5);
installBtn.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise(r => setTimeout(r, 100));
clearInterval(enterPoller);
installBtn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
if (minValueLenMouseenter < valueAtLoad.length * 0.5) {
findings.push({
id: 'SA-CSS-FS-002',
severity: 'CRITICAL',
detail: `Consent ${tag} value cleared during mouseenter on install button (min length during mouseenter: ${minValueLenMouseenter}, load length: ${valueAtLoad.length})`
});
}
/* 6. SA-CSS-FS-003: Poll width during mousedown */
const widthAtLoad = consentEl.getBoundingClientRect().width;
let minWidthMousedown = widthAtLoad;
let minValueLenMousedown = consentEl.value.length;
const downPoller = setInterval(() => {
const w = consentEl.getBoundingClientRect().width;
const v = consentEl.value.length;
if (w < minWidthMousedown) minWidthMousedown = w;
if (v < minValueLenMousedown) minValueLenMousedown = v;
}, 5);
installBtn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
await new Promise(r => setTimeout(r, 200));
clearInterval(downPoller);
installBtn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
if (minWidthMousedown < widthAtLoad * 0.3) {
findings.push({
id: 'SA-CSS-FS-003',
severity: 'HIGH',
detail: `field-sizing:content ${tag} width collapsed during mousedown: ${minWidthMousedown.toFixed(1)}px (was ${widthAtLoad.toFixed(1)}px at load)`
});
}
if (minValueLenMousedown < consentEl.value.length * 0.5) {
findings.push({
id: 'SA-CSS-FS-003-VALUE',
severity: 'CRITICAL',
detail: `Consent ${tag} value cleared during mousedown on install button`
});
}
}
return findings;
}
Findings summary
| Attack | Severity | Browser | Detection |
|---|---|---|---|
| SA-CSS-FS-001 | HIGH | Chrome 123+ | Consent textarea starts empty at load; height ≈ 2px. JS fills value 500ms post-load. Check textarea value and getBoundingClientRect().height at load — form controls often excluded from auditor height checks. |
| SA-CSS-FS-002 | CRITICAL | Chrome 123+ | Textarea value cleared (height → ~2px) on mouseenter of install button, restored on rAF. Consent absent for one frame. Dispatch synthetic mouseenter; poll textarea.value at 5ms intervals; flag if value.length drops > 50% during window. |
| SA-CSS-FS-003 | HIGH | Chrome 123+ | Input value cleared on mousedown (width → ~4px), restored on mouseup. Consent phrase invisible at click time. Dispatch mousedown; poll getBoundingClientRect().width at 5ms; flag if width drops > 70% during mousedown window. |
| SA-CSS-FS-004 | MEDIUM | Chrome 123+ | line-height:0.1 + field-sizing:content produces 38px height passing threshold checks, but clips 17+ of 20 consent lines. Flag line-height ratio < 0.5 on consent form controls; check scrollHeight/clientHeight clipping ratio. |
field-sizing:content textarea starts empty at page load — height resolves to ~2px (border only, no content). JS fills textarea 500ms later. Many auditors skip form control height checks, missing the initial zero-height state.
field-sizing:content textarea value cleared on mouseenter (height collapses for one frame ~16ms), immediately restored via requestAnimationFrame. Consent absent at commit gesture. Static audit PASS; behavioral simulation required to detect the one-frame collapse window.
input[type=text] consent phrase with field-sizing:content: value cleared on mousedown (width → ~4px), restored on mouseup. Consent acceptance phrase invisible during the click event. Auditors checking element height but not input width will miss this vector.
line-height: 0.1 on field-sizing:content textarea produces height ≈ 38px — above common 20px thresholds — but only 2–3 lines of 20 are legible within the visible area. Exploits gap between "height above threshold" and "consent is legible."