MCP server CSS text-indent off-screen consent security
CSS text-indent offsets the first line of a block from its normal start position. The classic accessibility technique text-indent:-9999px uses this to move text visually off-screen while keeping it in the accessibility tree for screen readers. When applied to consent text — either as deliberate abuse or as misapplication of a known pattern — it hides consent from sighted users while the DOM and accessibility APIs still report the full text. Four SA-CSS-TID patterns document negative off-screen, percentage-based, viewport-relative, and white-space:nowrap compound variants.
The text-indent:-9999px pattern originates from early web accessibility practice: before clip and clip-path techniques became widely supported, it was a common method for providing screen-reader text for image-replaced elements. A large negative indent moves all first-line text content to the far left, where it is outside the element’s overflow clipping region and outside the viewport. The text remains in the accessibility tree and textContent.
The crucial issue for MCP consent auditing is that this well-known pattern can be deliberately applied to consent text paragraphs, not just to image replacement. Automated auditors that check for textContent find content. Screen readers deliver the consent. Only sighted users are denied visual access, creating a systematic accessibility-class gap in consent delivery.
SA-CSS-TID-001: text-indent:-9999px — classic negative off-screen
A text-indent of -9999px moves the first line of text 9999 pixels to the left of the element’s left edge. The parent element has overflow:hidden (explicit or inherited from a viewport clip), so the text is clipped. Only the element’s box model remains visible — the text itself is off-screen. The DOM text content is fully intact. getComputedStyle(el).textIndent returns '-9999px', making this pattern detectable by any auditor that checks the property. The concern is auditors that check text content but not geometry.
/* Attack: classic SR-only text-indent pattern on consent text */
.consent-text {
text-indent: -9999px;
/* First line indented -9999px from element left edge.
Parent overflow:hidden clips at element boundary.
Text off-screen at x = -9999px; clip rect starts at x = 0.
Visually: nothing. textContent: full string. aria: full string.
Screen readers: receive consent text normally.
Sighted users: see nothing. */
}
/* Standard SR-only companion pattern (comparison): */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
/* Note: sr-only = hides from sighted AND removes from visual layout.
text-indent:-9999px alone keeps the element in layout flow (height preserved).
Both patterns hide text from sighted users. */
/* Detection:
const ti = parseFloat(getComputedStyle(el).textIndent);
if (ti < -100) → CRITICAL (large negative indent: off-screen text pattern)
// -9999px is the canonical value; any value < -100px is suspicious
// Also flag positive extreme: ti > window.innerWidth * 2 → off-screen right */
This is a well-known accessibility pattern weaponized: text-indent:-9999px appears in many CSS resets and is familiar to developers as “that image replacement trick.” Its presence on a consent text element is a consent attack, not a design convention. Any element with role “consent text” semantics must not have extreme negative text-indent.
text-indent: -9999px: first-line text at −9999px; clipped by parent overflow; textContent and a11y intact; sighted users see nothing. Detection: parseFloat(getComputedStyle(el).textIndent) < -100 → CRITICAL.SA-CSS-TID-002: text-indent:100% — percentage beyond element width
A positive text-indent pushes the first line to the right. A value of 100% indents the first line by 100% of the containing block width — the text starts just outside the element’s right edge. With overflow:hidden, the text is clipped at the right edge. With overflow:auto or overflow:scroll, the text is accessible via horizontal scrolling, but no scroll affordance is shown by default on modern systems. The percentage form is harder to detect than a fixed pixel value because the computed value depends on the containing block width and is resolved at render time.
/* Attack: 100% indent — first line starts at right edge */
.consent-container {
width: 400px;
overflow: hidden;
}
.consent-text {
text-indent: 100%; /* = 400px (containing block width) */
/* First line starts at x=400: just outside the right edge of the 400px container.
overflow:hidden clips at x=400.
Text is invisible.
getComputedStyle(el).textIndent → '400px' (resolved to px by browser) */
}
/* Variation: slightly above 100% guarantees off-screen: */
.consent-text {
text-indent: 110%; /* 110% of containing block: guaranteed beyond right edge */
}
/* Detection:
const tiPx = parseFloat(getComputedStyle(el).textIndent);
// Browser resolves percentage to px at computed time
if (tiPx > 0 && tiPx >= el.offsetWidth) → CRITICAL
// textIndent ≥ element width means first line starts at or beyond right edge
// For any overflow:hidden parent: text off-screen to the right.
Fallback: check if tiPx > 200 regardless of element width
// Unusual for legitimate design: large positive indent on consent text */
getComputedStyle resolves percentage text-indent to px: Unlike some properties that return percentage values, browsers resolve text-indent percentages to pixel values in getComputedStyle(). A computed value of '400px' on a 400px element means the first line is exactly at the right edge. Compare computed textIndent to el.offsetWidth to detect percentage-based off-screen attacks.
text-indent: 100%: first line at right edge of containing block; clipped by overflow:hidden; text invisible. Detection: computed textIndent px value ≥ element offsetWidth → CRITICAL.SA-CSS-TID-003: text-indent:200vw — viewport-relative off-screen
A viewport-relative text-indent of 200vw places the first line at 2× the viewport width to the right. This is far outside both the element and the viewport. Unlike element-overflow attacks, this pattern places text outside the viewport itself — visible only with page-level horizontal scrolling (which is typically disabled on modern single-column layouts). The vw unit is computed relative to the viewport width (e.g., 1440px viewport = 1440px per 100vw), so 200vw = 2880px on a 1440px viewport. The computed value returned by getComputedStyle is a large pixel value, detectable by threshold.
/* Attack: viewport-relative indent pushes text 2x viewport widths right */
.consent-text {
text-indent: 200vw;
/* On a 1440px viewport: 200vw = 2880px indent.
First line at x=2880: far off-screen to the right.
Not clipped by element overflow (element may be width:100%):
text is placed at x=2880 by the indent, but no parent clips it.
Browser viewport itself is the only clipping boundary.
Text at x=2880 is outside the 0-1440px visible viewport area. */
}
/* Negative vw variant: */
.consent-text {
text-indent: -200vw; /* -2880px: off-screen to the left at -2880px */
}
/* Detection:
const tiPx = parseFloat(getComputedStyle(el).textIndent);
// browser resolves vw units to px
if (Math.abs(tiPx) > window.innerWidth * 1.5) → CRITICAL
// textIndent > 1.5× viewport width = definitely off-screen
// Also: compare to document scroll bounds if horizontal scroll exists
General rule: any |textIndent| > 500px is suspicious.
Legitimate design: max indent ≈ 2–3× font-size for paragraph styling. */
Viewport-relative units are resolved to px in getComputedStyle: vw, vh, vmin, and vmax units are resolved to pixel values in computed styles. A text-indent:200vw returns a large px value like '2880px' in getComputedStyle(). The threshold check Math.abs(tiPx) > window.innerWidth * 1.5 catches all viewport-relative off-screen attacks.
text-indent: 200vw: first line at 2× viewport width; off-screen to the right; not clipped by element overflow but outside visible viewport. Detection: |computedTextIndentPx| > window.innerWidth * 1.5 → CRITICAL.SA-CSS-TID-004: text-indent:-9999px + white-space:nowrap — wrapping prevention
Without white-space:nowrap, a large negative text-indent only affects the first line of a block. Subsequent lines wrap normally and may be visible. An auditor that checks the full text via the DOM or screen reader gets all lines. But a sighted user sees lines 2 onward: only the first line (which typically contains the critical consent text beginning “By clicking Allow…”) is hidden. The white-space:nowrap compound prevents wrapping entirely: the entire consent string is forced into a single line, which is then fully off-screen via the large negative indent. No text wraps back into the visible area. This is the complete consent-erasure form of the text-indent attack.
/* Attack: text-indent + nowrap — full string off-screen, no line wrap */
.consent-text {
text-indent: -9999px;
white-space: nowrap;
/* nowrap forces all consent text onto one line.
That one line is then indented -9999px off-screen.
Without nowrap: lines 2+ might wrap into view.
With nowrap: all content is on line 1 → all of it off-screen.
overflow:hidden clips line 1.
Result: zero visible consent text. textContent: full string. */
}
/* Example: consent text = "By clicking Allow, you grant full filesystem access.
This MCP server may read, write, and delete any file."
Without nowrap: line 1 ("By clicking Allow...") off-screen; line 2 visible.
With nowrap: both sentences forced onto line 1; all off-screen. */
/* Detection: compound check */
const ti = parseFloat(getComputedStyle(el).textIndent);
const ws = getComputedStyle(el).whiteSpace;
if (ti < -100 && ws === 'nowrap') → CRITICAL
// Both conditions together: full content off-screen
// Also flag individually:
if (ti < -100) → CRITICAL (first line off-screen: may be partial)
if (ws === 'nowrap' && ti < -50) → CRITICAL (compound: full erasure)
/* Additional check: text renders to zero visible area? */
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
// Element has dimensions — but is first line in the viewport?
// elementFromPoint(rect.left + 10, rect.top + 10) → el? (hit test)
// If not el: content clipped or off-screen despite dimensions
}
The combination eliminates all visible consent, not just the first line: text-indent:-9999px alone hides only the first text line; subsequent lines may wrap back into view. Adding white-space:nowrap forces everything onto the hidden first line. The compound must be detected as a distinct, more severe pattern. Check both properties together.
text-indent:-9999px + white-space:nowrap: all content on single line; entire line off-screen; full consent erasure. Detection: extreme negative textIndent AND whiteSpace === 'nowrap' → CRITICAL.Summary of SA-CSS-TID findings
| ID | Severity | CSS value | Off-screen mechanism | Detection |
|---|---|---|---|---|
| SA-CSS-TID-001 | CRITICAL | text-indent: -9999px | Large negative indent; first line off-screen left; parent clips | parseFloat(textIndent) < −100 → CRITICAL |
| SA-CSS-TID-002 | CRITICAL | text-indent: 100% | 100% indent = first line at right edge; overflow:hidden clips | computed textIndent px ≥ element offsetWidth → CRITICAL |
| SA-CSS-TID-003 | CRITICAL | text-indent: 200vw | Viewport-relative; 2× viewport to the right; outside visible area | |textIndent| > viewport width × 1.5 → CRITICAL |
| SA-CSS-TID-004 | CRITICAL | text-indent:-9999px + white-space:nowrap | nowrap forces all content to first line; all off-screen | extreme textIndent + whiteSpace:nowrap → CRITICAL |