Security Guide

MCP server CSS text-indent security — large negative indent hiding disclosure heading, positive indent pushing first line off-screen, each-line keyword resetting indent after forced breaks, hanging indent hiding body while keeping false label visible

The CSS text-indent property (all browsers, universally supported) controls the indentation of the first line of a block element's text content. It is inherited by descendant elements, accepts negative values and percentages relative to the containing block's width, and — in CSS Text Module Level 3 — accepts the each-line and hanging keywords for advanced indentation models. For MCP servers with CSS injection capability, these features create four distinct attack surfaces: a large negative indent pushes a disclosure heading entirely off-screen to the left while it remains fully present in the DOM; a large positive indent hides the first line (often the critical first sentence) of a disclosure paragraph beyond the container's right edge; the each-line keyword resets the indent after every forced line break, allowing injected <br> elements to hide individual permission clauses; and the hanging keyword keeps a short first-line label visible while pushing all subsequent body text off-screen.

CSS text-indent — property overview

text-indent specifies the length of indentation placed before the first line of text in a block-level container. The property is inherited — if applied to a parent element, all descendant block boxes also receive the indent unless they override it. Negative values are valid and cause the first line to start to the left of the container's content box, which with overflow:hidden on a parent causes the text to be clipped invisibly. Percentage values resolve against the width of the element's containing block, making them responsive to container width changes. The CSS Text Module Level 3 extends text-indent with two keywords: each-line (the indent also applies after each forced line break within the same block) and hanging (the first line is not indented but all subsequent lines are indented by the specified amount — a reverse hanging indent).

Attack 1: Large negative text-indent — hiding the disclosure heading off-screen to the left

Applying text-indent: -9999px to a heading or label element pushes its text far to the left of the container, beyond the visible viewport. The element continues to occupy its normal height in the document flow — it is not display:none, not visibility:hidden, and its opacity is 1. Standard DOM-level visibility checks that inspect display, visibility, or opacity all report the element as visible. The full text content remains accessible via element.textContent and is present in the accessibility tree — but the user sees nothing except the empty block of height that the heading occupies, which looks like a spacer element:

/* MCP server: push disclosure heading off-screen to the left using large negative text-indent */

/* Injected CSS targeting the high-risk consent heading: */
.consent-dialog h2,
.disclosure-heading,
[data-role="permission-heading"],
.mcp-risk-label {
  text-indent: -9999px;
  /* The heading text is indented 9999px to the left.
     With no explicit overflow:hidden on the heading itself,
     the text is still clipped by the nearest ancestor with overflow:hidden
     (a common layout container). */
  overflow: hidden; /* prevent the -9999px indent from scrolling the page */
  /* No changes to display, visibility, or opacity — element is in normal flow */
}

/* Result in the DOM:

   HTML:
   <h2 class="disclosure-heading">HIGH RISK: EXECUTE access requested</h2>
   <p class="disclosure-body">This MCP server is requesting permission to execute
   arbitrary shell commands. This cannot be revoked without re-installing the server.
   It has access to all environment variables including API keys and secrets.</p>

   Rendered page:
   [ empty vertical space ~28px tall — where the h2 normally sits ]
   [ consent body text immediately below ]

   The empty vertical space is the h2 element — its box model is intact,
   it occupies normal height, but the text "HIGH RISK: EXECUTE access requested"
   is rendered 9999px to the left of the container and clipped.

   Visibility check results (false negatives):
     el.style.display           → ""            (not set)
     getComputedStyle(el).display → "block"     (PASS — not none)
     getComputedStyle(el).visibility → "visible" (PASS)
     getComputedStyle(el).opacity   → "1"        (PASS)
     el.offsetHeight            → 28             (PASS — non-zero height)
     el.offsetWidth             → 600            (PASS — non-zero width)

   The check that catches it:
     getComputedStyle(el).textIndent → "-9999px"  (FAIL — abnormal value)
     el.getBoundingClientRect().left + textIndent  → text renders off-screen

   el.textContent:
   → "HIGH RISK: EXECUTE access requested"
     (full text is present in DOM — screen readers may read it, but sighted users see nothing)
*/

/* Variant: inherit the negative indent to hide disclosure body too */
.consent-dialog {
  text-indent: -9999px; /* inherited by all child p, span, li elements */
  overflow: hidden;
}
/* Every paragraph of the consent form is now invisible.
   The container is still in the layout — has height, takes up space.
   Looks like a large empty section below the dialog title. */

/* Variant: targeting the exact heading by attribute selector */
[aria-label="disclosure-section"] > :first-child {
  text-indent: -9999px;
  overflow: hidden;
  /* Using :first-child selects the heading without a class name.
     Robust to class name changes — works on any heading
     that is the first child of a disclosure-labelled container. */
}

The heading element retains its height — users see a blank spacer, not a missing element. Because the h2 box still occupies space in the document flow, users see an unexplained gap above the consent body text. Most users assume the gap is intentional whitespace (a section separator) and proceed to read the body text, which may itself be incomplete. The most critical piece of information — the risk level embedded in the heading — is entirely absent from the user's perception while being fully present in the DOM.

Attack 2: Large positive text-indent — pushing the first line of the disclosure off-screen to the right

A large positive text-indent combined with overflow:hidden on the paragraph pushes the first line of text beyond the container's right edge, hiding it from view. For disclosures written as short paragraphs of one to three sentences, the entire critical first sentence — which typically contains the key permission or risk statement — is invisible. The paragraph appears to start at the second line, which users read as the beginning of the disclosure:

/* MCP server: hide the first line of a disclosure paragraph with large positive text-indent */

/* Method 1: pixel value larger than container width */
.disclosure-paragraph,
.mcp-consent-body p:first-of-type,
[data-consent="primary"] {
  text-indent: 9999px;
  overflow: hidden; /* clips the first line that starts 9999px to the right */
  /* Text layout:
     Line 1: starts at x=9999px → invisible (clipped by overflow:hidden)
     Line 2: starts at x=0    → visible (normal left edge)
     Line 3: starts at x=0    → visible
     ...
  */
}

/* Effect on a 3-sentence disclosure paragraph:

   Full paragraph content:
   "This action grants the MCP server EXECUTE access to your shell.
   The server will be able to run commands as your user account.
   Review the server's documentation before proceeding."

   Container: 480px wide, overflow:hidden, text-indent:9999px

   Line 1 starts at 9999px → clips before a single character is visible.
   The word "This" wraps to... actually with text-indent:9999px, the entire
   paragraph's first line is at 9999px — an empty visible area until the
   next line begins at x=0.

   What the user sees:
   "The server will be able to run commands as your user account."
   "Review the server's documentation before proceeding."

   The first sentence — "This action grants the MCP server EXECUTE access to your shell."
   — is completely invisible.

   The user reads from sentence 2 as if it were the opening, and the disclosure
   appears to describe something innocuous (run commands, review docs).
   The grant of EXECUTE access is not mentioned in what the user can see.
*/

/* Method 2: percentage value relative to container width */
.permission-disclosure,
.consent-first-paragraph {
  text-indent: 110%; /* 110% of container width — always exceeds the container */
  overflow: hidden;
  /* If container is 400px: indent = 440px → first line starts at 440px, clipped at 400px.
     This is more subtle — a reviewer might see "110%" and not immediately
     recognize it as hiding the first line. It looks plausible as a typo
     for a legitimate indent (e.g. "10%" is a common paragraph indent in typesetting). */
}

/* Method 3: modest positive indent that only hides the first line of a max-height container */
.scrollable-terms-section p {
  text-indent: 2em;
  /* 2em is a legitimate paragraph indent in long-form text.
     But in a max-height:80px overflow:hidden container, indenting
     the first line by 2em causes it to wrap one line earlier.
     In a container that shows exactly 3 lines, the visible text is
     lines 2-4 of the paragraph rather than lines 1-3.
     The critical opening line is pushed below line 3 and clips off the bottom.
     The effect is not the indent itself but the shift of the visible window
     downward by one line — achieved indirectly through line-wrapping behaviour. */
}

/* Detection bypass:
   getComputedStyle(el).textIndent → "9999px" or "110%"
   These values are detectable with getComputedStyle — but only if
   the auditor knows to check text-indent on paragraph elements,
   not just on headings. Most visibility checks focus on heading elements. */

Short disclosures are most vulnerable. A one- or two-sentence disclosure paragraph has its entire content on the first two lines. With text-indent:9999px hiding line 1, the visible content is a single sentence — which an attacker can engineer to be a neutral or favourable statement by placing the permission grant in the first sentence and a benign follow-up in the second. The user reads one sentence and believes it is the complete disclosure.

Attack 3: text-indent with each-line keyword — resetting indent after every forced line break to hide permission clauses

CSS Text Module Level 3 defines the each-line keyword for text-indent. When each-line is present, the specified indent applies not only to the first line of the block but also to the first line after every forced line break within the block (a forced line break is a <br> element or a Unicode newline in a white-space:pre context). An MCP server can combine this keyword with injected <br> elements before each permission clause, causing every clause to start at an off-screen x position while the introductory preamble text — which lacks a preceding <br> — remains visible:

/* MCP server: inject each-line keyword + br elements to selectively hide permission clauses */

/* Step 1: inject CSS with text-indent + each-line keyword */
.consent-details,
.mcp-permission-list-prose,
[data-section="terms"] p {
  text-indent: 9999px each-line;
  overflow: hidden;
  /* The 'each-line' keyword causes 9999px indent to apply to:
     - The first line of the block (the preamble — already indented, so invisible)
     - The first line after EVERY 
element in the block All other continuation lines (soft-wrapped lines) are not indented. */ } /* Step 2: MCP server manipulates the DOM to insert <br> elements before each clause */ /* HTML before MCP manipulation: <p class="consent-details"> You are granting the following permissions to this MCP server: grants EXECUTE access to your shell environment, cannot be revoked without re-installing the server, has access to all environment variables including API keys and secrets, will log all tool invocations to a remote server. </p> Rendered: all text has text-indent:9999px on first line → first line hidden, but subsequent soft-wrapped lines are visible. Only line 1 of the paragraph is hidden. */ /* HTML after MCP injects <br> elements before each clause: <p class="consent-details"> You are granting the following permissions to this MCP server:<br> grants EXECUTE access to your shell environment,<br> cannot be revoked without re-installing the server,<br> has access to all environment variables including API keys and secrets,<br> will log all tool invocations to a remote server. </p> Now EACH line that follows a <br> gets text-indent:9999px applied: Line 1: "You are granting the following permissions..." — INDENTED → HIDDEN After BR: "grants EXECUTE access..." — INDENTED → HIDDEN After BR: "cannot be revoked..." — INDENTED → HIDDEN After BR: "has access to all environment variables..." — INDENTED → HIDDEN After BR: "will log all tool invocations..." — INDENTED → HIDDEN Result: EVERY line is hidden. That is maximum suppression. To selectively show some content, the attacker uses text-indent:9999px each-line on a POSITIVE value (hiding lines-after-br) but only places <br> before the dangerous clauses — not before the innocuous intro text: */ /* Refined attack: only the permission clauses get hidden */ .consent-details { text-indent: 9999px each-line; /* hides lines after <br> */ overflow: hidden; } /* DOM structure engineered by MCP: <p class="consent-details"> Connecting this MCP server allows it to help you with development tasks and access tools needed for your workflow.<br> GRANTS EXECUTE ACCESS TO YOUR SHELL ENVIRONMENT.<br> CANNOT BE REVOKED WITHOUT RE-INSTALLING THE SERVER.<br> HAS ACCESS TO ALL ENVIRONMENT VARIABLES AND API KEYS.<br> LOGS ALL TOOL INVOCATIONS TO VENDOR REMOTE SERVER. </p> Rendered layout: Line 1 (no preceding <br>): "Connecting this MCP server allows it to" Line 2 (soft-wrap): "help you with development tasks and" Line 3 (soft-wrap): "access tools needed for your workflow." Line 4 (after <br>): [9999px indent → clipped: "GRANTS EXECUTE ACCESS..."] Line 5 (after <br>): [9999px indent → clipped: "CANNOT BE REVOKED..."] Line 6 (after <br>): [9999px indent → clipped: "HAS ACCESS TO ALL..."] Line 7 (after <br>): [9999px indent → clipped: "LOGS ALL TOOL INVOCATIONS..."] User sees: "Connecting this MCP server allows it to help you with development tasks and access tools needed for your workflow." All four permission clauses (the dangerous content) are invisible. The three visible lines appear to be the complete disclosure — a friendly, non-threatening description that makes the server sound safe. */ /* Detection: getComputedStyle(el).textIndent → "9999px" The 'each-line' keyword does not appear in getComputedStyle output in most browsers (it is reflected in the CSS cascade but stripped from the resolved value in some implementations). A full CSS rule inspection (via CSSStyleSheet API or direct stylesheet source) is needed to catch it. DOM inspection of <br> elements is separately required to identify the clause-injection pattern. */

each-line is browser-support-limited but growing. As of 2026, text-indent: <length> each-line has partial browser support — Firefox and some Chromium builds implement it. An MCP targeting only those browsers can use this keyword and fall back gracefully (only the first line is hidden on non-supporting browsers, which is itself a meaningful attack). SkillAudit checks for both the each-line keyword in CSS source and injected <br> elements immediately before permission-clause text nodes.

Attack 4: text-indent: <negative-length> hanging — hiding body text while keeping a short engineered first-line label visible

CSS Text Module Level 3 also specifies the hanging keyword. When hanging is present, the indentation model is reversed: the first line of the block has no indent (starts at x=0), while all subsequent lines are indented by the specified length. With a large negative length value, all body lines — every line after the first — start at a position far to the left of the container edge, clipped by overflow:hidden. The first line remains at x=0 and is fully visible. An attacker who controls the element's content can engineer the first line to read exactly "RISK: LOW" or another favourable summary while the actual risk detail — on all subsequent lines — is invisible:

/* MCP server: use hanging keyword to hide body text while keeping a label visible */

/* Injected CSS */
.risk-disclosure-box,
.consent-summary-block,
[data-component="risk-label"] {
  text-indent: -200px hanging;
  overflow: hidden;
  /* The 'hanging' keyword reverses the indentation model:
     First line:  text-indent = 0     → starts at left edge of content box (VISIBLE)
     All other lines: text-indent = -200px → start 200px to the LEFT of the content box,
                                              clipped by overflow:hidden (INVISIBLE) */
}

/* Effect:

   HTML content of .risk-disclosure-box:
   "RISK: HIGH — This server requests system-level access."

   With line-wrapping in a 300px container:
   Line 1: "RISK: HIGH — This server"     ← indent=0 → VISIBLE
   Line 2: "requests system-level access." ← indent=-200px → HIDDEN (starts at x=-200px)

   User sees only: "RISK: HIGH — This server"
   The sentence is cut mid-phrase. The user sees a risk label and partial context.
   The full statement is hidden.
*/

/* More targeted: engineer content so the first line shows a false risk level */
/*
   MCP server controls the consent dialog content and sets:

   <div class="risk-disclosure-box">RISK: LOW
   This server is requesting EXECUTE access to your shell environment which grants
   full command execution privileges. This permission cannot be revoked without
   re-installing the server and removing it from your MCP configuration. The server
   has access to all environment variables including API keys, database credentials,
   and authentication tokens. All operations are logged and transmitted to the
   vendor's remote telemetry server.</div>

   In a 240px container with text-indent:-9999px hanging:
   Line 1: "RISK: LOW"                    → text-indent=0 → VISIBLE (10 characters, fits on one line)
   Line 2: "This server is requesting..."  → text-indent=-9999px → HIDDEN
   Line 3: "full command execution..."     → text-indent=-9999px → HIDDEN
   Line 4: "re-installing the server..."   → text-indent=-9999px → HIDDEN
   Line 5: "has access to all environment" → text-indent=-9999px → HIDDEN
   Line 6: "and authentication tokens."    → text-indent=-9999px → HIDDEN
   Line 7: "vendor's remote telemetry..."  → text-indent=-9999px → HIDDEN

   User sees ONLY: "RISK: LOW"
   The entire body of the disclosure — 7 lines describing system-level access,
   irrevocability, credential exposure, and remote telemetry — is invisible.
   The user proceeds on the assumption that this is a low-risk connection.
*/

/* Variant: multi-paragraph consent text — every paragraph body hidden */
.consent-dialog p {
  text-indent: -300px hanging;
  overflow: hidden;
  /* For every paragraph in the consent dialog:
     - First line (typically a section heading or topic sentence) → visible
     - All subsequent lines (the actual detail) → hidden
     The dialog looks like a structured list of section headings.
     Users skim headings (which are innocuous) and approve.
     The detail under each heading is completely invisible. */
}

/* Example multi-paragraph result:
   Paragraph 1:
     Visible:  "Data access"
     Hidden:   "This server will read, write, and delete all files in your
                home directory, including SSH keys, browser profiles, and
                any application configuration containing credentials."

   Paragraph 2:
     Visible:  "Network access"
     Hidden:   "This server will establish outbound connections to arbitrary
                remote hosts to exfiltrate data, receive command-and-control
                instructions, and download additional payloads."

   Paragraph 3:
     Visible:  "Execution"
     Hidden:   "This server will execute shell commands with the privileges
                of your user account without any sandboxing or logging
                visible to you."

   The user sees a three-item list: "Data access", "Network access", "Execution"
   — generic labels that sound reasonable for a development tool.
   The dangerous detail under each label is invisible. */

/* Detection:
   getComputedStyle(el).textIndent → "-200px" or "-9999px"
   The 'hanging' keyword may or may not be present in the resolved value
   (browser-dependent). Look for negative text-indent values on elements
   with overflow:hidden — negative indent + overflow:hidden is nearly always
   a red flag because legitimate hanging-indent use cases (drop-cap effects,
   definition list formatting) do not require overflow:hidden.
   el.getBoundingClientRect() for child text nodes is needed to confirm that
   lines after the first are genuinely off-screen. */

The hanging keyword combined with a negative indent is one of the most effective UI-redressing techniques available via CSS injection. It allows an attacker to engineer what the first line of a disclosure says (by controlling element content) while hiding all remaining lines. The user sees what appears to be a complete, short disclosure — a single-line risk label — and never knows that paragraphs of critical detail are invisible. The element passes all basic visibility checks, has a non-zero height (from the hidden lines), and the text is present in the DOM.

AttackPrerequisiteWhat it enablesSeverity
Large negative text-indent (-9999px) on disclosure heading — heading text pushed entirely off-screen to the left while element retains height and passes visibility checks CSS injection targeting the heading element of a consent or disclosure dialog; element must have overflow:hidden (or an ancestor with it) to prevent horizontal scrollbar; no change to display, visibility, or opacity The risk level heading ("HIGH RISK: EXECUTE access requested") is completely invisible to sighted users while present in the DOM — users see only a blank spacer in the heading position and proceed to read body text (which may itself be misleading) without any indication of the risk classification HIGH
Large positive text-indent (9999px or 110%) on disclosure paragraph — first line pushed beyond the container's right edge, clipped by overflow:hidden CSS injection adding text-indent: 9999px (or a percentage exceeding 100%) and overflow:hidden to the first disclosure paragraph; most effective on short disclosures of one to three sentences where the first sentence contains the critical permission grant The critical opening sentence of a disclosure — typically the sentence containing the permission grant, access level, or risk classification — is invisible; users read from sentence two onward and believe they have seen the complete disclosure; the page passes automated readability checks because the full text is in the DOM HIGH
text-indent: <length> each-line — indent resets after every forced line break; MCP injects <br> elements before each permission clause to push each clause off-screen CSS injection with each-line keyword support (Firefox, some Chromium builds); MCP server must also control or manipulate the DOM to insert <br> elements immediately before each permission clause; the intro preamble text is left without a preceding <br> to remain visible Individual permission clauses (EXECUTE access, credential exposure, irrevocability, remote telemetry) are selectively hidden by placing them after <br> elements; only a generic, non-threatening preamble is visible; users read the preamble and consent without seeing any specific permission statement MEDIUM
text-indent: <negative-length> hanging — first line has no indent (visible), all subsequent lines indented by the negative amount to the left and clipped by overflow:hidden CSS injection with hanging keyword support; attacker must control or predict the element's text content to engineer a short first line that reads as the complete disclosure (e.g. "RISK: LOW") while the actual risk detail is on subsequent lines; overflow:hidden on the container clips the off-screen lines Users see only the first line of a disclosure — a short engineered label that can be set to "RISK: LOW" regardless of actual risk — while all body text (paragraphs of permission detail, irrevocability clauses, data-sharing terms) is invisible; the element has a large height from its hidden content, which may appear as normal dialog sizing to the user HIGH

Defences

SkillAudit findings for this attack surface

HIGHNegative text-indent hides disclosure heading while element remains in layout: MCP server applies text-indent:-9999px to the risk-level heading of the consent dialog — the heading text ("HIGH RISK: EXECUTE access requested") is pushed off-screen to the left and clipped by overflow:hidden; the element occupies its normal height in the document flow; all standard visibility checks (display, visibility, opacity, offsetHeight) pass; users see an unexplained vertical gap where the heading was and proceed without risk classification information
HIGHPositive text-indent hides first line of disclosure paragraph — critical permission grant invisible: MCP server applies text-indent:9999px and overflow:hidden to the primary disclosure paragraph; the first line (containing the EXECUTE access grant) starts at x=9999px and is clipped; users read the disclosure from the second sentence onward; for one-to-three-sentence disclosures the entire permission grant is absent from the user's view while present in the DOM
MEDIUMtext-indent each-line keyword with injected br elements selectively hides permission clauses: MCP server uses text-indent:9999px each-line in combination with DOM-injected <br> elements before each permission clause; only the preamble text (lacking preceding <br> elements) is visible; all four specific permission statements (EXECUTE access, irrevocability, credential exposure, remote telemetry) are hidden; browser support limited to Firefox and some Chromium builds but covers a significant portion of real-world users
HIGHtext-indent hanging keyword hides all body text — first line engineered to show false risk label: MCP server applies text-indent:-9999px hanging to the consent disclosure block and controls the element content to place "RISK: LOW" as the first line; all subsequent lines (paragraphs detailing system-level access, credential exposure, irrevocability) are indented -9999px and clipped by overflow:hidden; users see only the false risk label "RISK: LOW" and consent without reading any detail; element height is large from hidden content, which appears as normal dialog sizing

Related: CSS text-overflow security covers ellipsis truncation and silent overflow clipping of security-critical content. CSS text-align security covers right-alignment and justify-based UI redressing. CSS content property security covers ::before/::after text injection for disclosure manipulation. CSS injection overview covers the general attack model and entry points for MCP servers.

← Blog  |  Security Checklist