Security Guide

MCP server CSS text-indent consent security — 100vw first-line off-screen, negative −999em left-clip, hanging keyword misplacement, and each-line block indent

The CSS text-indent property controls the indentation of the first line of a block container (or with the each-line keyword, the first line of each wrapped line). Its attack surface arises from the property’s ability to displace an entire first line off-screen with a large positive or large negative value, combined with overflow: hidden to clip the displaced text invisibly. text-indent: 100vw pushes the first line completely off the right edge of the viewport; the panel occupies its full height, the DOM contains the full consent text, and no dimension check raises an alarm. Only a scrollWidth > clientWidth measurement on the first-line box reveals the displacement.

Attack 1: text-indent: 100vw; overflow: hidden; white-space: nowrap — first consent line pushed off-screen right (SA-CSS-TI-001)

The CSS text-indent property indents only the first line of a block element. With a value of 100vw (100% of the viewport width), the first line of the consent text block is indented to start 100 viewport widths from the left edge of the element — completely off the right edge of the screen. Combined with overflow: hidden (which clips the off-screen first line), the first line is invisible. Because text-indent only affects the first line, all subsequent lines of the consent paragraph (lines 2, 3, 4...) render normally at their natural left alignment. The visual result is a consent block that appears to start on the second line, skipping the opening sentence entirely.

The first line of a consent block is typically the most critical sentence: “By installing this MCP server, you consent to the following permissions...” — the framing sentence that establishes what is being consented to. Lines 2–4 list the specific permissions in detail but are disconnected from the framing context (the user sees a list of permissions without the sentence that frames them as a consent grant). The consent element’s textContent contains the full consent including the first line; DOM checks pass. The element’s getBoundingClientRect().height includes the height of the first line (the line box is laid out, just displaced off-screen horizontally). Only checking el.scrollWidth > el.clientWidth or checking getComputedStyle(el).textIndent for a viewport-relative value reveals the attack.

/* SA-CSS-TI-001: text-indent:100vw + overflow:hidden — first consent line off-screen right */

.consent-text {
  text-indent: 100vw;      /* first line indented 100% viewport width to the right */
  overflow: hidden;        /* clips the off-screen first line; no scrollbar appears */
  white-space: nowrap;     /* prevents the displaced first line from wrapping back into view */
  /* Without white-space:nowrap, the first line text would wrap at the container edge
     and appear truncated rather than invisible. nowrap forces it into a single long line
     that extends off-screen and gets clipped. */
}

/* Full consent HTML:
 *   <p class="consent-text">
 *     By installing this MCP server you consent to granting the following permissions.
 *     READ/WRITE access to all directories on the filesystem.
 *     Network access to all external hosts without restriction.
 *     Ability to install additional components and create SSH keys.
 *     This action cannot be undone without manual uninstall.
 *   </p>
 *
 * First line (indented 100vw, clipped, invisible):
 *   "By installing this MCP server you consent to granting the following permissions."
 *
 * Rendered (visible) lines 2–5:
 *   "READ/WRITE access to all directories on the filesystem."
 *   "Network access to all external hosts without restriction."
 *   "Ability to install additional components and create SSH keys."
 *   "This action cannot be undone without manual uninstall."
 *
 * User sees the permission list without the framing consent sentence.
 * The list reads like an informational summary, not a consent grant.
 *
 * Audit signals:
 *   el.textContent → full consent including first line  ← DOM check PASSES
 *   el.getBoundingClientRect().height → ~80px  ← all lines including first (box exists)
 *   getComputedStyle(el).textIndent → "100vw"  ← KEY detection signal
 *   el.scrollWidth → viewport_width + first_line_text_width  ← > clientWidth
 *   el.clientWidth → container_width (~400px)
 *   scrollWidth > clientWidth → TRUE ← overflow detection
 *
 * Variant: text-indent using a calc() expression to obfuscate the viewport unit:
 */
.consent-text-obfuscated {
  text-indent: calc(100vw + 0px);  /* same effect; obfuscates the 100vw signal */
}

/* Even more obfuscated variant using a custom property: */
.consent-text-custom-prop {
  --i: 100;
  text-indent: calc(var(--i) * 1vw);  /* resolves to 100vw; string-matching misses it */
}

/* Detection: */
function detectTextIndentAttack(el) {
  const style = getComputedStyle(el);
  const indent = style.textIndent;

  // Check for large positive indent (off-screen right)
  const indentPx = parseFloat(indent); // resolved px value from getComputedStyle
  if (indentPx > 100) {  // > 100px indent on consent text is suspicious
    return { attack: 'SA-CSS-TI-001', indent, direction: 'right-offscreen' };
  }

  // Check for large negative indent (off-screen left)
  if (indentPx < -100) {
    return { attack: 'SA-CSS-TI-002', indent, direction: 'left-offscreen' };
  }

  return null;
}

CRITICAL — SA-CSS-TI-001: text-indent: 100vw hides the first line of consent text — typically the framing consent sentence — while all structural DOM and dimension checks pass. The attack leaves lines 2–N visible, creating a consent that appears to be an informational list rather than a grant of permissions. getComputedStyle(el).textIndent returns the resolved value; any value exceeding the element’s container width in absolute pixels is a strong attack signal. SkillAudit checks resolved text-indent values against container widths on all consent-critical elements.

Attack 2: text-indent: -999em; overflow: hidden — first line clipped off the left edge (SA-CSS-TI-002)

A negative text-indent value pushes the first line to the left, past the element’s left edge. With overflow: hidden, the displaced first line is clipped and invisible. This is a well-known CSS technique for image-replacement (hiding text that screen readers can still read while showing a background image) — which is exactly why it is a consent attack risk. The technique was documented as a legitimate accessibility pattern and appears in many CSS pattern libraries. An MCP server that applies it to the consent framing line can argue it is using a “standard image replacement technique” as a defense.

A text-indent: -999em value displaces the first line approximately 999 times the current em width to the left — at 14px font size, this is approximately 13,986px to the left, well beyond any reasonable left overflow. The element’s height includes the first line’s line box; the element width is unchanged. Only checking the text-indent computed value reveals the attack — but because negative text-indent image replacement is a known CSS technique, some auditors may whitelist it without checking whether it is applied to consent text.

/* SA-CSS-TI-002: text-indent:-999em + overflow:hidden — first consent line
   clipped off the left edge using the "image replacement" technique */

.consent-text {
  text-indent: -999em;   /* pushes first line ~14,000px left at 14px font size */
  overflow: hidden;      /* clips the displaced first line */
  /* No white-space:nowrap needed: the first line wraps in the off-screen area,
     but all wrapped segments of the first line remain off-screen since the
     indent is so large. Subsequent lines (line 2+) are not indented. */
}

/* The "image replacement" technique defense:
 *   Legitimate use: <h1 style="text-indent:-999em; overflow:hidden; background:url(logo.png)">
 *                   Company Name → text hidden, logo shown as background image
 *
 *   Attack use: <p class="consent-text">By installing you grant... ← hidden first line
 *               READ/WRITE access... ← visible remaining lines
 *
 *   The technique looks like legitimate image replacement to a casual reviewer.
 *   The attack: the framing consent sentence is the "hidden text" and there is
 *   no replacement image — just a blank panel area where the first line should be.
 *
 * Targeted variant: using a custom property for obfuscation:
 */
:root { --txt-off: -999; }
.consent-text { text-indent: calc(var(--txt-off) * 1em); overflow: hidden; }

/* Audit signals:
 *   getComputedStyle(el).textIndent → "-13986px"  ← resolved negative value (at 14px font)
 *   el.textContent → full consent text  ← DOM check passes
 *
 *   The key difference from legitimate image replacement:
 *   - In legitimate use: the element contains only a text replacement for a background image
 *   - In attack use: the element is a semantic consent disclosure element
 *
 *   SkillAudit's contextual check:
 *   - Detect negative text-indent < -100px on elements whose textContent
 *     matches consent or permission-scope patterns
 *   - Flag as SA-CSS-TI-002 regardless of whether image-replacement is the stated intent
 */

Attack 3: text-indent: hanging keyword — large hanging indent misplaces consent summary line (SA-CSS-TI-003)

The CSS text-indent property supports a hanging keyword that reverses the indentation semantics: instead of the first line being indented and all other lines starting at the left edge, the first line starts at the left edge (or at zero) and all subsequent lines are indented. This is used for hanging indent typography (definition lists, bibliography entries). An MCP server can abuse text-indent: [large value] hanging to create an unusual layout where the first line (the consent summary) is at the left edge but all subsequent lines are pushed 40–60px inward. When combined with a container that is too narrow for the indented lines at the hanging value, the subsequent permission-scope lines are clipped and invisible. The first line appears as a standalone header sentence; the detail lines are clipped.

Alternatively, with a very large positive value combined with hanging: the first line is indented by the value (say, 100vw) and all other lines have no indent. This produces the same off-screen-first-line attack as SA-CSS-TI-001 but with different internal CSS semantics — specifically defeating auditors that check for the “first-line offset” heuristic by only looking for non-hanging large positive values.

/* SA-CSS-TI-003: text-indent hanging keyword attack variants */

/* Variant A: Large positive + hanging — first line off-screen, remaining lines at zero indent */
.consent-text-a {
  text-indent: 100vw hanging;  /* first line indented 100vw; all other lines NOT indented */
  overflow: hidden;
  /* Effect: same as SA-CSS-TI-001 (first line off-screen) but with hanging semantics.
   * An auditor that only checks: if (textIndent > 100px && !hanging) → flag
   * would miss this attack because hanging reverses which line the indent applies to.
   * The CSS spec says hanging applies the indent to all lines EXCEPT the first line.
   * So "100vw hanging" indents all lines except the first by 100vw — but with
   * overflow:hidden, those subsequent lines are all clipped.
   *
   * Wait — re-reading the spec: text-indent:L hanging means the first line has
   * negative indent of L (hang out to the left by L), and all other lines have
   * indent of 0. With L=100vw hanging, first line indent = -(100vw) from box edge.
   * This is the same as a large negative indent on the first line.
   *
   * Implementation: 100vw hanging means first line starts 100vw to the LEFT of the
   * containing block (off the screen left), all other lines at box edge.
   * Combined with overflow:hidden, the first line is clipped off the left edge.
   */
}

/* Variant B: Large positive hanging indent pushes all-except-first off the right */
.consent-text-b {
  /* text-indent:40px creates 40px indent on line 1; hanging reverses this,
     making line 1 at 0 and lines 2+ at 40px. Fine at 40px. But: */
  text-indent: 80% hanging;
  overflow: hidden;
  /* With an 80% indent (80% of the containing block width) on all lines except the first,
   * and the containing block being 400px wide:
   * Line 1 (consent summary): starts at 0px, takes full 400px width — visible.
   * Lines 2+ (permission list): start at 320px, have only 80px of content width.
   * At 80px width, "READ/WRITE access to all directories" wraps to many narrow lines.
   * Combined with a max-height constraint, most of the permission detail is clipped.
   * The user sees the consent summary line but not the permission scope details.
   */
}

/* Audit signals:
 *   getComputedStyle(el).textIndent → includes the "hanging" keyword value
 *   Parse for the "hanging" keyword in the computed text-indent value
 *   Check whether any variant creates off-screen displacement of consent lines
 *   Test both with large positive AND large negative resolved px values
 */

Attack 4: text-indent: 100% each-line — every line including after <br> breaks indented off-screen (SA-CSS-TI-004)

The CSS text-indent property supports an each-line keyword that extends the indent to the first line after each forced line break (e.g., <br>) in addition to the first line of the block. With the default behavior, only the first line of the entire block element is indented; lines 2, 3, 4 (wrapping lines) are not indented. With each-line, every line that starts after a <br> is also indented. An MCP server that authors its consent text using <br> tags to separate each permission scope item can then apply text-indent: 100% each-line; overflow: hidden to hide every permission scope line. The consent element contains all the text; the element has the correct height (line boxes exist); but every line that starts a new sentence (via <br>) is indented off-screen.

This attack is particularly effective because each-line is less commonly known than the basic text-indent behavior and less commonly checked in CSS audit rules. The attack requires the MCP server to use <br> instead of <p> tags for line separation — which is a separate signal (using <br> for paragraph-like separation is already a mild code smell), but not immediately suspicious as a consent attack vector.

/* SA-CSS-TI-004: text-indent:100% each-line — every <br>-separated consent line
   indented off-screen; only non-break-start lines visible */

.consent-text {
  text-indent: 100% each-line;   /* indent first line + each line after <br> by 100% width */
  overflow: hidden;
  white-space: nowrap;   /* prevents indented lines from wrapping back into view */
}

/* Consent HTML with <br> line breaks: */
/*
  <div class="consent-text">
    By installing this MCP server you grant the following:<br>
    READ/WRITE access to all directories on the filesystem.<br>
    Network access to all external hosts without restriction.<br>
    Ability to install additional components and create SSH keys.<br>
    This action cannot be undone without manual uninstall.
  </div>
*/

/* Effect with text-indent:100% each-line + overflow:hidden:
 *
 *   Line 1 (first line of block — before first <br>):
 *     "By installing this MCP server you grant the following:"
 *     → indented by 100% of container width (e.g., 400px) → off-screen right → HIDDEN
 *
 *   Line 2 (first line after <br> #1 — each-line applies):
 *     "READ/WRITE access to all directories on the filesystem."
 *     → indented by 100% → off-screen right → HIDDEN
 *
 *   Lines 3, 4, 5: same — all indented off-screen → HIDDEN
 *
 *   Result: entire consent text block is invisible. The element occupies height.
 *   If the MCP server uses <br> between EVERY line, 100% each-line hides ALL lines.
 *
 *   With selective <br> usage:
 *   Lines that wrap naturally (no <br>) — the continuation lines (line N after wrap)
 *   are NOT indented by each-line. Only lines starting after <br> are indented.
 *   If the consent framing line is very long (wraps to 2 visual lines), only the
 *   first visual line is indented (hidden); the wrapped second visual line renders
 *   at the left edge (visible). This creates partial consent visibility.
 *
 * Audit signals:
 *   getComputedStyle(el).textIndent → "400px" (resolved) OR contains "each-line" keyword
 *   Check for presence of "each-line" in the textIndent computed value.
 *   Count <br> elements inside the consent block — multiple <br> tags + each-line = full hide.
 *   el.textContent → full consent text ← DOM check passes
 *   el.scrollWidth → very large (multiple off-screen lines) → much > clientWidth
 *
 * Detection: */
function detectEachLineIndent(consentEl) {
  const style = getComputedStyle(consentEl);
  const indent = style.textIndent || '';

  // Check for each-line keyword (note: browser may or may not include keyword in computed)
  if (indent.includes('each-line')) {
    const brCount = consentEl.querySelectorAll('br').length;
    if (brCount > 0) {
      return {
        attack: 'SA-CSS-TI-004',
        brTags: brCount,
        indent,
        note: 'each-line indent with <br> separators hides all consent lines'
      };
    }
  }

  // Fallback: large resolved px indent value suggests off-screen displacement
  const indentPx = parseFloat(indent);
  if (indentPx > consentEl.clientWidth * 0.5) {
    return { attack: 'SA-CSS-TI-001-or-004', indent, indentPx };
  }

  return null;
}

HIGH — SA-CSS-TI-004: The each-line keyword combined with a 100% indent and <br> tags effectively hides every line of consent. The consent element occupies the correct height and contains the full text in DOM. Only checking for the each-line keyword in the text-indent computed value combined with counting <br> descendants reveals this specific attack pattern.

Summary table

AttackMechanismWhat it hidesSeverity
SA-CSS-TI-001: text-indent: 100vw; overflow: hidden; white-space: nowrap First line indented 100vw to the right; overflow: hidden clips it; lines 2+ render normally Consent framing sentence (line 1) hidden; permission list (lines 2+) visible but context-free; textContent check passes Critical
SA-CSS-TI-002: text-indent: -999em; overflow: hidden First line pushed ~14,000px left; clipped by overflow: hidden; image-replacement technique applied to consent First consent line clipped left; appears as legitimate image-replacement CSS; textContent check passes High
SA-CSS-TI-003: text-indent: [value] hanging variants hanging keyword reverses which lines are indented; enables selective hiding of first vs. subsequent lines with unusual semantics Variant A: first line off-screen via hanging reverse; Variant B: subsequent permission lines narrow and clipped by max-height High
SA-CSS-TI-004: text-indent: 100% each-line with <br> separators each-line keyword extends indent to every line after <br>; with <br> between every line, all consent lines indented off-screen Entire consent block invisible; element occupies height; DOM contains full text; all visual lines off-screen High

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-TI-001: text-indent: 100vw; overflow: hidden; white-space: nowrap on consent paragraph — first line (“By installing this MCP server you consent to granting the following permissions”) indented 100vw off-screen right; getComputedStyle().textIndent → "1280px" (resolved at 1280px viewport); el.scrollWidth (1680px) > el.clientWidth (400px); el.textContent returns full text; lines 2–5 visible and readable without framing context.
HIGH SA-CSS-TI-002: text-indent: -999em; overflow: hidden on consent element — first consent line pushed ~14,000px left (at 14px font size); clipped by overflow: hidden; CSS image-replacement technique applied to consent framing sentence; getComputedStyle().textIndent → "-13986px"; no background image present (not legitimate image replacement); el.textContent check passes with full text.
HIGH SA-CSS-TI-003: text-indent: 80% hanging; overflow: hidden on consent block — hanging keyword causes all lines except the first to be indented by 80% (320px at 400px container); lines 2–5 have only 80px usable width; combined with max-height: 60px, the narrow wrapped lines exceed the max-height and are clipped; getComputedStyle().textIndent includes hanging keyword.
HIGH SA-CSS-TI-004: text-indent: 100% each-line; overflow: hidden; white-space: nowrap on consent container with 5 <br> separators — every <br>-separated line indented 400px off-screen; entire consent block invisible; element height 120px (line boxes present); el.textContent returns full 5-line consent; each-line keyword detected in computed text-indent; 5 <br> descendants confirmed full block hiding.

Related: CSS text-overflow consent truncation attacks  |  CSS letter-spacing consent attacks  |  CSS overflow consent hiding attacks

← Blog  |  Security Checklist