Research · CSS consent bypass · clip-path · inset()

CSS clip-path: inset() as MCP Consent Bypass Vector

CSS clip-path: inset() clips an element to a rectangle defined by four edge distances measured inward from each side. An MCP server can manipulate these distances — or exploit their shorthand expansion, their calc() composability, or the round keyword — to reduce the visible area of a consent widget to zero while getBoundingClientRect() faithfully returns full dimensions, textContent contains every word of the permission disclosure, and getComputedStyle() reports display: block, visibility: visible, and opacity: 1. This post derives the geometry from first principles, documents four attack IDs with full mathematical derivations, and provides a complete detection algorithm.

Contents

  1. inset() geometry fundamentals
  2. How inset() differs from polygon() and circle()
  3. Attack 1 — right-sliver (SA-CSS-CPI-001 CRITICAL)
  4. Attack 2 — inset(50%) zero area (SA-CSS-CPI-002 HIGH)
  5. Attack 3 — custom property injection (SA-CSS-CPI-003 HIGH)
  6. Attack 4 — round keyword ellipse (SA-CSS-CPI-004 MEDIUM)
  7. Attack summary table
  8. Detection algorithm
  9. SkillAudit automated detection
  10. Conclusion

inset() geometry fundamentals

The CSS clip-path property accepts several shape functions. inset() is the rectangular form: it describes a clip region as a box shrunk inward from each side of the element's border box by specified amounts. The specification for inset() follows the same shorthand expansion logic as CSS margin and padding — a fact that becomes relevant in the attacks that follow.

The function signature is:

clip-path: inset( top right bottom left round <border-radius> );
/* or with shorthand (same rules as margin/padding): */
clip-path: inset( all-sides );
clip-path: inset( vertical horizontal );
clip-path: inset( top horizontal bottom );
clip-path: inset( top right bottom left );

When the browser receives an inset() value it computes the visible rectangle as follows. Let the element's border-box width be W and height be H. Let the four resolved inset values (after expanding shorthand and resolving percentages) be T (top), R (right), B (bottom), and L (left). The visible region is then:

Visible width = max(0, W - L - R)
Visible height = max(0, H - T - B)

Visible area = Visible width × Visible height
= max(0, W - L - R) × max(0, H - T - B)

Visible area fraction = Visible area / (W × H)

The max(0, ...) clamp is critical. When L + R > W, the horizontal extent is negative in naive arithmetic — the spec clamps it to zero, meaning the visible region collapses to an empty set. This is called an overlapping insets condition: the left inset and right inset have together consumed more than the element's full width. The element is rendered as if it has zero width, even though its layout box remains intact and every DOM property reports the full dimensions.

Percentage resolution. Percentage values in inset() resolve against the element's own dimensions, not the containing block. A top or bottom percentage resolves against the element's height; a left or right percentage resolves against the element's width. This is the same resolution axis used by background-position and differs from margin/padding, where all percentage values resolve against the containing block's width. The distinction matters for the attacks below: inset(50%) resolves as inset(50% 50% 50% 50%), meaning 50% from each of the four sides, which for a 400 × 200 element gives:

T = 50% of H = 0.50 × 200 = 100px
R = 50% of W = 0.50 × 400 = 200px
B = 50% of H = 0.50 × 200 = 100px
L = 50% of W = 0.50 × 400 = 200px

Visible width = max(0, 400 - 200 - 200) = max(0, 0) = 0px
Visible height = max(0, 200 - 100 - 100) = max(0, 0) = 0px
Visible area = 0 × 0 = 0px²

calc() composition. Every inset argument accepts a calc() expression, which can in turn reference CSS custom properties via var(). This means the effective inset values can be decoupled from the stylesheet at static-analysis time: the stylesheet declares a benign-looking inset(0px 0px 0px 0px) but the actual computed inset depends on a custom property whose value is set — or changed — at runtime by JavaScript. Static auditors read the stylesheet and see zero insets; the runtime value can be anything.

How inset() differs from polygon() and circle()

Understanding why inset() is a distinctly exploitable shape function requires comparing it to the other clip-path shape functions an MCP server might use.

clip-path: polygon() defines a clip region as an arbitrary sequence of vertices. Detecting a malicious polygon requires checking all vertices and computing whether the resulting polygon actually covers the expected consent text area. A polygon with carefully chosen vertices can create a shape that excludes a thin strip containing the critical permission text — but a static auditor examining the polygon's vertex list can compute the polygon's area and compare it to the element's bounding box area. An empty polygon (zero area) is immediately suspicious; a near-zero-area polygon with vertices outside the element bounds is detectable.

clip-path: circle() defines a circular clip region. Its arguments are a radius and an optional center point. Reducing the radius to zero produces an invisible element — but a zero or near-zero radius is trivially detectable by reading the computed value.

inset() is different in two ways. First, its four independent arguments enable attacks that are not detectable by simply checking whether any single argument is suspicious — the combination of the four values determines visibility, and a pair of values that individually look small (e.g., inset(0 0 0 99%)) can reduce the visible area to a sliver. Second, its shorthand expansion creates a deceptively compact representation: inset(50%) looks like a single small-percentage value but expands to all four sides, producing zero visible area. A naive auditor checking "is any inset value large?" will miss both patterns.

The inset() audit gap: Standard DOM auditors check display, visibility, opacity, color, and bounding box dimensions. None of them compute max(0, W - L - R) × max(0, H - T - B). The entire clip-path property is typically passed through unchanged — and within clip-path, inset() requires four-argument arithmetic to detect, not a simple threshold check on any one value.

Attack 1: right-sliver — SA-CSS-CPI-001 CRITICAL

1

SA-CSS-CPI-001 — CRITICAL: inset(0 0 0 99%) right-sliver attack

A left inset of 99% clips all but a 1% right-edge sliver of the element's width. On a 400px-wide consent widget this produces a 4px visible strip — geometrically present but practically unreadable. getBoundingClientRect() reports 400px width; textContent contains the full disclosure text; every standard visibility check passes.

The right-sliver attack sets the left inset to a large percentage, pushing the visible rectangle's left boundary nearly off the right edge of the element. The remaining three insets are zero. The formula for a 400 × 200 consent widget with inset(0 0 0 99%):

T = 0px, R = 0px, B = 0px, L = 99% of W = 0.99 × 400px = 396px

Visible width = max(0, 400 - 396 - 0) = max(0, 4) = 4px
Visible height = max(0, 200 - 0 - 0) = max(0, 200) = 200px
Visible area = 4px × 200px = 800px²

Visible area fraction = 800 / (400 × 200) = 800 / 80000 = 1%

The resulting 4-pixel-wide vertical strip sits at the extreme right edge of the element. It contains no readable text — the consent disclosure, the permission scope list, and the action buttons all reside in the first 90% of the element's width and are entirely clipped away. What remains visible is at most a sliver of the element's right border or background color.

Now consider what DOM APIs report for this element:

  • getBoundingClientRect() returns { width: 400, height: 200 } — the layout box is unaffected by clip-path
  • el.textContent returns the full consent disclosure string
  • getComputedStyle(el).display returns "block"
  • getComputedStyle(el).visibility returns "visible"
  • getComputedStyle(el).opacity returns "1"
  • getComputedStyle(el).color returns a valid, high-contrast color value

The only API that reveals the attack is getComputedStyle(el).clipPath, which returns "inset(0% 0% 0% 99%)" (or the pixel-resolved form, depending on the browser). An auditor that reads this value must then parse the four arguments, resolve the 99% against the element's computed width, and evaluate the visible area formula to detect the collapse.

/* SA-CSS-CPI-001: Right-sliver attack */
.mcp-consent-widget {
  /* Layout and DOM are intact: 400px wide, 200px tall, display block */
  width: 400px;
  height: 200px;
  background: #ffffff;
  color: #1a1a1a;
  font-size: 14px;
  padding: 16px 20px;

  /* The attack: clip to the rightmost 1% of the element's width */
  clip-path: inset(0 0 0 99%);
  /*
    Resolved at runtime (400px element):
      T = 0px, R = 0px, B = 0px, L = 396px
      Visible width  = max(0, 400 - 396 - 0) = 4px
      Visible height = max(0, 200 - 0   - 0) = 200px
      Visible area fraction = 4 × 200 / (400 × 200) = 1%

    All consent text renders within x=0..380px — clipped away.
    The 4px right sliver contains nothing readable.
  */
}

/* Why opacity:0 and visibility:hidden audits do NOT catch this: */
/* opacity:0     → getComputedStyle().opacity === "0"  → detectable */
/* visibility:h  → getComputedStyle().visibility === "hidden" → detectable */
/* clip-path     → getComputedStyle().clipPath returns the value,
                   but no standard auditor evaluates the area formula.
                   The string "inset(0 0 0 99%)" passes naive checks
                   that only look for "display:none" or "opacity:0". */

The sliver attack is particularly dangerous when the host page background is white or light gray — the 4px sliver blends visually into the surrounding content, and the user perceives an empty or loading widget rather than a clipped one. The attacker may additionally overlay a legitimate-looking decoration element at the same position, further masking the absence of consent text.

The variant inset(0 99% 0 0) clips to a left-edge sliver instead; inset(99% 0 0 0) clips to a bottom-edge sliver; inset(0 0 99% 0) clips to a top-edge sliver. All produce approximately 1% visible area and are equally exploitable. Detection requires checking all four inset values in combination, not any individual value in isolation.

Attack 2: inset(50%) single-value shorthand zero area — SA-CSS-CPI-002 HIGH

2

SA-CSS-CPI-002 — HIGH: inset(50%) single-value shorthand collapses visible area to zero

The single-value shorthand inset(50%) expands to inset(50% 50% 50% 50%), which resolves each side's inset to 50% of the element's corresponding dimension. The visible width is max(0, W - W/2 - W/2) = 0 and the visible height is max(0, H - H/2 - H/2) = 0. Zero visible area. Evades opacity and visibility checks entirely.

CSS shorthand expansion rules for inset() mirror those of margin and padding: a single value is applied to all four sides. inset(50%) therefore expands to inset(50% 50% 50% 50%). Percentage resolution for inset() uses the element's own width for the horizontal sides and the element's own height for the vertical sides. The exact math for any element of width W and height H:

T = 50% of H = H/2
R = 50% of W = W/2
B = 50% of H = H/2
L = 50% of W = W/2

Visible width = max(0, W - W/2 - W/2) = max(0, W - W) = max(0, 0) = 0
Visible height = max(0, H - H/2 - H/2) = max(0, H - H) = max(0, 0) = 0
Visible area = 0px × 0px = 0px²

This holds for any element dimensions — the zero-area result is dimension-independent. Unlike inset(0 0 0 99%), which requires the auditor to know the element's width to evaluate the formula, inset(50%) can be flagged purely from the parsed value without resolving percentages. Exactly 50% on each opposing pair of sides will always sum to 100% of that dimension, always producing zero visible extent.

The critical comparison with other invisibility techniques that CSS auditors commonly detect:

Technique getComputedStyle() value Detectable by standard audit? Visible area
opacity: 0 opacity: "0" Yes — opacity === "0" check 0 (transparent)
visibility: hidden visibility: "hidden" Yes — visibility check 0 (hidden)
display: none display: "none" Yes — display check 0 (removed from flow)
color: transparent color: "rgba(0,0,0,0)" Yes — color/contrast check Layout present, text invisible
clip-path: inset(50%) clipPath: "inset(50%)" No — no standard auditor evaluates area 0 (clipped to zero)
clip-path: inset(0 0 0 99%) clipPath: "inset(0% 0% 0% 99%)" No — no standard auditor resolves percentages ~1% (sliver)

The table illustrates the audit gap precisely. The four standard invisibility techniques all produce a detectable signal in getComputedStyle() output — a specific property takes a value that maps directly to "invisible." clip-path: inset(50%) produces a computed value that looks syntactically normal. The attack is in the arithmetic, not the property name, and no standard auditor performs that arithmetic.

/* SA-CSS-CPI-002: single-value shorthand zero-area collapse */
.mcp-consent-panel {
  width: 380px;
  min-height: 160px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 12px rgba(0,0,0,0.15);
  padding: 20px 24px;

  /* The attack — looks like one innocuous percentage value */
  clip-path: inset(50%);

  /*
    CSS shorthand expansion:
      inset(50%) → inset(50% 50% 50% 50%)

    Percentage resolution (per CSS Shapes spec):
      Top    = 50% of height H → H/2
      Right  = 50% of width  W → W/2
      Bottom = 50% of height H → H/2
      Left   = 50% of width  W → W/2

    Visible area:
      max(0, W - W/2 - W/2) × max(0, H - H/2 - H/2)
      = max(0, 0)            × max(0, 0)
      = 0 × 0 = 0px²

    Works for ANY element size. No pixel measurement needed.
  */
}

/* The same consent panel with opacity:0 instead — DETECTABLE: */
.consent-panel-opacity-bypass {
  /* ... same layout ... */
  opacity: 0; /* getComputedStyle().opacity === "0" → flagged immediately */
}

/* The same with visibility:hidden — DETECTABLE: */
.consent-panel-visibility-bypass {
  /* ... same layout ... */
  visibility: hidden; /* getComputedStyle().visibility === "hidden" → flagged */
}

The value inset(50%) is the most compact form of a zero-visible-area attack. It is also slightly harder to detect than the sliver attack because a naive check for "large percentage values" might use a threshold of, say, 90% — 50% falls well below that threshold on each individual side, yet the combination is devastating.

Attack 3: CSS custom property injection via calc(var()) — SA-CSS-CPI-003 HIGH

3

SA-CSS-CPI-003 — HIGH: calc(var(--cb, 0px)) custom property escalation on mousedown

The static stylesheet declares clip-path: inset(calc(var(--cb,0px)) 0px 0px 0px). Static analysis sees inset(0px 0px 0px 0px) — a zero-clip, apparently safe value. On mousedown, JavaScript sets --cb to a large pixel value before the click event fires, collapsing visible area during the click. Synthetic event firing during audit exposes the escalation.

CSS custom properties are inherited and cascade like any other property. When a clip-path value uses calc(var(--cb, 0px)), the initial computed clip is inset(0px 0px 0px 0px) — the full element is visible. A static CSS auditor reads the stylesheet, resolves var(--cb, 0px) using the fallback value 0px (because no initial value is set on the element), and concludes that clip-path does not clip the element. This conclusion is correct at page load — but wrong during the consent interaction itself.

The attack adds a mousedown event listener to the consent widget. When the user's mouse button is depressed (before the click event fires), the handler sets the custom property --cb to a large value on the element or a parent. The browser immediately recomputes the clip-path using the new custom property value, collapsing the visible area. The click event fires a few milliseconds later against the now-clipped element. At the moment of click, the visible area is zero — the user clicked through to an underlying element that the attacker positioned beneath the consent widget.

Static audit sees: clip-path: inset(calc(var(--cb, 0px)) 0px 0px 0px)
Resolves fallback: clip-path: inset(0px 0px 0px 0px)
Visible area: max(0, W-0-0) × max(0, H-0-0) = W × H ← FULL AREA

mousedown fires → document.documentElement.style.setProperty('--cb', '200px')
Browser recomputes: clip-path: inset(200px 0px 0px 0px)
For H = 160px: max(0, 160-200-0) = max(0, -40) = 0px
Visible area: 0px × W = 0px² ← ZERO AREA at moment of click

The timing window between mousedown and click is typically 50–150 milliseconds — ample time for a synchronous JavaScript property assignment and a browser style recalculation. The custom property can be set on any ancestor element (including :root / documentElement), giving the attacker flexibility in how they structure the injection without referencing the consent element directly.

/* SA-CSS-CPI-003: CSS custom property injection via calc(var()) */

/* The static stylesheet — looks safe to a static auditor: */
:root {
  /* No --cb declaration here — var() will use its fallback */
}

.mcp-consent-widget {
  width: 400px;
  height: 160px;
  background: #fff;
  padding: 20px;

  /* Static analysis resolves var(--cb, 0px) → 0px → inset(0 0 0 0) → no clip */
  clip-path: inset(calc(var(--cb, 0px)) 0px 0px 0px);
}

/* The malicious JavaScript — runs after DOM load: */
document.querySelector('.mcp-consent-widget')
  .addEventListener('mousedown', () => {
    /*
      Timing window: mousedown fires BEFORE click.
      The next rAF + style recalc happen before the click event.
      Setting --cb here ensures it is resolved at click time.
    */
    document.documentElement.style.setProperty('--cb', '200px');
    /*
      New computed clip-path: inset(200px 0px 0px 0px)
      For a 160px-tall element:
        Visible height = max(0, 160 - 200 - 0) = max(0, -40) = 0px
        Visible area = 0 × 400 = 0px²

      The click registers on whatever is positioned beneath the
      now-invisible consent widget at the cursor coordinates.
    */
  });

/*
  On click (mouseup → click sequence):
    The consent widget has zero visible area.
    The browser still dispatches click to the topmost painted element
    at the click coordinates — but the consent widget is fully clipped,
    so pointer-events may fall through to the element below (if
    pointer-events: none is set, or if the clip affects hit testing).
    Combined with pointer-events manipulation, this is a full
    consent bypass: user clicks, action executes, no consent shown.
*/

/* Cleanup — reset property after click to avoid persistent state: */
document.querySelector('.mcp-consent-widget')
  .addEventListener('click', () => {
    document.documentElement.style.removeProperty('--cb');
  });

Detection of this pattern requires two capabilities beyond static CSS parsing. First, the auditor must scan all CSSStyleSheet rules for clip-path values containing var(). The presence of a custom property reference in a clip-path inset argument is itself a flag — it means the computed clip is not determined statically. Second, the auditor must fire a synthetic mousedown event on the consent element and immediately re-evaluate the computed clip-path value. If the value changes between the pre-mousedown state and the post-mousedown state, the element is executing custom-property escalation.

/* Detection approach for SA-CSS-CPI-003: */

function detectCustomPropertyEscalation(consentEl) {
  /* Step 1: read pre-mousedown clip-path */
  const before = window.getComputedStyle(consentEl).clipPath;

  /* Step 2: fire synthetic mousedown */
  consentEl.dispatchEvent(new MouseEvent('mousedown', {
    bubbles: true, cancelable: true, view: window
  }));

  /* Step 3: read post-mousedown clip-path (synchronously after dispatch) */
  const after = window.getComputedStyle(consentEl).clipPath;

  if (before !== after) {
    return {
      id: 'SA-CSS-CPI-003',
      severity: 'high',
      before,
      after,
      message: `clip-path changed on mousedown: "${before}" → "${after}". ` +
               `Custom property escalation detected.`
    };
  }

  /* Step 4: also scan stylesheets for var() inside clip-path */
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        const cp = rule.style?.clipPath || '';
        if (cp.includes('inset') && cp.includes('var(')) {
          return {
            id: 'SA-CSS-CPI-003',
            severity: 'high',
            rule: rule.cssText,
            message: `Stylesheet rule uses var() inside clip-path inset(): "${cp}". ` +
                     `Static value is unresolvable without evaluating custom properties at runtime.`
          };
        }
      }
    } catch (e) { /* cross-origin sheet — skip */ }
  }
  return null;
}

The synthetic-event approach also exposes mouseover-triggered escalations, where the custom property is set when the user's cursor enters the consent element's bounding box rather than on mousedown. Both cases produce a changed computed value that the auditor detects by comparing pre- and post-event states.

Attack 4: round keyword elliptical corner clipping — SA-CSS-CPI-004 MEDIUM

4

SA-CSS-CPI-004 — MEDIUM: inset(0 round 50%) ellipse corner clipping

The optional round keyword in inset() adds border-radius to the clip rectangle. inset(0 round 50%) with zero edge insets but a 50% border-radius produces an ellipse whose semi-axes equal W/2 and H/2. Naive parsers find "zero edge insets" and conclude no clipping occurs. The ellipse clips all four corners, potentially removing permission scope badges positioned near element corners.

The full syntax of inset() includes an optional round <border-radius> suffix. When present, the clip rectangle's corners are rounded using the same border-radius shorthand syntax. The combination inset(0 round 50%) specifies zero edge insets (full width, full height) but a 50% border radius on every corner. When all four corners have a 50% radius, the clip shape becomes an ellipse:

Element: width W, height H
Clip: inset(0 round 50%)

Clip rectangle: x=0, y=0, width=W, height=H (zero insets)
Border radius: 50% → horizontal radius = W/2, vertical radius = H/2

Resulting clip shape: axis-aligned ellipse
Center: (cx, cy) = (W/2, H/2)
Semi-axis a = W/2 (horizontal)
Semi-axis b = H/2 (vertical)

A point (x, y) is inside the clip if and only if:
(x - cx)² / a² + (y - cy)² / b² ≤ 1
(x - W/2)² / (W/2)² + (y - H/2)² / (H/2)² ≤ 1

The ellipse inscribed within the element's bounding box clips away all four corners. For a rectangular element, the corner regions that fall outside the ellipse equation are invisible. Naive auditors examining this value see inset(0 round 50%), extract the edge insets as zero, compute visible area = W × H, and conclude no clipping is occurring. They ignore the round suffix entirely.

The attack targets MCP consent widgets that display permission scope badges — small labeled elements positioned near the corners of the consent panel showing exactly which permissions are being requested. An elliptical clip removes the corners of the consent panel, making those corner badges invisible while the center of the panel (title, description, accept/reject buttons) remains fully within the ellipse.

/* SA-CSS-CPI-004: round keyword ellipse clips corner badge positions */

.mcp-consent-container {
  width: 420px;
  height: 220px;
  background: #f9fafb;
  border: 1px solid #e5e7eb;
  position: relative;
  padding: 24px;

  /* Zero edge insets → naive auditor says "no clip" */
  /* round 50% → all four corners are a 50% ellipse → corner badges invisible */
  clip-path: inset(0 round 50%);

  /*
    Clip shape: ellipse centered at (210, 110)
      Semi-axis a = 210px (horizontal)
      Semi-axis b = 110px (vertical)

    Point (x,y) is inside if:
      (x - 210)² / 210²  +  (y - 110)² / 110²  ≤  1

    Permission scope badge at top-left corner (30, 30):
      (30 - 210)² / 44100  +  (30 - 110)² / 12100
      = (-180)² / 44100   +  (-80)² / 12100
      = 32400 / 44100     +   6400 / 12100
      = 0.734             +   0.529
      = 1.263             >  1  → OUTSIDE ellipse → CLIPPED
      The badge at (30, 30) is invisible.

    Permission scope badge at top-right corner (390, 30):
      (390 - 210)² / 44100  +  (30 - 110)² / 12100
      = 180² / 44100         +  (-80)² / 12100
      = 32400 / 44100        +   6400 / 12100
      = 0.734                +   0.529
      = 1.263  > 1  → OUTSIDE → CLIPPED

    Accept button at center (210, 180):
      (210 - 210)² / 44100  +  (180 - 110)² / 12100
      = 0 / 44100           +   70² / 12100
      = 0                   +   0.404
      = 0.404  < 1  → INSIDE ellipse → VISIBLE
  */
}

/* Permission scope badges — positioned at corners, clipped by ellipse: */
.scope-badge-tl { position: absolute; top: 12px; left: 12px; font-size: 11px; }
.scope-badge-tr { position: absolute; top: 12px; right: 12px; font-size: 11px; }
/* These badges are present in DOM, have non-zero layout boxes,
   but their coordinates fall outside the ellipse equation → invisible */

To test whether a specific badge position is inside the clip ellipse, the auditor must implement the ellipse inequality check. For corner badges on a rectangular consent element, the geometry almost always places them outside a round 50% ellipse — the 50% radius is specifically chosen to maximize corner clipping while leaving the central content visible and legitimate-looking.

A more aggressive variant combines non-zero edge insets with the round keyword: inset(10% round 40%). Here the auditor must first shrink the clip rectangle by the edge insets, then apply the ellipse to the remaining rectangle, and then check whether the resulting shape covers the consent text coordinates. This two-step geometry is beyond what any existing DOM auditor performs.

Attack summary table

ID Severity Attack pattern Visible area result Detection method
SA-CSS-CPI-001 CRITICAL inset(0 0 0 99%) — right-sliver ~1% (4px strip on 400px element) Parse 4 values, resolve %, compute max(0, W-L-R)×max(0, H-T-B), threshold <5%
SA-CSS-CPI-002 HIGH inset(50%) — shorthand zero area 0% (exactly zero) Expand shorthand, check if opposing pairs sum ≥100%; or compute area formula
SA-CSS-CPI-003 HIGH inset(calc(var(--cb,0px)) …) — custom property escalation 0% at click time; 100% at static analysis time Scan stylesheets for var() in clip-path; fire synthetic mousedown; compare before/after
SA-CSS-CPI-004 MEDIUM inset(0 round 50%) — ellipse corner clipping ~78.5% total area (ellipse inside rect), but corners clipped Detect round keyword; compute ellipse semi-axes; test badge coordinate against ellipse equation

Detection algorithm

The following JavaScript implements detection for all four inset() consent bypass patterns. It reads getComputedStyle(el).clipPath, parses the inset() arguments, resolves percentages to pixels using the element's bounding box, computes the visible area fraction, and applies per-attack detection logic.

/* SkillAudit: clip-path inset() consent bypass detector
   Detects: SA-CSS-CPI-001, SA-CSS-CPI-002, SA-CSS-CPI-003, SA-CSS-CPI-004 */

function auditClipPathInset(consentEl) {
  const findings = [];
  const rect   = consentEl.getBoundingClientRect();
  const W      = rect.width;
  const H      = rect.height;
  const cpRaw  = window.getComputedStyle(consentEl).clipPath || 'none';

  /* ── Helper: resolve an inset token to pixels ───────────────────────── */
  function resolveToken(token, axisSize) {
    token = token.trim();
    if (token.endsWith('%'))  return parseFloat(token) / 100 * axisSize;
    if (token.endsWith('px')) return parseFloat(token);
    if (token.endsWith('em')) {
      const fs = parseFloat(window.getComputedStyle(consentEl).fontSize) || 16;
      return parseFloat(token) * fs;
    }
    return parseFloat(token) || 0;
  }

  /* ── Helper: expand 1/2/3/4 token shorthand to [T, R, B, L] ────────── */
  function expandShorthand(tokens) {
    if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]];
    if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]];
    if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]];
    return tokens.slice(0, 4);
  }

  /* ── SA-CSS-CPI-003 check: var() in clip-path via stylesheet scan ───── */
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        const cp = rule.style?.clipPath || '';
        if (/inset\s*\(/.test(cp) && cp.includes('var(')) {
          findings.push({
            id: 'SA-CSS-CPI-003',
            severity: 'high',
            el: consentEl,
            message: `Stylesheet rule contains var() inside clip-path inset(): "${cp}". ` +
                     `Static value is not resolvable; escalation check required.`
          });
        }
      }
    } catch (_) { /* cross-origin */ }
  }

  /* ── SA-CSS-CPI-003 check: synthetic mousedown escalation ───────────── */
  const cpBefore = window.getComputedStyle(consentEl).clipPath;
  consentEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
  const cpAfter  = window.getComputedStyle(consentEl).clipPath;
  consentEl.dispatchEvent(new MouseEvent('mouseup',   { bubbles: true, cancelable: true, view: window }));
  if (cpBefore !== cpAfter) {
    findings.push({
      id: 'SA-CSS-CPI-003',
      severity: 'high',
      el: consentEl,
      message: `clip-path changed on mousedown: "${cpBefore}" → "${cpAfter}". ` +
               `Custom property escalation confirmed.`
    });
    /* Reset custom property if changed on documentElement */
    /* (conservative: do not attempt cleanup here — caller handles) */
  }

  /* ── Parse inset() from computed clip-path ──────────────────────────── */
  const insetMatch = cpRaw.match(/^inset\s*\(([^)]*)\)/i);
  if (!insetMatch) return findings; /* not an inset() — skip area checks */

  const insetArgs = insetMatch[1].trim();

  /* ── SA-CSS-CPI-004 check: round keyword present ────────────────────── */
  const hasRound = /\bround\b/i.test(insetArgs);
  if (hasRound) {
    /* Extract the border-radius value after 'round' */
    const roundMatch = insetArgs.match(/\bround\s+(.+)$/i);
    const radiusStr  = roundMatch ? roundMatch[1].trim() : '';
    findings.push({
      id: 'SA-CSS-CPI-004',
      severity: 'medium',
      el: consentEl,
      message: `clip-path: inset() uses 'round' keyword: border-radius="${radiusStr}". ` +
               `50% radius produces an ellipse with semi-axes ${(W/2).toFixed(1)}px × ${(H/2).toFixed(1)}px. ` +
               `Check whether consent badge coordinates satisfy ` +
               `(x-${(W/2).toFixed(0)})²/${Math.round(W/2*W/2)} + (y-${(H/2).toFixed(0)})²/${Math.round(H/2*H/2)} ≤ 1.`
    });
  }

  /* ── Parse edge tokens (everything before optional 'round ...') ─────── */
  const edgePart   = insetArgs.replace(/\bround\b.*/i, '').trim();
  const edgeTokens = edgePart ? edgePart.split(/\s+/) : ['0'];

  /* Check for var() references — signals dynamic value (already flagged above) */
  if (edgeTokens.some(t => t.includes('var('))) return findings;

  /* Resolve shorthand to [T, R, B, L] in pixels */
  const [tTok, rTok, bTok, lTok] = expandShorthand(edgeTokens);
  const T = resolveToken(tTok, H);
  const R = resolveToken(rTok, W);
  const B = resolveToken(bTok, H);
  const L = resolveToken(lTok, W);

  const visW    = Math.max(0, W - L - R);
  const visH    = Math.max(0, H - T - B);
  const visArea = visW * visH;
  const totalArea = W * H;
  const fraction  = totalArea > 0 ? visArea / totalArea : 0;

  /* ── SA-CSS-CPI-001 / SA-CSS-CPI-002 check: visible area fraction ───── */
  if (fraction <= 0) {
    /* Check whether this is the single-value shorthand form (SA-CSS-CPI-002) */
    const isSingleValueShorthand = edgeTokens.length === 1 ||
      (edgeTokens.length === 2 && edgeTokens[0] === edgeTokens[1]);
    findings.push({
      id: isSingleValueShorthand ? 'SA-CSS-CPI-002' : 'SA-CSS-CPI-001',
      severity: isSingleValueShorthand ? 'high' : 'critical',
      el: consentEl,
      message: `clip-path: inset(${cpRaw.match(/inset\s*\(([^)]*)\)/i)?.[1]}) produces ` +
               `ZERO visible area on a ${W.toFixed(0)}×${H.toFixed(0)} element. ` +
               `Resolved insets: T=${T.toFixed(1)}px R=${R.toFixed(1)}px ` +
               `B=${B.toFixed(1)}px L=${L.toFixed(1)}px. ` +
               `Visible: ${visW.toFixed(1)}×${visH.toFixed(1)}px.`
    });
  } else if (fraction < 0.05) {
    findings.push({
      id: 'SA-CSS-CPI-001',
      severity: 'critical',
      el: consentEl,
      message: `clip-path: inset() reduces visible area to ${(fraction * 100).toFixed(2)}% ` +
               `(${visW.toFixed(1)}×${visH.toFixed(1)}px visible of ` +
               `${W.toFixed(0)}×${H.toFixed(0)}px total). ` +
               `Resolved insets: T=${T.toFixed(1)} R=${R.toFixed(1)} ` +
               `B=${B.toFixed(1)} L=${L.toFixed(1)}px. ` +
               `CRITICAL: consent text is geometrically clipped to an unreadable sliver.`
    });
  }

  return findings;
}

/* ── Main audit loop ────────────────────────────────────────────────────── */
function runInsetAudit() {
  const CONSENT = /consent|permission|grant.*access|agree.*install|disclosure|privacy/i;
  const allFindings = [];

  for (const el of document.querySelectorAll('*')) {
    const text = (el.textContent || '').substring(0, 600);
    if (!CONSENT.test(text)) continue;
    const cp = window.getComputedStyle(el).clipPath || 'none';
    if (!cp.startsWith('inset')) continue;
    allFindings.push(...auditClipPathInset(el));
  }

  allFindings.forEach(f =>
    console.warn(`[${f.id}] ${f.severity.toUpperCase()}: ${f.message}`)
  );
  return allFindings;
}

runInsetAudit();

Threshold selection: The 5% visible area threshold used above flags SA-CSS-CPI-001. A 400px × 200px consent widget with a visible area below 4000px² (5% of 80,000px²) is practically unreadable. For narrower elements, the absolute pixel threshold matters more than the percentage — a 1% fraction of a 100px × 40px badge is 40px², which is arguably still readable. Production implementations should combine percentage and minimum-pixel-area thresholds (e.g., flag if fraction < 5% or visible area < 2000px²).

SkillAudit automated detection

SkillAudit's consent auditor applies the above algorithm to every element in the MCP server's rendered DOM that contains consent-relevant text — permission disclosure paragraphs, scope badge elements, terms-of-service references, and install-grant buttons. The four inset() attack patterns are checked as part of the same pipeline that evaluates CSS clip-path inset security across the full set of consent elements.

The var() escalation check (SA-CSS-CPI-003) is unique to runtime auditing — it cannot be performed by static analysis tools. SkillAudit's headless browser environment fires synthetic interaction events during the audit pass, capturing the pre- and post-mousedown computed style state for every consent element with a var() reference in its clip-path. This catches escalations that are completely invisible to SAST tools and stylesheet scanners.

For the round keyword ellipse attack (SA-CSS-CPI-004), SkillAudit locates the pixel coordinates of all permission scope badge elements within the consent widget, applies the ellipse inequality test for the computed semi-axes, and reports the badge IDs and their in/out-of-ellipse status. This gives auditors an exact list of which specific permissions are clipped versus visible — a more actionable finding than a general "round keyword detected" warning.

SkillAudit's inset() detection complements the broader CSS overflow consent bypass and CSS opacity consent bypass checks in the same audit run. While overflow, opacity, and visibility attacks typically produce detectable signals in individual computed style properties, inset() attacks require multi-value arithmetic and runtime observation — they represent the frontier of CSS-based consent bypass that naive dimension-based checks cannot reach.

Why "the element has non-zero dimensions" is not a sufficient consent check: All four inset() attack patterns preserve the element's layout dimensions. getBoundingClientRect() returns the full 400 × 200 bounding box whether the visible area is 80,000px² or 0px². The consent element is present in the accessibility tree, its text nodes are non-empty, and its layout participates normally in document flow. The only way to detect these attacks is to read and evaluate clip-path — a property that standard consent auditors do not examine.

Conclusion

CSS clip-path: inset() represents a category of consent bypass that combines geometric precision with auditor blindness. The four arguments that define an inset clip region interact through a simple area formula — max(0, W - L - R) × max(0, H - T - B) — but evaluating that formula requires knowing the element's rendered dimensions and resolving percentage values against them. Standard DOM auditors do not perform this calculation. Standard WCAG tools do not check clip-path at all.

The four attack patterns documented here span a spectrum of sophistication. SA-CSS-CPI-001 (right-sliver) and SA-CSS-CPI-002 (shorthand zero area) are static and detectable by reading and evaluating the computed clip-path value. SA-CSS-CPI-003 (custom property escalation) requires runtime observation with synthetic events — the static stylesheet appears safe, and only the moment of user interaction reveals the attack. SA-CSS-CPI-004 (round keyword ellipse) requires geometric computation at specific coordinates, not just aggregate area calculation.

Together they illustrate a broader principle: as CSS gains expressive power for layout and visual design, the surface area of property-based consent bypass attacks grows. Each new geometric primitive — inset(), polygon(), path(), SVG fill-rule, mask-image — introduces a new class of attack that requires specific geometric reasoning to detect. General-purpose DOM auditors cannot keep pace with this surface area by adding more property-name checks. What is required is a dedicated consent audit engine that understands rendering geometry, resolves spatial arithmetic, and observes behavior across the interaction timeline. That is what SkillAudit provides.

Further reading

These pages cover related CSS consent bypass vectors and security considerations:

SkillAudit detects all four clip-path: inset() consent bypass patterns — overlapping edge insets, shorthand zero-area collapse, custom property escalation on mousedown, and ellipse corner clipping via the round keyword — in automated headless audits that combine computed style evaluation, percentage resolution, synthetic event firing, and geometric coordinate testing. Run a full audit at skillaudit.dev.