Security Guide

MCP server CSS position-anchor security — rebinding to off-screen anchor moves button out of viewport, cascade override changes default anchor, 0-size anchor makes anchor() values resolve to off-viewport coordinates, JS mousedown redirects anchor target before click fires

CSS position-anchor binds a CSS-anchor-positioned element to its default anchor. Every anchor() and anchor-size() call in that element's style resolves against this anchor. Changing position-anchor to point to a hidden or off-screen element effectively teleports the consent button to that element's coordinates — no other properties on the button need to change.

CSS position-anchor — property overview

position-anchor is a CSS property on a CSS-anchor-positioned element (one with position: absolute, fixed, or sticky using CSS Anchor Positioning). It specifies the default anchor by referencing an anchor-name custom identifier declared on another element. When set, anchor() and anchor-size() functions in the element's style can omit the anchor name and still resolve correctly. Related: anchor positioning overview, anchor-name, anchor-scope.

Attack 1: rebinding position-anchor to an off-screen anchor element

The consent button is positioned via CSS Anchor Positioning, using anchor() to compute its inset values relative to a visible anchor element (e.g., the consent dialog container). An attacker adds a second element with a different anchor-name off-screen and injects a CSS rule or inline style that overrides position-anchor to point to the off-screen element. All anchor() calls in the button's style now resolve against the off-screen anchor. The button moves to the off-screen coordinates. Its CSS opacity, z-index, and visual properties are unchanged — it is simply positioned off-screen.

/* Normal setup: button anchored to visible consent dialog */
.consent-dialog {
  anchor-name: --consent-dialog;
}

.consent-btn {
  position: absolute;
  position-anchor: --consent-dialog;
  top:    anchor(top);
  left:   anchor(left);
  width:  anchor-size(width);
  height: anchor-size(height);
  /* Button overlays exactly the consent dialog */
}

/* Attack: inject off-screen anchor + override position-anchor */
.offscreen-trap {
  anchor-name: --offscreen-trap;
  position: fixed;
  top: -9999px;
  left: -9999px;
  width: 100px;
  height: 100px;
}

/* Override via injected stylesheet or higher-specificity rule */
.consent-section .consent-btn {
  position-anchor: --offscreen-trap;
  /* All anchor() calls now resolve to (-9999px, -9999px)
     Button's top: anchor(top) = -9999px → off-screen
     Button's left: anchor(left) = -9999px → off-screen
     opacity, z-index, visibility all unchanged — button just not on screen */
}
// Detection: validate that position-anchor resolves to a visible element
function auditPositionAnchor(el) {
  const cs = getComputedStyle(el);
  const positionAnchor = cs.getPropertyValue('position-anchor').trim();

  if (!positionAnchor || positionAnchor === 'auto' || positionAnchor === 'none') return;

  // Find the element with matching anchor-name
  let anchorEl = null;
  document.querySelectorAll('*').forEach(candidate => {
    const anchorName = getComputedStyle(candidate).getPropertyValue('anchor-name').trim();
    if (anchorName === positionAnchor) anchorEl = candidate;
  });

  if (!anchorEl) {
    console.warn('[SkillAudit] position-anchor:', positionAnchor,
      '— no element found with matching anchor-name; positioned element has no anchor;',
      'fallback position may be off-screen; element:', el);
    return;
  }

  const anchorRect = anchorEl.getBoundingClientRect();
  const anchorInViewport =
    anchorRect.top < window.innerHeight && anchorRect.bottom > 0 &&
    anchorRect.left < window.innerWidth && anchorRect.right > 0;

  if (!anchorInViewport) {
    console.warn('[SkillAudit] position-anchor:', positionAnchor,
      '— anchor element is NOT in viewport; consent button may be positioned off-screen;',
      'anchor BCR:', JSON.stringify(anchorRect),
      '| consent button BCR:', JSON.stringify(el.getBoundingClientRect()),
      '| anchor element:', anchorEl);
  }
}

BCR on the button will be off-screen: After the rebinding, consentButton.getBoundingClientRect() returns coordinates matching the off-screen anchor. An audit that reads BCR on the consent button and checks top < viewportHeight will correctly detect the button is off-screen. But an audit that only checks the button's own CSS properties — opacity, visibility, display — will see no issue. The attack is invisible to style-only audits; it requires a layout (BCR) check.

Attack 2: cascade override changes default anchor via higher-specificity rule

The consent button has position-anchor: --consent-dialog in a base stylesheet. An injected stylesheet adds a more specific selector that overrides this. Unlike anchor-name injection (which requires adding a new anchor-name to an element), this attack only needs a CSS rule that sets position-anchor to an attacker-controlled name. If a matching element with that anchor-name does not exist, the anchor is unresolved — the browser's fallback behavior applies, which may place the button at a default position or make it disappear.

/* Attack: cascade injection via ID or :is() specificity boost */
#consent-wrapper .modal-body .consent-btn {
  position-anchor: --unresolved-anchor;
  /* --unresolved-anchor has no element with matching anchor-name.
     Browser behavior when anchor is unresolved:
     - anchor() function values become invalid → treated as 'auto'
     - auto in inset properties → browser chooses position (may be 0,0)
     - If @position-try fallbacks are defined, browser tries each
     - If no fallback works, element may be hidden or positioned at 0,0 */
}

/* Fallback behavior varies by browser. In some cases:
   top: anchor(top) → top: auto → button stacks to document top
   In others: element is hidden entirely (no valid position found) */
// Detection: check position-anchor against known safe anchor names
function auditCascadePositionAnchor(el, safeAnchorNames = ['--consent-dialog', '--consent-anchor']) {
  const cs = getComputedStyle(el);
  const positionAnchor = cs.getPropertyValue('position-anchor').trim();

  if (!positionAnchor || positionAnchor === 'auto' || positionAnchor === 'none') return;

  if (!safeAnchorNames.includes(positionAnchor)) {
    console.warn('[SkillAudit] position-anchor unexpected value:',
      positionAnchor, '(expected one of:', safeAnchorNames.join(', ') + ')',
      '— may be cascade-injected anchor redirect; verify button is on-screen;',
      'BCR:', JSON.stringify(el.getBoundingClientRect()), '| element:', el);
  }
}

Attack 3: binding to a 0-size anchor collapses anchor-size() values

If position-anchor points to an element with zero width and height (e.g., width:0; height:0 or an element with no dimensions), then anchor-size(width) and anchor-size(height) resolve to 0. If the button's own width and height depend on these functions (e.g., width: anchor-size(width) to match the consent container), the button collapses to zero size. A zero-size element passes opacity and visibility checks — it exists and is "visible" — but cannot be interacted with because it has zero click area.

/* Attack: position-anchor bound to 0-size element */
.zero-size-anchor {
  anchor-name: --collapse-anchor;
  position: fixed;
  width: 0; height: 0;
  top: 50%; left: 50%; /* centered but 0-size */
}

/* Inject: point consent button at 0-size anchor */
.consent-btn {
  position-anchor: --collapse-anchor;
  width:  anchor-size(width);  /* resolves to 0 */
  height: anchor-size(height); /* resolves to 0 */
  /* Button is at center of screen but 0×0px.
     opacity: 1 (passes opacity check)
     visibility: visible (passes visibility check)
     getBoundingClientRect().width = 0 → click area = 0
     No click events can fire on a 0×0 element. */
}
// Detection: check anchor element has non-zero dimensions
function auditZeroSizeAnchor(el) {
  const cs = getComputedStyle(el);
  const positionAnchor = cs.getPropertyValue('position-anchor').trim();

  if (!positionAnchor || positionAnchor === 'auto' || positionAnchor === 'none') return;

  let anchorEl = null;
  document.querySelectorAll('*').forEach(candidate => {
    if (getComputedStyle(candidate).getPropertyValue('anchor-name').trim() === positionAnchor) {
      anchorEl = candidate;
    }
  });

  if (!anchorEl) return;

  const anchorRect = anchorEl.getBoundingClientRect();
  if (anchorRect.width === 0 || anchorRect.height === 0) {
    const elRect = el.getBoundingClientRect();
    console.warn('[SkillAudit] position-anchor:', positionAnchor,
      '— anchor element has zero dimensions (width:', anchorRect.width,
      'height:', anchorRect.height + ');',
      'anchor-size() calls will resolve to 0;',
      'consent button may have zero click area;',
      'button width:', elRect.width, 'height:', elRect.height,
      '| anchor element:', anchorEl, '| button element:', el);
  }
}

Attack 4: JS mousedown — redirect position-anchor to off-screen element

The consent button is correctly positioned via position-anchor: --consent-dialog. At capture-phase mousedown, the attacker injects position-anchor: --offscreen-trap via the button's inline style. The browser immediately recalculates the button's position using the new anchor — which is off-screen. The button teleports from its visible position to the off-screen trap position before the click event fires. The mousedown event was captured at the visible position, but the click fires on the element's new (invisible) location. No click handler triggers the consent action.

/* JS attack: inject position-anchor at mousedown */
document.addEventListener('mousedown', e => {
  const btn = document.querySelector('.consent-btn');
  if (!btn) return;

  btn.style.setProperty('position-anchor', '--offscreen-trap');
  /* Effect:
     - anchor() inset values immediately resolve against --offscreen-trap
     - Button repositions to (-9999px, -9999px) before click fires
     - mousedown: captured at visible position → button there
     - click fires: button is now off-screen → no visible target
     - The user's tap/click completes but button is no longer where they clicked */

  /* Note: position-anchor only affects anchor() and anchor-size() values.
     If the button has static inset properties (top: 100px) not using anchor(),
     this attack has no effect. Only buttons using anchor() for their position. */
}, true);
// Detection: monitor position-anchor during click events
const mouseState = { down: false };
document.addEventListener('mousedown', () => { mouseState.down = true; },  true);
document.addEventListener('mouseup',   () => { mouseState.down = false; }, true);

new MutationObserver(mutations => {
  if (!mouseState.down) return;
  for (const m of mutations) {
    if (m.attributeName !== 'style') continue;
    const el = m.target;
    const anchor = el.style.getPropertyValue('position-anchor');
    if (anchor) {
      const rect = el.getBoundingClientRect();
      const inViewport = rect.top < window.innerHeight && rect.bottom > 0;
      console.warn('[SkillAudit] position-anchor changed during mousedown to:', anchor,
        '— button in viewport after change:', inViewport,
        '| BCR after change:', JSON.stringify(rect),
        '| element:', el);
    }
  }
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });

Findings summary

High Off-screen anchor rebinding: position-anchor set to an element with anchor-name pointing off-screen — all anchor() insets resolve to off-viewport coordinates; button is fully off-screen; opacity, visibility, and display properties all look correct; detected only via getBoundingClientRect() on the button or the anchor element.
High Cascade override to unresolved anchor: injected higher-specificity rule sets position-anchor to a name with no matching anchor-name element — anchor() values become invalid, button may appear at 0,0 or be hidden; fallback behavior is browser-dependent; detected by checking the position-anchor value against expected safe anchor names.
Medium Zero-size anchor: position-anchor bound to element with 0 width/height — anchor-size() calls resolve to 0; button may have zero click area while opacity:1 and visibility:visible pass checks; detected by reading anchor element BCR dimensions after position-anchor resolution.
High JS mousedown position-anchor redirect: inline style injects new position-anchor at mousedown — button repositions before click fires; click land on empty space; anchor() insets recalculate immediately; MutationObserver on button style attribute detects the injection; only affects buttons whose insets use anchor() functions.

SkillAudit audits position-anchor values, resolves the referenced anchor element, verifies it is in-viewport with non-zero dimensions, and cross-checks the positioned element's BCR against the expected anchor region. Runtime injection detection monitors position-anchor style changes during click events. Run a free audit on your MCP server.