Security Research — 2026-10-17

SVG Text Positioning Attack Families on MCP Consent: baseline-shift, dominant-baseline, letter-spacing, word-spacing, and text-decoration

Five SVG presentation attribute families that subvert MCP consent readability without altering textContent or bounding rectangles. Unlike the geometric transform families covered in previous posts — dx/dy per-glyph drift, rotate per-character tilt, textLength/lengthAdjust compression, and text-anchor anchor-point displacement — these five families operate on how glyphs are painted rather than where text flows. Two families manipulate vertical em-box alignment (baseline-shift and dominant-baseline). Two families explode inter-character and inter-word spacing to scatter glyphs across thousands of pixels (letter-spacing and word-spacing). One family covers glyph ink entirely with decoration strokes tuned to match the background (text-decoration). All five share the same root detection gap: SVG presentation attributes invisible to CSS-only scanners. This article covers twenty finding codes across SA-CSS-BSH, SA-CSS-DBL, SA-CSS-LSP, SA-CSS-WSP, and SA-CSS-TDE, and provides a unified detection algorithm.

Contents

  1. Family 1: Baseline Displacement — baseline-shift and dominant-baseline (SA-CSS-BSH + SA-CSS-DBL)
  2. Family 2: Spacing Explosion — letter-spacing and word-spacing (SA-CSS-LSP + SA-CSS-WSP)
  3. Family 3: Decoration Cover — text-decoration (SA-CSS-TDE)
  4. Cross-family: Mousedown transient injection pattern
  5. Detection coverage table
  6. SkillAudit finding codes

Family 1: Baseline Displacement — baseline-shift and dominant-baseline (SA-CSS-BSH + SA-CSS-DBL)

Every glyph in an SVG text element is placed relative to a baseline: the imaginary horizontal line on which most Latin lowercase letters sit. SVG exposes two independent mechanisms for relocating that baseline — and thus the entire rendered glyph — relative to the element’s nominal y-coordinate. The first, baseline-shift, moves the current text position up or down by a percentage of the element’s font size, or by an absolute length, or via the keyword values super (approximately +50% above baseline) and sub (approximately −50% below baseline). The second, dominant-baseline, selects which typographic reference line of the font is treated as the alignment point: options include hanging (the line on which Devanagari script hangs), ideographic (the bottom of the CJK em-box), mathematical (at the centre of mathematical glyphs), central, and middle.

Both attributes are SVG presentation attributes. Both participate in the CSS cascade when a corresponding CSS property exists — which means getComputedStyle can see them in some configurations — but both can also be set as raw element attributes that a CSS-only scanner will miss at the SVG attribute layer. More importantly, both are routinely applied at the <tspan> level rather than the parent <text> level. A scanner that reads only the parent text element’s computed style will find auto for baseline-shift and auto for dominant-baseline while individual tspans holding the dangerous permission scope words are being silently displaced vertically off the visible area. See the full SA-CSS-BSH reference page and the full SA-CSS-DBL reference page for SVG code examples and per-pattern detection snippets.

Critical

SA-CSS-BSH-001: baseline-shift="super" off-panel displacement

baseline-shift="super" shifts the current text position roughly 50% of the font-size above the nominal baseline. On a 16 px font, permission-scope tspans move ~8 px upward. With a small explicit y value (e.g., y="10"), the displaced glyphs exit the visible consent panel entirely. textContent and the parent element’s bounding rect are both unchanged; the displaced tspan’s own bounding rect reports coordinates outside the visible viewport.

Critical

SA-CSS-BSH-002: baseline-shift="-200%" downward push

A large negative percentage shift — baseline-shift="-200%" — pushes glyphs 2× the font-size downward below the nominal y-coordinate. On a 14 px font with y="20", the tspan renders at approximately y="48", below the lower edge of a standard 40 px consent panel. The percentage is computed against the line-height or font-size of the current element, so threshold-based checks on absolute pixel values will fail without normalizing the percentage to px first.

High

SA-CSS-BSH-003: baseline-shift="50px" absolute push on permission tspan

An absolute length value — baseline-shift="50px" — shifts the permission-scope tspan 50 px above the main consent text line. The parent text element and all other tspans render at the expected position; only the targeted tspan is displaced. Per-tspan inspection with absolute y-bounds analysis (is the tspan’s computed bounding rect within the visible consent panel area?) is required.

Critical

SA-CSS-BSH-004: mousedown inject baseline-shift="super"

JS injects baseline-shift="super" on the scope tspan at mousedown; removes it at mouseup. DOM is clean at rest — the attribute is absent in any static snapshot. Requires MutationObserver watching baseline-shift on all text and tspan descendants. Combined with a small y value baked into the static markup, the mousedown injection is sufficient to push the tspan fully out of the panel during the click window.

Critical

SA-CSS-DBL-001: dominant-baseline="hanging" with small y

dominant-baseline="hanging" re-anchors the text so the hanging baseline (typically ~70% of the em-box above the alphabetic baseline) aligns with the element’s y-coordinate. On a text element with y="8", this displaces the alphabetic glyphs 70% × font-size above the nominal position — pushing them above the panel’s top edge. The mechanism is purely typographic: the y-coordinate is honoured; only the interpretation of which font reference line aligns with y has changed.

Critical

SA-CSS-DBL-002: dominant-baseline="ideographic" downward shift

dominant-baseline="ideographic" aligns the bottom of the CJK em-box with the element’s y-coordinate. For Latin text on a 16 px font, this shifts glyphs approximately 1 em downward relative to the alphabetic baseline. With a y value near the bottom of the consent panel, the displaced glyphs fall below the panel’s visible area. The effect is equivalent to a ~16 px downward dy shift, but is entirely invisible to scanners checking dy attributes.

High

SA-CSS-DBL-003: mixed dominant-baseline per-tspan

The outer <text> element carries dominant-baseline="alphabetic" (the normal value), making a parent-level check pass. Individual permission-scope tspans carry dominant-baseline="hanging" or dominant-baseline="ideographic", displacing only those words. A per-tspan scan is the only reliable detection path.

Critical

SA-CSS-DBL-004: mousedown inject dominant-baseline="hanging"

JS injects dominant-baseline="hanging" at mousedown; reverts to dominant-baseline="alphabetic" at mouseup. Because the revert sets the attribute to a safe value rather than removing it, an observer watching only for attribute removal will miss the window. The observer must check the attribute value, not merely its presence.

Unified detection for the baseline displacement family requires three simultaneous checks. First, for every text and tspan element, call getAttribute('baseline-shift') and getAttribute('dominant-baseline') directly; do not rely solely on getComputedStyle because the SVG attribute form takes precedence over unset CSS properties in a way that may not be reflected in older browser engines. Second, normalise any percentage or absolute value of baseline-shift to pixels and compare against threshold bounds (recommended: flag any shift exceeding ±8 px on a standard 14–16 px consent font). Third, compare each element’s getBoundingClientRect() against the consent panel’s bounding rect — glyphs shifted out of the panel will have rects that fall outside the panel area even though the text element’s parent rect remains inside.

// Baseline displacement detection — SA-CSS-BSH + SA-CSS-DBL
function scanBaselineDisplacement(svgEl, panelRect, fontSizePx = 16) {
  const findings = [];
  const NON_ALPHABETIC_BASELINES = /^(hanging|ideographic|mathematical|central|middle|text-before-edge|text-after-edge)$/i;

  for (const el of svgEl.querySelectorAll('text, tspan')) {
    // --- SA-CSS-BSH: baseline-shift ---
    const bsAttr = el.getAttribute('baseline-shift') || '';
    const bsComputed = getComputedStyle(el).baselineShift || '';
    const bsRaw = bsAttr || bsComputed;

    if (bsRaw && !/^(auto|0|0px|0%)$/i.test(bsRaw.trim())) {
      // Normalise to pixels
      let bsPx = 0;
      if (/^super$/i.test(bsRaw))       bsPx = fontSizePx * 0.5;
      else if (/^sub$/i.test(bsRaw))    bsPx = -fontSizePx * 0.5;
      else if (/%$/.test(bsRaw))        bsPx = (parseFloat(bsRaw) / 100) * fontSizePx;
      else                              bsPx = parseFloat(bsRaw) || 0;

      const rect = el.getBoundingClientRect();
      const outsidePanel = rect.bottom < panelRect.top || rect.top > panelRect.bottom;

      findings.push({
        code: Math.abs(bsPx) > 8 ? 'SA-CSS-BSH-001' : 'SA-CSS-BSH-003',
        severity: 'CRITICAL',
        el: el.tagName,
        note: `baseline-shift="${bsRaw}" (${bsPx.toFixed(1)}px); outsidePanel=${outsidePanel}`
      });
    }

    // --- SA-CSS-DBL: dominant-baseline ---
    const dbAttr = el.getAttribute('dominant-baseline') || '';
    const dbComputed = getComputedStyle(el).dominantBaseline || '';
    const dbRaw = dbAttr || dbComputed;

    if (NON_ALPHABETIC_BASELINES.test(dbRaw.trim())) {
      const rect = el.getBoundingClientRect();
      const outsidePanel = rect.bottom < panelRect.top || rect.top > panelRect.bottom;
      findings.push({
        code: 'SA-CSS-DBL-001',
        severity: 'CRITICAL',
        el: el.tagName,
        note: `dominant-baseline="${dbRaw}"; em-box reference line shifted; outsidePanel=${outsidePanel}`
      });
    }
  }
  return findings;
}

// MutationObserver for transient baseline injection (SA-CSS-BSH-004, SA-CSS-DBL-004)
function watchBaselineInject(svgEl, panelRect, onFinding) {
  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      const el = mut.target;
      const bs = el.getAttribute('baseline-shift') || '';
      const db = el.getAttribute('dominant-baseline') || '';
      if (bs && !/^(auto|0|0px|0%)$/i.test(bs.trim()))
        onFinding({ code: 'SA-CSS-BSH-004', severity: 'CRITICAL', el: el.tagName,
          note: `Dynamic baseline-shift="${bs}" injected on <${el.tagName}> during click window` });
      if (/^(hanging|ideographic|mathematical|central|middle)$/i.test(db.trim()))
        onFinding({ code: 'SA-CSS-DBL-004', severity: 'CRITICAL', el: el.tagName,
          note: `Dynamic dominant-baseline="${db}" injected on <${el.tagName}> during click window` });
    }
  });
  observer.observe(svgEl, {
    subtree: true, attributes: true, attributeOldValue: true,
    attributeFilter: ['baseline-shift', 'dominant-baseline']
  });
  return observer;
}

Y-bounds analysis is mandatory for BSH and DBL. It is not sufficient to flag all non-zero baseline-shift or non-alphabetic dominant-baseline values. Legitimate consent panels use dominant-baseline="middle" to vertically center labels inside icon buttons. The finding criterion is: non-default baseline combined with a bounding rect that falls outside the visible consent panel area, or a shift magnitude exceeding the configurable threshold on a permission-scope tspan.


Family 2: Spacing Explosion — letter-spacing and word-spacing (SA-CSS-LSP + SA-CSS-WSP)

The letter-spacing presentation attribute adds extra space after each rendered glyph; word-spacing adds extra space after each space character (word boundary). Both are additive: the value is the additional gap beyond the font’s normal advance-width or word-space metrics. Both accept any CSS length value, meaning an attacker can specify letter-spacing="200px" or word-spacing="300px" and scatter consent text across thousands of pixels while leaving every individual character perfectly legible in isolation. Both attributes also accept negative values, enabling the complementary attack: extreme negative values collapse all glyphs into a single overlapping mass of ink in which no character is individually distinguishable. See the full SA-CSS-LSP reference page and the full SA-CSS-WSP reference page for SVG code examples and per-pattern detection snippets.

The scatter magnitudes are worth internalising. At letter-spacing="200px" on a 40-character consent string, the text extends 200 × 40 = 8,000 px beyond its nominal start position. No consent panel is 8,000 px wide. The first two or three characters may remain visible; every subsequent character is clipped by the SVG viewport or the enclosing HTML layout. The string “Allow: shell execution, credential access” visually appears as “A” followed by an empty panel. At word-spacing="300px" on a five-word consent string, the spaces between words become 300 px gaps, expanding the full text to approximately 300 × 4 = 1,200 px plus the total character widths. Individual words remain readable as isolated tokens if the user can see them, but sentence-level meaning is destroyed: there is no readable phrase, only isolated words distributed across a viewport-wide strip.

The negative variants behave differently in character. At letter-spacing="-4px", consecutive glyphs overlap. At letter-spacing="-6px", the overlapping mass becomes dense enough on most fonts that ascenders and descenders intermingle and individual glyph forms are no longer distinguishable. At word-spacing="-8px", word boundaries collapse and the string renders as a single run of intersecting glyphs. The thresholds differ between the two attributes because letter-spacing applies per glyph (smaller advances mean smaller collapse threshold) while word-spacing applies only at space characters. Recommended flag thresholds based on empirical rendering: letter-spacing outside [−4 px, 8 px] and word-spacing outside [−6 px, 20 px].

Critical

SA-CSS-LSP-001: letter-spacing="200" glyph scatter

letter-spacing="200" (SVG user units, typically 1:1 with CSS px at 1× zoom) adds 200 px after each glyph. On a 40-character consent string the total expansion is 8,000 px. Only the first character or two remain visible in a standard-width consent panel. textContent contains the full permission string; getBoundingClientRect on the text element reflects the full 8,000 px width, which may extend outside the viewport clipping region and thus appear bounded.

Critical

SA-CSS-LSP-002: letter-spacing="-6px" collapse to ink mass

letter-spacing="-6px" moves each glyph 6 px to the left of its normal advance-width position, causing dense overlap on most Latin fonts at 14–16 px sizes. The entire consent string renders as an illegible mass of intersecting strokes. textContent is intact; the text element’s bounding rect reflects a width slightly shorter than normal, not obviously anomalous to a BCR-based size check.

High

SA-CSS-LSP-003: per-tspan letter-spacing scatter on scope words

The outer <text> element carries no letter-spacing. The “shell execution credential access” tspan carries letter-spacing="150", scattering only the permission-scope portion. The header “Allow:” remains legible. A parent-level check on the text element finds no anomaly; per-tspan scanning is required.

Critical

SA-CSS-LSP-004: mousedown inject letter-spacing="200"

JS injects letter-spacing="200" on the consent text element at mousedown; removes at mouseup. Attribute is absent at rest. MutationObserver watching letter-spacing on all text and tspan descendants is required; static attribute scan and getComputedStyle both miss this at rest.

Critical

SA-CSS-WSP-001: word-spacing="300" word scatter

word-spacing="300" adds 300 px after each space character. On a five-word consent string, the four inter-word gaps expand by 1,200 px total. Words are individually legible wherever they happen to fall, but the sentence is split across a horizontal distance that no consent panel contains. The user sees the first word and an empty area; subsequent words are clipped or fall outside the visible viewport.

Critical

SA-CSS-WSP-002: word-spacing="-8px" word boundary collapse

word-spacing="-8px" on a 14 px font eliminates the space character advance entirely and pulls the following word 8 px back into the preceding word’s trailing glyphs. All word boundaries dissolve; the string renders as a single run of overlapping glyphs. The negative threshold for word-spacing collapse (−6 px) is slightly larger in magnitude than for letter-spacing (−4 px) because word-spacing affects only the space character advance, not individual letter advances.

High

SA-CSS-WSP-003: per-tspan word-spacing scatter on scope phrase

The scope tspan (“shell execution credential access”) carries word-spacing="200"; the introductory tspan (“Allow:”) carries none. The introductory text renders normally and anchors the user’s attention; the scattered words follow at wide intervals that begin outside the visible panel area. Per-tspan inspection required.

Critical

SA-CSS-WSP-004: mousedown inject word-spacing="300"

JS injects word-spacing="300" at mousedown; removes at mouseup. Identical transient injection pattern to LSP-004. Both letter-spacing and word-spacing must be watched simultaneously by the MutationObserver; a scanner watching only one misses the other.

// Spacing explosion detection — SA-CSS-LSP + SA-CSS-WSP
// Thresholds (px): letter-spacing outside [-4, 8]; word-spacing outside [-6, 20]
const LS_MIN = -4, LS_MAX = 8;
const WS_MIN = -6, WS_MAX = 20;

function parsePxValue(raw) {
  if (!raw) return null;
  const n = parseFloat(raw);
  if (isNaN(n)) return null;
  // SVG user units without explicit unit are treated as px at 1× zoom
  return n;
}

function scanSpacingExplosion(svgEl) {
  const findings = [];

  for (const el of svgEl.querySelectorAll('text, tspan')) {
    // SA-CSS-LSP: letter-spacing
    const lsAttr = el.getAttribute('letter-spacing') || '';
    const lsComp = getComputedStyle(el).letterSpacing || '';
    const lsRaw = lsAttr || lsComp;
    const lsPx = parsePxValue(lsRaw);

    if (lsPx !== null && (lsPx < LS_MIN || lsPx > LS_MAX)) {
      findings.push({
        code: lsPx > LS_MAX ? 'SA-CSS-LSP-001' : 'SA-CSS-LSP-002',
        severity: 'CRITICAL',
        el: el.tagName,
        note: `letter-spacing="${lsRaw}" (${lsPx}px); threshold [${LS_MIN}, ${LS_MAX}]px exceeded`
      });
    }

    // SA-CSS-WSP: word-spacing
    const wsAttr = el.getAttribute('word-spacing') || '';
    const wsComp = getComputedStyle(el).wordSpacing || '';
    const wsRaw = wsAttr || wsComp;
    const wsPx = parsePxValue(wsRaw);

    if (wsPx !== null && (wsPx < WS_MIN || wsPx > WS_MAX)) {
      findings.push({
        code: wsPx > WS_MAX ? 'SA-CSS-WSP-001' : 'SA-CSS-WSP-002',
        severity: 'CRITICAL',
        el: el.tagName,
        note: `word-spacing="${wsRaw}" (${wsPx}px); threshold [${WS_MIN}, ${WS_MAX}]px exceeded`
      });
    }
  }
  return findings;
}

// MutationObserver for transient spacing injection (SA-CSS-LSP-004, SA-CSS-WSP-004)
function watchSpacingInject(svgEl, onFinding) {
  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      const el = mut.target;
      const ls = parsePxValue(el.getAttribute('letter-spacing') || '');
      const ws = parsePxValue(el.getAttribute('word-spacing') || '');
      if (ls !== null && (ls < LS_MIN || ls > LS_MAX))
        onFinding({ code: 'SA-CSS-LSP-004', severity: 'CRITICAL', el: el.tagName,
          note: `Dynamic letter-spacing="${ls}px" on <${el.tagName}> during click window` });
      if (ws !== null && (ws < WS_MIN || ws > WS_MAX))
        onFinding({ code: 'SA-CSS-WSP-004', severity: 'CRITICAL', el: el.tagName,
          note: `Dynamic word-spacing="${ws}px" on <${el.tagName}> during click window` });
    }
  });
  observer.observe(svgEl, {
    subtree: true, attributes: true, attributeOldValue: true,
    attributeFilter: ['letter-spacing', 'word-spacing']
  });
  return observer;
}

Per-tspan detection is mandatory for LSP-003 and WSP-003. Scanners that compute spacing only from the parent <text> element’s computed style will miss attacks where letter-spacing or word-spacing are applied only to the permission-scope <tspan> while the introductory phrase is deliberately left readable to anchor the user’s attention.


Family 3: Decoration Cover — text-decoration (SA-CSS-TDE)

The text-decoration shorthand and its constituent longhand properties (text-decoration-line, text-decoration-color, text-decoration-style, text-decoration-thickness) control the ink strokes drawn over, under, or through text. For consent-panel attacks, the relevant attack surface is the intersection of two facts: SVG supports text-decoration as a presentation attribute; and decoration colors can be set independently of text color. This independence enables a class of attacks where a white decoration stroke is drawn over white-background consent text, covering glyph ink without altering textContent, bounding rects, fill, or font-size. See the full SA-CSS-TDE reference page for SVG code examples and JavaScript detection snippets for each pattern.

The critical detection insight for decoration attacks is that the comparison must be made against the background color, not the text fill color. An attacker sets the text fill to black (matching the consent panel’s normal text color) and the decoration-color to white (matching the consent panel’s background). A scanner that checks whether the decoration-color matches the text fill color will find no match and report no finding. The correct check is: does the decoration-color match or closely approximate the effective background color of the SVG consent panel? Computing the effective background requires traversing the ancestor chain to find the first element with a non-transparent background-color or SVG fill, including the SVG’s own viewport background.

The thickness dimension matters for determining coverage completeness. A single line-through with a 2 px stroke covers the mid-zone of the glyph em-box but leaves ascenders and descenders legible. A triple decoration stack — underline overline line-through — with a thickness set to 40% of the font-size covers the full em-box from descender to ascender. SVG’s support for multiple decoration lines (via space-separated values in text-decoration-line) makes this a single attribute assignment rather than three, so it is trivial to implement and fits within a mousedown injection budget.

Critical

SA-CSS-TDE-001: line-through with white decoration-color

text-decoration-line="line-through" with text-decoration-color="white" on a white-background SVG panel draws a white horizontal stroke through the mid-zone of each glyph. On most Latin fonts at 14–16 px, the mid-zone stroke bisects lowercase letters at or above the x-height, covering enough ink to make letter identification ambiguous. textContent, text fill color, and bounding rect are all unchanged.

Critical

SA-CSS-TDE-002: triple decoration stack covering full em-box

text-decoration-line="underline overline line-through" with text-decoration-color="white" and text-decoration-thickness="40%" deploys three decoration bands: one below the baseline (underline), one above the cap height (overline), and one through the mid-zone (line-through). With 40% thickness each, the three bands collectively cover the full em-box from descender to ascender. No glyph ink is visible. The text element occupies its normal layout slot but is completely blank to the human eye.

High

SA-CSS-TDE-003: per-tspan decoration on scope phrase

The introductory tspan (“Allow:”) carries no decoration. The permission-scope tspan carries text-decoration-line="line-through overline" with text-decoration-color matching the background. The introductory text is fully legible; the scope phrase is covered. A parent-level text element check finds no decoration; per-tspan inspection required. The optional nature of per-tspan coverage for TDE (compared to mandatory for BSH/DBL/LSP/WSP) stems from this being a less commonly exploited path.

Critical

SA-CSS-TDE-004: mousedown inject triple decoration

JS injects text-decoration-line="underline overline line-through", text-decoration-color="white", and text-decoration-thickness="40%" simultaneously at mousedown; removes all three at mouseup. Three attributes must be observed simultaneously: removing the MutationObserver filter for any one of the three allows a partial decoration attack that still covers the line-through mid-zone. The observer must watch all three attributes.

// Decoration cover detection — SA-CSS-TDE
function getEffectiveBackgroundColor(el) {
  // Traverse ancestors to find nearest opaque background
  let node = el;
  while (node && node !== document.documentElement) {
    const bg = getComputedStyle(node).backgroundColor;
    if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
    // Check SVG fill on rect/svg ancestors
    if (node.tagName === 'svg' || node.tagName === 'rect') {
      const fill = node.getAttribute('fill');
      if (fill && fill !== 'none') return fill;
    }
    node = node.parentElement;
  }
  return 'rgb(255, 255, 255)'; // assume white if no background found
}

function colorsApproximatelyMatch(colorA, colorB) {
  // Parse RGB components and check within tolerance 30/255
  const toRgb = c => {
    const m = c.match(/rgba?\s*\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/);
    return m ? [+m[1], +m[2], +m[3]] : null;
  };
  const rgbA = toRgb(colorA);
  const rgbB = toRgb(colorB);
  if (!rgbA || !rgbB) return colorA.toLowerCase() === colorB.toLowerCase();
  return Math.max(
    Math.abs(rgbA[0] - rgbB[0]),
    Math.abs(rgbA[1] - rgbB[1]),
    Math.abs(rgbA[2] - rgbB[2])
  ) <= 30;
}

function scanDecorationCover(svgEl) {
  const findings = [];

  for (const el of svgEl.querySelectorAll('text, tspan')) {
    const cs = getComputedStyle(el);
    const lineAttr = el.getAttribute('text-decoration-line')
      || el.getAttribute('text-decoration')
      || cs.textDecorationLine || '';
    const colorAttr = el.getAttribute('text-decoration-color') || cs.textDecorationColor || '';

    const hasDecorationLine = /underline|overline|line-through/i.test(lineAttr);
    if (!hasDecorationLine) continue;

    // Compare decoration-color against effective background
    const bgColor = getEffectiveBackgroundColor(el);
    const decorationMatchesBg = colorsApproximatelyMatch(colorAttr, bgColor);

    // Count decoration lines for severity assessment
    const lineCount = (lineAttr.match(/underline|overline|line-through/gi) || []).length;
    const thicknessAttr = el.getAttribute('text-decoration-thickness')
      || cs.textDecorationThickness || '';

    if (decorationMatchesBg) {
      findings.push({
        code: lineCount >= 3 ? 'SA-CSS-TDE-002' : 'SA-CSS-TDE-001',
        severity: 'CRITICAL',
        el: el.tagName,
        note: `text-decoration-line="${lineAttr}" with decoration-color="${colorAttr}" `
          + `matches background "${bgColor}"; ${lineCount} decoration line(s), `
          + `thickness="${thicknessAttr}"; glyph ink covered`
      });
    }
  }
  return findings;
}

// MutationObserver for transient decoration injection (SA-CSS-TDE-004)
function watchDecorationInject(svgEl, onFinding) {
  const DECO_ATTRS = [
    'text-decoration', 'text-decoration-line',
    'text-decoration-color', 'text-decoration-thickness'
  ];
  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      const el = mut.target;
      const line = el.getAttribute('text-decoration-line')
        || el.getAttribute('text-decoration') || '';
      const color = el.getAttribute('text-decoration-color') || '';
      const hasLine = /underline|overline|line-through/i.test(line);
      if (hasLine && color) {
        const bg = getEffectiveBackgroundColor(el);
        if (colorsApproximatelyMatch(color, bg))
          onFinding({ code: 'SA-CSS-TDE-004', severity: 'CRITICAL', el: el.tagName,
            note: `Dynamic decoration-color="${color}" matches bg "${bg}" on <${el.tagName}>` });
      }
    }
  });
  observer.observe(svgEl, {
    subtree: true, attributes: true, attributeOldValue: true,
    attributeFilter: DECO_ATTRS
  });
  return observer;
}

Background comparison, not fill comparison. The canonical TDE attack sets text-decoration-color to the panel background color while keeping fill (the text color) at its normal dark value. Scanners that only check whether text-decoration-color matches fill will find no match and report no finding. The comparison must be text-decoration-color versus computed background. The getEffectiveBackgroundColor helper above walks the ancestor chain to account for cases where the background is set on an SVG <rect> or on an HTML ancestor enclosing the SVG.


Cross-family: Mousedown transient injection pattern

All five families share a common exploit pattern in their “-004” variants: attributes are injected at the precise moment of mousedown and removed at mouseup. The attack exploits the gap between the moment the user commits to clicking (mousedown) and the moment the click event fires (mouseup + click). Browser rendering updates happen synchronously with DOM mutations, meaning the consent text re-renders in its disrupted form during the sub-100 ms click window. A user who read the consent text before initiating the click has already committed to proceeding; what they actually consented to — the rendered text at mousedown — differs from what they read.

The detection-evasion property of this pattern is that the DOM returns to its clean state before any post-click audit can inspect it. Static analysis tools, CSS stylesheet scans, and any inspector run after the click completes will find nothing. The only reliable detection path is a MutationObserver that is active during a simulated interaction. A complete audit must programmatically dispatch a mousedown event at the center of the Allow button bounding rect, hold one animation frame, dispatch a matching mouseup, then disconnect the observer — all while the observer records any attribute mutations that fired in between.

Critically, a scanner watching only one of the five families misses the other four. An operator who deploys a scanner covering only writing-mode injection (from the previous post’s SA-CSS-WRM-004 coverage) has no coverage for letter-spacing, word-spacing, baseline-shift, dominant-baseline, or text-decoration transient injection. Each family requires its own attributeFilter list. The combined observer below watches all five families simultaneously:

// Combined MutationObserver for all 5 transient injection families
function watchAllTextPositioningAttribs(svgEl, onFinding) {
  const ALL_WATCHED = [
    // Family 1: baseline displacement
    'baseline-shift',
    'dominant-baseline',
    // Family 2: spacing explosion
    'letter-spacing',
    'word-spacing',
    // Family 3: decoration cover
    'text-decoration',
    'text-decoration-line',
    'text-decoration-color',
    'text-decoration-thickness',
    'text-decoration-style'
  ];

  const observer = new MutationObserver(mutations => {
    for (const mut of mutations) {
      const el = mut.target;
      const attr = mut.attributeName;
      const val = el.getAttribute(attr) || '';

      switch (attr) {
        case 'baseline-shift':
          if (val && !/^(auto|0|0px|0%)$/i.test(val.trim()))
            onFinding({ code: 'SA-CSS-BSH-004', severity: 'CRITICAL',
              note: `${attr}="${val}" on <${el.tagName}>` });
          break;

        case 'dominant-baseline':
          if (/^(hanging|ideographic|mathematical|central|middle)$/i.test(val.trim()))
            onFinding({ code: 'SA-CSS-DBL-004', severity: 'CRITICAL',
              note: `${attr}="${val}" on <${el.tagName}>` });
          break;

        case 'letter-spacing': {
          const px = parseFloat(val);
          if (!isNaN(px) && (px < LS_MIN || px > LS_MAX))
            onFinding({ code: 'SA-CSS-LSP-004', severity: 'CRITICAL',
              note: `${attr}="${val}" on <${el.tagName}>` });
          break;
        }

        case 'word-spacing': {
          const px = parseFloat(val);
          if (!isNaN(px) && (px < WS_MIN || px > WS_MAX))
            onFinding({ code: 'SA-CSS-WSP-004', severity: 'CRITICAL',
              note: `${attr}="${val}" on <${el.tagName}>` });
          break;
        }

        case 'text-decoration':
        case 'text-decoration-line':
        case 'text-decoration-color': {
          // Re-evaluate full decoration state on the element
          const line = el.getAttribute('text-decoration-line')
            || el.getAttribute('text-decoration') || '';
          const color = el.getAttribute('text-decoration-color') || '';
          if (/underline|overline|line-through/i.test(line) && color) {
            const bg = getEffectiveBackgroundColor(el);
            if (colorsApproximatelyMatch(color, bg))
              onFinding({ code: 'SA-CSS-TDE-004', severity: 'CRITICAL',
                note: `decoration-color="${color}" matches bg "${bg}" on <${el.tagName}>` });
          }
          break;
        }
      }
    }
  });

  observer.observe(svgEl, {
    subtree: true,
    attributes: true,
    attributeOldValue: true,
    attributeFilter: ALL_WATCHED
  });

  return observer; // disconnect after simulated-click interaction completes
}

// Simulated interaction helper
async function simulateClickAndObserve(buttonEl, svgEl, onFinding) {
  const rect = buttonEl.getBoundingClientRect();
  const cx = rect.left + rect.width / 2;
  const cy = rect.top + rect.height / 2;

  const obs = watchAllTextPositioningAttribs(svgEl, onFinding);
  try {
    buttonEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: cx, clientY: cy }));
    await new Promise(r => requestAnimationFrame(r));
    buttonEl.dispatchEvent(new MouseEvent('mouseup',   { bubbles: true, clientX: cx, clientY: cy }));
    await new Promise(r => requestAnimationFrame(r));
  } finally {
    obs.disconnect();
  }
}

Run both phases on every consent panel audit. Phase 1 (static attribute scan via the scanBaseline*, scanSpacing*, and scanDecoration* functions above) catches statically baked attacks (the “-001” through “-003” variants). Phase 2 (the combined MutationObserver via simulateClickAndObserve) catches the transient “-004” injection variants. Phase 2 must be run during interaction simulation, not at load time. Disconnect the observer immediately after the simulation completes to avoid false positives from unrelated DOM activity.


Detection coverage table

Family getAttribute getComputedStyle Per-tspan Y-bounds MutationObserver
SA-CSS-BSH (baseline-shift) ✓ required ✓ partial (may miss attr form) required required ✓ required (mousedown)
SA-CSS-DBL (dominant-baseline) ✓ required ✓ check both required required ✓ required (mousedown, watch value not just presence)
SA-CSS-LSP (letter-spacing) ✓ required ✓ check both required — ✓ required (mousedown)
SA-CSS-WSP (word-spacing) ✓ required ✓ check both required — ✓ required (mousedown)
SA-CSS-TDE (text-decoration) ✓ required ✓ check both optional (per-tspan scoping used in TDE-003) — ✓ required (mousedown + 3 attrs: line, color, thickness)

The table reveals two important asymmetries. First, Y-bounds analysis is required only for the baseline displacement families (BSH and DBL). For spacing and decoration attacks, the text element remains within its normal layout slot — the glyphs are either scattered outside the viewport clipping boundary or covered in-place. Bounding rect checks on the text element itself provide no signal. Second, the TDE MutationObserver must watch three distinct attributes simultaneously: text-decoration-line, text-decoration-color, and text-decoration-thickness. An attack can deliver full em-box coverage across two separate attribute mutations if the scanner’s observer filter misses any one of the three. The combined watchAllTextPositioningAttribs function above handles all three as a set.


SkillAudit finding codes

CRITICAL SA-CSS-BSH-001: baseline-shift="super" on SVG consent text or tspan — glyph displaced ~50% font-size above nominal y-coordinate; textContent intact; per-tspan getAttribute and y-bounds analysis required.
CRITICAL SA-CSS-BSH-002: baseline-shift="-200%" (large negative percentage) pushes permission-scope glyphs below panel lower edge; percentage must be normalised to px against current font-size before threshold comparison.
HIGH SA-CSS-BSH-003: baseline-shift="50px" (absolute length) on permission-scope tspan with normal baseline on parent text element; per-tspan inspection and getBoundingClientRect panel-bounds comparison required.
CRITICAL SA-CSS-BSH-004: JS mousedown injects baseline-shift="super"; removed at mouseup; DOM clean at rest; MutationObserver on baseline-shift for all text/tspan descendants required.
CRITICAL SA-CSS-DBL-001: dominant-baseline="hanging" with small y value displaces Latin glyphs ~70% font-size above nominal y; effective background-relative glyph position exits panel top; per-tspan scan and y-bounds analysis required.
CRITICAL SA-CSS-DBL-002: dominant-baseline="ideographic" shifts glyphs ~1em downward relative to alphabetic baseline; with y near panel bottom, glyphs exit panel; getBoundingClientRect of affected tspan falls outside panel rect.
HIGH SA-CSS-DBL-003: mixed dominant-baseline per-tspan — parent text element carries alphabetic (normal); permission tspans carry hanging or ideographic; parent element check passes; per-tspan scan required.
CRITICAL SA-CSS-DBL-004: JS mousedown injects dominant-baseline="hanging"; reverts to alphabetic at mouseup (not removal — sets to safe value); MutationObserver must check attribute value on each change, not only presence/absence.
CRITICAL SA-CSS-LSP-001: letter-spacing="200" (SVG user units) scatters 40-character consent string over 8,000 px; first 1–2 characters visible; remainder clipped or off-screen; textContent intact; getAttribute threshold check (>8px) required.
CRITICAL SA-CSS-LSP-002: letter-spacing="-6px" collapses all glyphs into overlapping ink mass; characters individually illegible; bounding rect slightly narrower than normal but not obviously anomalous; threshold check (<−4px) required.
HIGH SA-CSS-LSP-003: per-tspan letter-spacing scatter on permission-scope phrase only; introductory phrase legible; scope phrase scattered; parent text element shows no anomaly; per-tspan scan required.
CRITICAL SA-CSS-LSP-004: JS mousedown injects letter-spacing="200"; removed at mouseup; MutationObserver on letter-spacing for all text/tspan descendants required; getComputedStyle at rest finds normal value.
CRITICAL SA-CSS-WSP-001: word-spacing="300" expands inter-word gaps by 300 px each; five-word string spans 1,200+ px; only first word visible in standard panel; individual words legible in isolation but sentence structure destroyed.
CRITICAL SA-CSS-WSP-002: word-spacing="-8px" collapses word boundaries; all words run together as a single mass of overlapping glyphs; threshold check (<−6px) required; different threshold from letter-spacing collapse because word-spacing affects only space character advance.
HIGH SA-CSS-WSP-003: per-tspan word-spacing scatter on scope phrase; introductory tspan normal; scope tspan carries large word-spacing; per-tspan attribute and computed style scan required.
CRITICAL SA-CSS-WSP-004: JS mousedown injects word-spacing="300"; removed at mouseup; MutationObserver on word-spacing required simultaneously with letter-spacing — a scanner watching only one misses the other.
CRITICAL SA-CSS-TDE-001: text-decoration-line="line-through" with text-decoration-color matching SVG background — white stroke covers glyph mid-zone on white-background panel; comparison must be decoration-color vs background, not decoration-color vs text fill.
CRITICAL SA-CSS-TDE-002: triple decoration stack (underline overline line-through) with background-matching color and text-decoration-thickness="40%" covers full em-box from descender to ascender; text element occupies normal layout slot but glyph ink entirely hidden.
HIGH SA-CSS-TDE-003: per-tspan decoration cover on scope phrase; introductory tspan undecorated; permission-scope tspan carries full decoration stack with background-matching color; parent element check passes; per-tspan scan required.
CRITICAL SA-CSS-TDE-004: JS mousedown simultaneously injects text-decoration-line, text-decoration-color, and text-decoration-thickness; removed at mouseup; MutationObserver must watch all three attributes; removing filter on any one allows partial decoration attack.

Related: SVG baseline-shift consent attacks (SA-CSS-BSH)  |  SVG dominant-baseline consent attacks (SA-CSS-DBL)  |  SVG letter-spacing consent attacks (SA-CSS-LSP)  |  SVG word-spacing consent attacks (SA-CSS-WSP)  |  SVG text-decoration consent attacks (SA-CSS-TDE)  |  SVG bidirectional and typography attack families

← Blog  |  Security Checklist