← Blog

MCP server CSS transform viewport escape consent security

Four SA-CSS-TRV consent attack patterns using CSS transform functions to move, collapse, invert, or mirror consent text so that it becomes invisible or unreadable while remaining present in the DOM with normal layout dimensions. CSS transforms are applied after layout: the element’s layout box retains its original position and dimensions (affecting surrounding elements normally), while the rendered output is visually displaced or distorted. This creates a critical gap between what getBoundingClientRect() reports and where the text actually appears on screen.

By SkillAudit  |  29 Oct 2026

CSS transform is a visual property: it moves, scales, rotates, or skews an element’s rendered output without affecting its layout. The layout box (the space the element occupies in the document flow) remains at the original position with the original dimensions. Only the painted output is transformed. This design means that getBoundingClientRect(), which returns the transformed bounding box, reports the visual position after transformation — but the layout still operates on the pre-transform box. For consent text attacks, this means that transforms moving text off-screen are detected by getBoundingClientRect() (which shows negative or off-viewport coordinates), but the element still appears to occupy space in the layout, and offsetWidth/offsetHeight report the pre-transform dimensions.

SA-CSS-TRV-001: transform:translateX(100vw) off-viewport displacement

Translating consent text by 100vw (one full viewport width) to the right moves every rendered pixel outside the right edge of the viewport. The user sees a blank area where the consent text should be. The layout box remains at its original document-flow position, so surrounding content (including a visible consent checkbox or button) is unaffected. The translate can use any unit: translateX(100vw), translateX(2000px), translateX(200%) relative to the element width — all move the text off-screen in different directions.

.consent-text {
  transform: translateX(100vw);   /* shift right by one full viewport width */
  display: inline-block;          /* transforms require non-inline display */
}

/* CSS transform vs CSS positioning:
   position:absolute + left:100vw: moves the LAYOUT BOX off-screen.
   transform:translateX(100vw): moves only the RENDERED OUTPUT off-screen.
   The layout box stays at the original position.

   Practical difference for attacks:
   If a consent dialog uses flow layout, position:absolute + left:100vw would
   collapse the dialog width (element removed from flow).
   transform:translateX(100vw): element stays in flow, preserving dialog layout.
   The dialog box still appears correctly sized and positioned.
   Only the consent text paragraph is rendered off-screen; surrounding elements unchanged.

   getBoundingClientRect on .consent-text after transform:translateX(100vw):
   - Returns the TRANSFORMED bounding box
   - x: original_x + viewport_width (off-screen to the right)
   - width: original text width (unchanged by translateX)
   - The returned rect has x > window.innerWidth → element is off-screen

   Check for off-screen elements:
   const rect = el.getBoundingClientRect();
   const isOffscreen =
     rect.right < 0 ||                  /* off left */
     rect.left > window.innerWidth ||   /* off right */
     rect.bottom < 0 ||                 /* off top */
     rect.top > window.innerHeight;     /* off bottom */

   If consent text element is off-screen → HIGH SA-CSS-TRV-001

   offsetWidth check:
   el.offsetWidth: returns the PRE-TRANSFORM layout box width (still non-zero).
   → Checking offsetWidth > 0 passes even when element is off-screen.
   Use getBoundingClientRect, not offsetWidth, to check visual viewport presence.

   Variant — Y axis displacement:
   transform:translateY(-100vh): move consent text above viewport (off top)
   Same detection: getBoundingClientRect().bottom < 0 → off top → CRITICAL */

offsetWidth vs getBoundingClientRect: el.offsetWidth reports the pre-transform layout dimensions (always non-zero for visible elements). el.getBoundingClientRect() reports the post-transform visual bounding box, including the displacement from translateX/translateY. An audit that checks offsetWidth > 0 to verify element visibility will miss transform-based off-viewport attacks. Detection must use getBoundingClientRect() and verify that the returned rectangle intersects the viewport (0 ≤ rect.left ≤ window.innerWidth and 0 ≤ rect.top ≤ window.innerHeight).

CRITICAL
SA-CSS-TRV-001 — transform: translateX(100vw) displaces consent text outside the right viewport edge; layout box at original position; offsetWidth reports non-zero pre-transform width; getBoundingClientRect().left > window.innerWidth. Detection: getBoundingClientRect() viewport intersection check → CRITICAL if not intersecting.

SA-CSS-TRV-002: transform:scaleX(0.001) sub-pixel width collapse

Scaling the horizontal dimension of consent text to 0.001 collapses each text character to a vertical line of effectively zero width — at 16px font size and scaleX(0.001), each character is rendered as a 0.016px wide line, below the sub-pixel rendering threshold of any display. The text is visually undetectable: even a high-DPI retina display at 3× density cannot render a 0.016px line as a visible pixel. The getBoundingClientRect().width reflects the scaled width (near zero), which is one of the few CSSOM properties that reveals this attack.

.consent-text {
  display: inline-block;
  transform: scaleX(0.001);   /* collapse horizontal dimension to 0.1% of original */
}

/* scaleX(0.001) visual effect:
   Original text: "By clicking Allow..." at 16px font size, ~300px wide
   After scaleX(0.001):
   - Rendered width: 300px × 0.001 = 0.3px
   - Each character: ~10px wide × 0.001 = 0.01px wide
   → Sub-pixel rendering: browser clips to nearest pixel boundary → 0px wide
   → Entire text rendered as a vertical line (or nothing) at the original position

   getBoundingClientRect on .consent-text after scaleX(0.001):
   width: 300 × 0.001 = 0.3  ← near zero (reported as fractional pixels)
   height: unchanged (line-height, e.g. 24px)
   x, y: at original viewport position

   offsetWidth on .consent-text:
   Returns layout width BEFORE transform (e.g., 300px) ← non-zero, misleading

   Detection:
   const rect = el.getBoundingClientRect();
   if (rect.width < 1 && el.offsetWidth > 10) {
     // Element has significant layout width but near-zero visual width
     // → scaleX near-zero transform attack
     // → HIGH SA-CSS-TRV-002
   }

   Variant — scaleY(0.001): collapse vertical dimension
   Rendered as a horizontal line at near-zero height.
   Same detection: getBoundingClientRect().height < 1 AND offsetHeight > 10 → CRITICAL

   Variant — scale(0.001, 0.001): collapse both dimensions to point
   Rendered as a single sub-pixel dot. Completely invisible.
   → CRITICAL (more severe than single-axis collapse)

   Variant — scaleX(-0.001): near-zero with horizontal flip (mirror)
   Same visual effect (sub-pixel width) with mirroring
   → Same detection approach */

getBoundingClientRect width vs offsetWidth: el.offsetWidth returns the layout box width before transform (the full text width). el.getBoundingClientRect().width returns the post-transform visual width (0.3px for scaleX(0.001)). Detection of scale collapse attacks requires using getBoundingClientRect().width and flagging values below 1px for elements with significant pre-transform dimensions (offsetWidth > 10). Checking only offsetWidth > 0 completely misses scale collapse.

CRITICAL
SA-CSS-TRV-002 — transform: scaleX(0.001) collapses consent text to 0.01px-wide sub-pixel lines; effectively invisible on any display; offsetWidth reports pre-transform layout width; getBoundingClientRect().width is near zero. Detection: getBoundingClientRect().width < 1 with offsetWidth > 10 → CRITICAL scale collapse attack.

SA-CSS-TRV-003: transform:rotate(180deg) upside-down consent text

Rotating consent text 180 degrees renders it upside-down. The text is technically legible — a user who tilts their head 180 degrees, or views the screen from below, can read it — but in practice, most users will not read upside-down text. The attacker’s goal is not complete invisibility but unintelligibility: the text is present, visible, and survives automated text-extraction tools (which read textContent, not the visual rendering), but users fail to understand it. This is a weaker attack than off-viewport displacement but effective against users who do not inspect the source or notice the rotation.

.consent-text {
  display: inline-block;
  transform: rotate(180deg);   /* render text upside-down */
  transform-origin: center;    /* rotate around element center */
}

/* rotate(180deg) practical effects:
   - Text is rendered upside-down and right-to-left (both axes flipped by 180°)
   - Visually: each character is individually flipped; the line reads right-to-left upside-down
   - The text box remains at the same position in the layout
   - getBoundingClientRect(): returns the bounding box of the rotated element
     (for 180° rotation: same dimensions, same center, same viewport position)
     → rect.width and rect.height unchanged; rect.x and rect.y approximately same center

   Why does this attack work in practice?
   Users encountering upside-down text in a consent dialog typically:
   1. Assume it is a rendering bug and click "Allow" or "Accept" to dismiss
   2. Scroll past without reading
   3. Close the dialog without consenting (which may re-display it)
   The attack does not prevent consent interaction but prevents informed consent.

   Automated text extraction:
   el.textContent: returns correct consent text (DOM text nodes unaffected)
   document.evaluate('//text()', ...): same — correct text
   Screenshot OCR tools: would need to detect and correct rotation before OCR
   Standard OCR without rotation detection: may produce garbage output for 180° text

   Detection:
   Parse getComputedStyle(el).transform matrix:
   transform: rotate(180deg) → matrix(-1, 1.22e-15, -1.22e-15, -1, 0, 0)
   (floating point representation of the rotation matrix)
   Check matrix: if cos(rotation) < -0.9 (i.e., rotation near 180°) → CRITICAL
   Also check rotation near 90° and 270° (text rendered sideways → unreadable).
   Severity: HIGH (not CRITICAL — text is visible but upside-down, not invisible) */

Rotation detection via transform matrix: getComputedStyle(el).transform returns the CSS transform value as a matrix() or matrix3d() function rather than the original rotate() form. Extract the rotation angle from the matrix: for a 2D rotation, the angle is Math.atan2(matrix[1], matrix[0]) in radians. Rotations of 90°, 180°, or 270° all produce unreadable text orientation. Any rotation where Math.abs(angle) > 45° should be flagged as a potential legibility attack.

HIGH
SA-CSS-TRV-003 — transform: rotate(180deg) renders consent text upside-down; technically legible but practically unread; DOM textContent correct; getBoundingClientRect() unchanged. Detection: extract rotation from transform matrix; flag rotations > 45° as legibility attack → HIGH (text present but unintelligible to most users).

SA-CSS-TRV-004: transform:scale(-1,1) horizontal mirror-flip

Applying transform: scale(-1, 1) flips the consent text horizontally around its vertical center axis, producing a mirror image. Each character is individually mirrored: “B” becomes a backwards “B”, “d” becomes “b”, and the word order within a line reads right-to-left. The visual result resembles text seen in a mirror. Most users will not recognize mirror-flipped text as consent language. Automated text extraction reads the DOM text nodes (correct), but visual OCR or screenshot analysis would need explicit mirror-flip detection and correction to recover the original text.

.consent-text {
  display: inline-block;
  transform: scale(-1, 1);     /* flip horizontally; scale Y by 1 (no vertical change) */
  transform-origin: center;
}

/* scale(-1, 1) effects:
   scaleX(-1): mirror flip around vertical axis
   scaleY(1): no change to vertical dimension
   Result: each rendered character is mirrored left-right.
   The word "Allow" appears as "wolA" (mirrored letters in mirrored order).
   The line "By clicking Allow you grant..." appears as right-to-left with
   each individual character also mirrored.

   getBoundingClientRect after scale(-1, 1):
   Width: unchanged (absolute scale factor is 1 on X axis)
   Height: unchanged
   Position: approximately same center point (transform-origin:center)
   → getBoundingClientRect does not reveal the flip

   Transform matrix for scale(-1, 1):
   matrix(-1, 0, 0, 1, 0, 0)
   The (0,0) element of the matrix is -1: indicates horizontal flip.

   Detection via matrix decomposition:
   const matrix = new DOMMatrix(getComputedStyle(el).transform);
   const scaleX = Math.sqrt(matrix.a * matrix.a + matrix.b * matrix.b);
   const scaleY = Math.sqrt(matrix.c * matrix.c + matrix.d * matrix.d);
   const isFlippedX = matrix.a / scaleX < 0;    // negative a after normalization
   const isFlippedY = matrix.d / scaleY < 0;    // negative d after normalization
   if (isFlippedX || isFlippedY) → SA-CSS-TRV-004 detected

   Combined attack: scale(-1, 1) + translateX(100%)
   Places mirrored text at the same visual position as original but mirrored.
   Combines unintelligibility (mirror) with potential off-screen displacement.

   Evasion context:
   scale(-1,1) is a legitimate CSS technique for reflections and RTL icon mirroring.
   False positive filter: check if the element contains consent-critical text
   (keywords: "allow", "grant", "access", "permission", "agree", "consent")
   before flagging the transform as an attack. */

Reduce false positives with consent keyword check: scale(-1, 1) has legitimate uses in UI design (flipping icons for RTL languages, creating reflection effects). Before flagging a horizontal flip as a consent attack, check whether the element contains consent-critical keywords (“allow”, “grant”, “access”, “permission”, “agree”, “consent”) in its textContent. Applying the flag only to elements that contain such keywords reduces false positives while still catching the attack on actual consent dialogs.

HIGH
SA-CSS-TRV-004 — transform: scale(-1, 1) mirror-flips consent text horizontally; each character and word order reversed; visually unreadable as consent language; getBoundingClientRect() dimensions unchanged. Detection: DOMMatrix decomposition for negative scaleX on consent-critical text elements → HIGH.

Summary of SA-CSS-TRV findings

IDSeverityTransformEffectDetection key
SA-CSS-TRV-001 CRITICAL translateX(100vw) Consent text moved outside viewport; layout box at original position getBoundingClientRect viewport intersection check
SA-CSS-TRV-002 CRITICAL scaleX(0.001) Text collapsed to sub-pixel width; effectively invisible getBoundingClientRect.width < 1 with offsetWidth > 10
SA-CSS-TRV-003 HIGH rotate(180deg) Text upside-down; technically legible but practically unread Matrix rotation angle > 45° on consent text
SA-CSS-TRV-004 HIGH scale(-1, 1) Text mirrored horizontally; characters and word order reversed DOMMatrix negative scaleX decomposition on consent-critical elements

Related pages