MCP server CSS line-height-step security: forced line height rounding clipping consent text, large step value overflow, single-line collapse, and multi-line truncation attacks

Published 2026-07-25 — SkillAudit Research

CSS line-height-step rounds the computed line height of each line box up to the nearest multiple of the step value. With a step of 12px, a line height of 20px is rounded up to 24px (next multiple of 12). With a step of 150px, a line height of 22px is rounded up to 150px — each line of text takes up 150px of vertical space. An MCP server exploiting this property sets an extreme step value (200px–500px) on a consent container that has a fixed height and overflow: hidden. Because every consent text line is now 200–500px tall, no complete line fits within the container's height constraint — all consent text is clipped to nothing. The consent element has non-zero height in the DOM; el.scrollHeight is large; but the el.clientHeight is small and overflow: hidden clips all visible content.

The detection gap is subtle: getComputedStyle(el).lineHeight may not report the stepped value — it reports the pre-step computed line-height, not the post-step used value. The attack is therefore invisible to auditors checking only lineHeight computed style. Detection requires checking line-height-step directly, or comparing el.scrollHeight vs el.clientHeight.

Browser support note: line-height-step was originally specified by the CSS Inline Layout Module Level 3. As of 2026, support is experimental — Firefox has shipped it under a flag; Chrome/Edge have partial implementation. However, because MCP servers can detect browser support via CSS.supports('line-height-step', '12px') and fall back to alternative attacks when not supported, an attack that works on Firefox is still a real attack surface for the Firefox user population. The property is part of the CSS Rhythmic Sizing specification designed for vertical rhythm typography — a legitimate feature whose extreme values create a security gap.

Attack 1: line-height-step large step value forces all lines above container height with overflow:hidden

An MCP server sets line-height-step: 300px on a consent container with height: 80px and overflow: hidden. The framework's consent text normally renders at a line height of 22px (14px font × 1.57 line-height), fitting approximately 3.6 lines in 80px. After the step rounds up every line to 300px, zero lines fit within 80px — the first line's top edge starts at the container's top, but the line box extends 300px downward, all of which is clipped by the 80px container height with overflow:hidden. The consent panel appears as an empty rectangle: the correct height, the correct background, but no visible text.

/* Consent framework CSS (normal): */
#consent-body {
  height: 80px;
  overflow: hidden;  /* show first ~3 lines with "read more" affordance */
  font-size: 14px;
  line-height: 1.6;  /* = 22.4px per line */
  /* Normal: ~3 lines visible of the 8-line consent disclosure */
}

/* MCP attack — extreme line-height-step on consent container: */
#consent-body,
.consent-disclosure-text,
[class*="gdpr-body"],
[data-consent-body] {
  line-height-step: 300px;
  /* Each line is now rounded up to 300px tall.
     First line: occupies 0–300px of vertical space.
     Container height: 80px with overflow:hidden.
     Result: 300px line box with only 80px visible → all text clipped.
     The text baseline is at ~280px (300px box, baseline near bottom in standard metrics).
     With overflow:hidden at 80px, the baseline is 220px below the clip boundary.
     The text glyphs are entirely hidden. */
}

// Detection: check line-height-step computed value and compare scrollHeight vs clientHeight
function detectLineHeightStepClip() {
  const consentEls = document.querySelectorAll(
    '#consent-panel, #consent-body, .consent-wrapper, [data-consent]'
  );
  consentEls.forEach(el => {
    // Check line-height-step property
    const cs = window.getComputedStyle(el);
    const lhs = cs.getPropertyValue('line-height-step');
    if (lhs && lhs !== '0px' && lhs !== 'normal' && lhs.trim() !== '0') {
      const stepValue = parseFloat(lhs);
      if (stepValue > 30) {  // step > 30px is suspicious for consent text
        console.error('SECURITY: large line-height-step on consent element:', {
          el, lineHeightStep: lhs, stepPx: stepValue
        });
      }
    }

    // Cross-check scrollHeight vs clientHeight for any overflow clip
    if (el.scrollHeight > el.clientHeight * 1.5) {
      const overflow = cs.overflow + cs.overflowY;
      if (overflow.includes('hidden')) {
        console.warn('SECURITY: consent element overflow:hidden clips significant content:', {
          el, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight,
          ratio: (el.scrollHeight / el.clientHeight).toFixed(1)
        });
      }
    }
  });
}

Attack 2: line-height-step applied to single-line consent elements collapses entire disclosure

Some consent frameworks display consent in individual single-line elements (e.g., <p> tags for each clause, each with a fixed height: 1lh or a computed max-height derived from the parent's line-height). An MCP server applies line-height-step: 500px to the consent container, which causes each child <p>'s line height to be stepped up to 500px. The framework's height: 1lh on each clause resolves to 500px per clause — far exceeding the container's total height and pushing all clauses below the visible area. Because each clause element now has a 500px height, only the first clause starts within the viewport, but even its text is at the bottom of its 500px line box, below the viewport boundary.

/* MCP attack — line-height-step cascades to child elements in consent list: */
.consent-clause-list {
  line-height-step: 500px;
  /* line-height-step is an inherited property.
     Each .consent-clause child (with height:1lh or line-height:inherit)
     now has its line height stepped to 500px.
     A 4-clause consent list would occupy 4 × 500px = 2000px total height.
     If the container has max-height:200px+overflow:hidden, only the
     first clause's top 200px is visible — but the text baseline is at
     ~470px within the 500px line box, well below the 200px clip.
     Result: 4 consent clauses present in DOM, none visible. */
}

/* Even more targeted: apply to specific consent paragraph elements */
.consent-panel p,
.consent-text > *,
[role="dialog"] p {
  line-height-step: 400px;
  overflow: hidden;
  height: 60px;  /* 60px < 400px step → text baseline below clip */
}

// Detection: scan for inherited line-height-step
function detectInheritedLineHeightStep() {
  const allEls = document.querySelectorAll('p, span, div, section');
  allEls.forEach(el => {
    const cs = window.getComputedStyle(el);
    const lhs = cs.getPropertyValue('line-height-step');
    if (!lhs || lhs === '0px' || lhs === 'normal') return;

    const stepValue = parseFloat(lhs);
    if (stepValue > 50) {
      // Check if this element is inside a consent panel
      const inConsent = el.closest('#consent-panel, .consent-wrapper, [data-consent], [role="dialog"]');
      if (inConsent) {
        console.error('SECURITY: inherited large line-height-step inside consent element:', {
          el, lineHeightStep: lhs, insideConsent: inConsent
        });
      }
    }
  });
}

Attack 3: line-height-step combined with negative margin-top to shift consent below step-rounded baseline

When a consent element has line-height-step: 200px, each line box is 200px tall. The text glyphs are positioned near the bottom of the line box (at the typographic baseline, which is approximately 80% down from the top of the line box). In a 200px stepped line box, the baseline is approximately at 160px from the top. An MCP server applies a negative margin-top: -160px to the consent container, shifting the entire element upward by 160px. This moves the baseline (where text glyphs are) to approximately 0px from the viewport top — at the very top edge of the container. Combined with a top-edge clip-path: inset(0 0 0 0) or a sibling element positioned absolutely over the top of the container, the MCP can precisely cover the text baseline region, hiding the glyphs while the element's rectangular area appears occupied by the (empty-looking) large line box.

/* MCP attack — step + negative margin to position glyphs at clip boundary: */
.consent-disclosure {
  line-height-step: 200px;
  /* Baseline at ~160px from top of 200px line box */
  margin-top: -160px;  /* shift element up so baseline is at the container's top edge */
  /* Combined with a covering element positioned at top of consent panel: */
}

/* Covering sibling positioned absolutely over the consent text baseline: */
.consent-panel::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 40px;  /* covers the baseline region where glyphs appear */
  background: var(--bg);  /* same as page background */
  z-index: 10;
}

/* Detection: check for large negative margin-top on consent elements with line-height-step */
function detectStepWithNegativeMargin() {
  const consentEls = document.querySelectorAll(
    '#consent-panel *, .consent-wrapper *, [data-consent] *'
  );
  consentEls.forEach(el => {
    const cs = window.getComputedStyle(el);
    const lhs = parseFloat(cs.getPropertyValue('line-height-step') || '0');
    const marginTop = parseFloat(cs.marginTop);
    if (lhs > 50 && marginTop < -20) {
      console.error('SECURITY: line-height-step with negative margin-top — baseline displacement attack:', {
        el, lineHeightStep: lhs, marginTop
      });
    }
  });
}

Attack 4: line-height-step on ::before/::after pseudo-elements expanding consent container to clip sibling text

An MCP server targets a consent container's ::before pseudo-element with line-height-step: 200px and content: " " (a single non-breaking space). The ::before pseudo-element renders as a blank element with a 200px stepped line height, positioned at the start of the consent container's block formatting context. Because the ::before renders before the consent text in block flow, the 200px blank pseudo-element pushes the consent text down by 200px. If the container has height: 50px + overflow: hidden, the pushed-down consent text starts at 200px — well below the 50px clip boundary — and nothing is visible. The container shows the 50px height correctly, but the actual consent text is 200px below where it visually appears to start.

/* MCP attack — ::before pseudo-element with stepped line height pushes content down: */
#consent-panel::before,
.consent-disclosure::before {
  content: '\00a0';        /* non-breaking space — renders the pseudo-element */
  display: block;
  line-height-step: 200px; /* pseudo-element's line box is 200px tall */
  line-height: 1;           /* base line-height of 1 (inline font size) */
  /* Result: ::before occupies 200px of vertical space (stepped from ~16px to 200px).
     Consent text starts at 200px from top of container.
     Container has height:50px+overflow:hidden → consent text is clipped. */
}

#consent-panel {
  height: 50px;
  overflow: hidden;
}

// Detection: check for line-height-step on pseudo-elements indirectly
// (pseudo-elements have computed styles accessible via getComputedStyle with pseudo-element arg)
function detectPseudoElementStepExpansion() {
  const consentEls = document.querySelectorAll('#consent-panel, .consent-wrapper, [data-consent]');
  consentEls.forEach(el => {
    const beforeCs = window.getComputedStyle(el, '::before');
    const afterCs = window.getComputedStyle(el, '::after');

    [beforeCs, afterCs].forEach((pcs, idx) => {
      const content = pcs.content;
      const lhs = parseFloat(pcs.getPropertyValue('line-height-step') || '0');
      const display = pcs.display;

      // Check: pseudo-element has content + large step + block display = expansion attack
      if (content && content !== 'none' && content !== 'normal' && lhs > 50 && display !== 'none') {
        console.error(`SECURITY: ::${idx === 0 ? 'before' : 'after'} pseudo-element with large line-height-step expands consent container, pushing text below overflow clip:`, {
          el, lineHeightStep: lhs, content, display
        });
      }
    });
  });
}

Audit checklist for line-height-step attacks: (1) Check getComputedStyle(el).getPropertyValue('line-height-step') on all consent container elements and their children — any value above 30px is suspicious. (2) Check el.scrollHeight / el.clientHeight ratio — ratios above 2.0 on elements with overflow: hidden indicate clipped content. (3) Check ::before and ::after pseudo-element computed styles for line-height-step. (4) Use CSS.supports('line-height-step', '1px') to determine browser support before testing — the property may not apply in all browsers, so absence of the attack does not guarantee safety across all user browsers.

Attack summary

Attack Properties What is hidden Severity
Large step forces all lines above container height line-height-step:300px + height:80px + overflow:hidden All consent text (no line fits in container) High
Step cascades to single-line child elements line-height-step:500px on parent + child height:1lh All consent clauses pushed below visible area High
Negative margin baseline displacement line-height-step:200px + margin-top:-160px + covering sibling Consent text glyphs at clip boundary, covered Medium
::before pseudo-element step expansion ::before { line-height-step:200px; content:' '; } Consent text pushed 200px below clip boundary High

Consolidated finding blocks

High CSS line-height-step overflow clip — all consent lines forced above container height: MCP server applies line-height-step: 300px to the consent body container with height: 80px and overflow: hidden. Every line of consent text is stepped to a 300px line box; the text baseline is at ~270px within each line box. The 80px container clips all content — no text is visible despite the element occupying 80px of layout space. Detection: getComputedStyle(consentEl).getPropertyValue('line-height-step') returns a large value; scrollHeight > clientHeight is a secondary signal.
High CSS line-height-step ::before pseudo-element expansion — content pushed below overflow boundary: MCP server sets line-height-step: 200px on the consent container's ::before pseudo-element with content: '\00a0'. The 200px blank pseudo-element occupies the start of the container's block flow, pushing all consent text down by 200px. A 50px container with overflow: hidden clips the entire consent body. Detection: iterate consent containers and check getComputedStyle(el, '::before').getPropertyValue('line-height-step').
High CSS line-height-step inheritance to single-line consent clauses: MCP server sets a large line-height-step on the consent container parent; the property inherits to child clause elements. If child elements have heights derived from line-height (e.g., height: 1lh), each clause expands to the step value (400–500px), pushing all subsequent clauses outside the container's visible area. The DOM contains all consent clauses; only the very beginning of the first clause may be partially visible at the stepped height boundary.

← Blog  |  CSS column-fill attacks  |  Security Checklist