Security reference · CSS injection · Outline attacks · Inset overlay

MCP server CSS outline-offset security

The CSS outline-offset property moves the outline relative to the element's border box. A positive value expands the outline outward; a negative value pulls it inward, into the element's padding and content area. When the negative offset equals or exceeds the outline width, the outline renders entirely inside the element — directly over the text content. MCP servers exploit this by setting a thick outline in the background color of the page, effectively painting an invisible-but-opaque box over the consent disclosure without changing display, visibility, or opacity.

outline-offset mechanics and inset attack geometry

outline-offset valueOutline positionEffect on content
0 (default)Outline sits on top of the borderNo content coverage — outline is at the element boundary
-10pxOutline starts 10px inside the border edgeOutline overlaps padding area but typically not content
-50px with outline-width: 50pxOutline edge coincides with padding-box; inner edge reaches content areaOutline starts overlapping content area from all four sides
-200px with outline-width: 200pxOutline pulled 200px inward; inner edge 200px deeper stillOutline completely covers the content area of any element smaller than 400px

Outline does not affect layout: Unlike border, the CSS outline property does not affect the box model — it does not contribute to the element's dimensions and does not move sibling elements. A massive inset outline covering all consent text has zero effect on layout. The element's width, height, and position remain exactly as authored. Only the visual rendering is affected — consent text is painted under the outline layer and is occluded.

Attack 1: background-color inset outline — full text coverage

The primary outline-offset attack: a thick outline in the same color as the page background, combined with a large negative outline-offset that pulls the outline entirely inside the element. The consent element exists, is rendered, and has normal computed display and visibility values — but the page-background-colored outline layer is painted on top of the text, making it invisible. The outline is not a separate overlay element; it is part of the consent element's own rendering:

/* Malicious CSS — SA-CSS-OUTOFF-001 */
/* Assumptions: page background is white (#ffffff) */
/* consent disclosure is a block-level element with some padding */
.mcp-consent-disclosure {
  /* Normal layout properties — nothing suspicious */
  padding: 16px;
  border-radius: 8px;
  font-size: 13px;
  color: #333;

  /* The attack — outline inset overlay */
  outline-width: 200px;
  outline-style: solid;
  outline-color: #ffffff; /* matches page background exactly */
  outline-offset: -200px; /* pulls outline 200px inside the element */

  /* Result:
     - outline-width 200px pulls inward 200px from each edge
     - outline-offset -200px pulls inward another 200px
     - Total inset from border edge: 200px (outline-offset) + 0 = 200px
     - The outline inner edge reaches 200px inside the element from all sides
     - For any element narrower than 400px, the outline fills the entire content
     - Text under the outline is not painted (outline renders above text in stacking order)
     - Page background color matches outline color — looks like white space
  */
}

/* Why this evades consent-visibility detection:
   - computed display: block (not none)
   - computed visibility: visible (not hidden)
   - computed opacity: 1 (not 0)
   - Element has real dimensions — getBoundingClientRect().width and height are non-zero
   - Text color is #333 — contrast check passes (dark on white)
   - The outline layer is not checked by standard consent visibility scanners
   - No pseudo-element involved — outline is part of the element itself

/* Refinement: use CSS custom properties for dynamic background tracking */
.mcp-consent-disclosure {
  outline: 200px solid var(--page-bg, #ffffff);
  outline-offset: -200px;
}

/* Detection: check for large negative outline-offset on elements with consent text */
function detectInsetOutlineAttack() {
  const findings = [];
  const candidates = document.querySelectorAll('*');
  for (const el of candidates) {
    if (!/consent|disclosure|terms|privacy|by installing|by clicking/i.test(el.textContent || '')) continue;
    /* Skip elements that are too large or are wrappers (check direct text content) */
    if (el.children.length > 3) continue;
    const style = getComputedStyle(el);
    const outlineOffset = parseFloat(style.outlineOffset);
    const outlineWidth = parseFloat(style.outlineWidth);
    if (outlineOffset < -20 && outlineWidth > 20) {
      findings.push({ id: 'SA-CSS-OUTOFF-001', severity: 'high',
        message: `Element with consent text has outline-offset:${outlineOffset}px and outline-width:${outlineWidth}px — inset outline may cover content. outline-color: ${style.outlineColor}` });
    }
    /* Check if outline color matches background */
    const bgColor = style.backgroundColor;
    if (bgColor !== 'rgba(0, 0, 0, 0)' && style.outlineColor === bgColor && outlineOffset < 0) {
      findings.push({ id: 'SA-CSS-OUTOFF-001', severity: 'critical',
        message: `Consent element outline-color matches background-color (${bgColor}) with negative outline-offset — background-colored inset outline covering consent text` });
    }
  }
  return findings;
}

Stacking order: outline renders above content, below pseudo-elements: In the CSS painting order, an element's outline is painted after its background and content but before ::after pseudo-element content. This means the inset outline covers the text content of the element but is itself covered by ::after overlays. MCP can chain this with a pseudo-element to create a two-layer covering attack — inset outline covers the text; pseudo-element covers the outline's visible edge artifacts.

Attack 2: focus-within triggered inset outline — interactive hiding

A consent disclosure block that appears visible before the user interacts with the install form, then becomes covered when the user focuses an input field inside the form. Using :focus-within on the consent's parent container, MCP applies the inset outline only after user interaction — passing audits that check consent visibility at load time, but hiding consent precisely when the user is about to interact with the install flow:

/* Malicious CSS — SA-CSS-OUTOFF-002 */
/* Consent visible at page load; hidden when any child input is focused */
/* The install form input is inside the same container as the consent disclosure */
.mcp-install-container {
  /* Normal container styles */
}

/* At load time: .mcp-consent-disclosure has no outline — consent is visible */
.mcp-consent-disclosure {
  outline: none;
  transition: outline-width 0ms, outline-offset 0ms; /* instant switch */
}

/* When user focuses the install form's name or email field: */
.mcp-install-container:focus-within .mcp-consent-disclosure {
  outline-width: 500px;
  outline-style: solid;
  outline-color: #f8fafc; /* light gray background matching page */
  outline-offset: -500px;
  /* Consent is now covered — precisely when user is filling the install form */
}

/* Audit tool check: consent is visible at load time ✓ */
/* User check: by the time user sees consent, they focus a field → consent hidden */

/* Alternative: :hover-based trigger */
.mcp-install-section:hover .mcp-consent-disclosure {
  outline: 300px solid white;
  outline-offset: -300px;
  /* Triggered as soon as mouse enters the install section */
}

/* Detection: check for consent elements inside :focus-within or :hover rules */
function detectFocusWithinOutlineAttack() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        const sel = rule.selectorText || '';
        if (!/:focus-within|:focus|:hover/.test(sel)) continue;
        const css = rule.cssText || '';
        if (!/outline/.test(css)) continue;
        if (!/consent|disclosure|terms/.test(sel)) continue;
        const offsetMatch = css.match(/outline-offset\s*:\s*(-[\d.]+px)/);
        if (offsetMatch) {
          const offset = parseFloat(offsetMatch[1]);
          if (offset < -20) {
            findings.push({ id: 'SA-CSS-OUTOFF-002', severity: 'critical',
              message: `CSS rule "${sel}" applies outline-offset:${offset}px on consent element triggered by :focus-within/:hover — interactive inset outline attack hiding consent during install interaction` });
          }
        }
      }
    } catch (_) {}
  }
  return findings;
}

Attack 3: pseudo-element inset outline — stacked overlay

Rather than applying the inset outline directly to the consent element, MCP places it on a ::after or ::before pseudo-element that is absolutely positioned to cover the consent element. This indirection makes the attack harder to attribute: the consent element itself has no suspicious CSS properties, and the pseudo-element's outline is painted on a different element. The pseudo-element acts as a translucent-to-opaque overlay with the inset outline providing the fill:

/* Malicious CSS — SA-CSS-OUTOFF-003 */
/* The consent element itself is clean — no suspicious CSS */
.mcp-consent-disclosure {
  position: relative; /* required for ::after absolute positioning */
  padding: 12px;
  font-size: 13px;
  color: #374151;
  /* Nothing suspicious here */
}

/* The attack is in the pseudo-element: */
.mcp-consent-disclosure::after {
  content: '';
  position: absolute;
  inset: 0;          /* cover the entire consent disclosure */
  /* The ::after element now covers the consent element's box exactly */

  /* Inset outline on the ::after covers the consent text */
  outline: 300px solid #ffffff; /* white matching page background */
  outline-offset: -300px;        /* pull all the way inside */

  /* The ::after has no background by default (transparent) */
  /* But its outline layer — rendered as part of its own painting — */
  /* covers the text in the parent element because it is positioned on top */
}

/* Why pseudo-element indirection evades detection:
   - The consent element's computed outline is "none"
   - The ::after element has content:'' — empty content, expected for decorators
   - Consent text scanners check the element itself, not its pseudo-elements
   - getComputedStyle(el) does not expose ::after outline properties
   - Must use getComputedStyle(el, '::after') to see pseudo-element styles
   - Most automated tools do not inspect pseudo-element outline properties

/* Detection: check ::after and ::before outline on consent-containing elements */
function detectPseudoElementOutlineAttack() {
  const findings = [];
  const candidates = document.querySelectorAll('*');
  for (const el of candidates) {
    if (!/consent|disclosure|terms|privacy/i.test(el.textContent || '')) continue;
    for (const pseudo of ['::after', '::before']) {
      try {
        const pseudoStyle = getComputedStyle(el, pseudo);
        const outlineOffset = parseFloat(pseudoStyle.outlineOffset);
        const outlineWidth = parseFloat(pseudoStyle.outlineWidth);
        if (outlineOffset < -10 && outlineWidth > 10) {
          findings.push({ id: 'SA-CSS-OUTOFF-003', severity: 'high',
            message: `Consent element ${pseudo} pseudo-element has outline-offset:${outlineOffset}px, outline-width:${outlineWidth}px — pseudo-element inset outline may cover consent text. pseudo outline-color: ${pseudoStyle.outlineColor}` });
        }
      } catch (_) {}
    }
  }
  return findings;
}

Attack 4: CSS variable-driven inset outline — dynamic background tracking

A sophisticated variant uses CSS custom properties (var(--bg)) to track the page background color dynamically. If the page uses a theme switcher (light/dark mode) or if the MCP payload is injected into different host pages with different background colors, a hardcoded white outline will only work on white-background pages. By referencing a custom property that mirrors the background color, MCP creates an inset outline attack that adapts to any background. The custom property is set by MCP's injected script after reading getComputedStyle(document.body).backgroundColor:

/* Malicious CSS — SA-CSS-OUTOFF-004 */
/* MCP injects this CSS at install-form render time */
:root {
  --mcp-bg: #ffffff; /* set by MCP script to match host page background */
}

.mcp-consent-disclosure {
  outline: 500px solid var(--mcp-bg);
  outline-offset: -500px;
  /* Dynamically tracks host page background; works on dark and light themes */
}

/* MCP's injected script reads the background and sets the variable: */
/*
const bg = getComputedStyle(document.documentElement).backgroundColor
  || getComputedStyle(document.body).backgroundColor;
document.documentElement.style.setProperty('--mcp-bg', bg);
const style = document.createElement('style');
style.textContent = `.mcp-consent-disclosure { outline: 500px solid var(--mcp-bg); outline-offset: -500px; }`;
document.head.appendChild(style);
*/

/* Why CSS variable tracking is harder to detect:
   - The CSS file itself shows "var(--mcp-bg)" — color is not hardcoded
   - Static analysis of the CSS cannot determine what color will be used at runtime
   - The custom property is set by script after reading the host page background
   - This makes the attack background-agnostic: works on white, dark, branded pages
   - Detection requires reading the computed value of outline-color at runtime

/* Detection: check computed outline-color vs background-color at runtime */
function detectDynamicBackgroundOutlineAttack() {
  const findings = [];
  const candidates = document.querySelectorAll('[class*="consent"], [class*="disclosure"], [class*="terms"]');
  for (const el of candidates) {
    if (!/consent|disclosure|terms|privacy/i.test(el.textContent || '')) continue;
    const style = getComputedStyle(el);
    const outlineOffset = parseFloat(style.outlineOffset);
    const outlineWidth = parseFloat(style.outlineWidth);
    if (outlineOffset >= 0 || outlineWidth < 5) continue;
    /* Get all ancestor background colors to check outline match */
    let ancestor = el.parentElement;
    while (ancestor && ancestor !== document.documentElement) {
      const ancestorBg = getComputedStyle(ancestor).backgroundColor;
      if (ancestorBg !== 'rgba(0, 0, 0, 0)' && ancestorBg === style.outlineColor) {
        findings.push({ id: 'SA-CSS-OUTOFF-004', severity: 'critical',
          message: `Consent element outline-color (${style.outlineColor}) matches ancestor background-color — CSS-variable-driven background-tracking inset outline attack. outline-offset: ${outlineOffset}px` });
        break;
      }
      ancestor = ancestor.parentElement;
    }
    /* Also check CSS variable usage in inline styles */
    const inlineStyle = el.getAttribute('style') || '';
    if (/outline/.test(inlineStyle) && /var\(/.test(inlineStyle)) {
      findings.push({ id: 'SA-CSS-OUTOFF-004', severity: 'high',
        message: `Consent element inline style sets outline using CSS var() — possible dynamic background-tracking inset outline attack: "${inlineStyle}"` });
    }
  }
  return findings;
}

Outline vs border — why the distinction matters for auditors: CSS border occupies layout space and is included in the box model. CSS outline does not — it is painted outside the layout flow. This means an element with a 500px inset outline will have the same offsetWidth, offsetHeight, and getBoundingClientRect() dimensions as one without any outline. Tools that check element dimensions to verify content space will report the element as "normally sized." The visual coverage is invisible to dimension-based checks. SkillAudit checks computed outline-offset, outline-width, and outline-color against background colors for all consent-containing elements.

SkillAudit findings for CSS outline-offset consent attacks

CriticalSA-CSS-OUTOFF-001 — large negative outline-offset with thick outline in background color on consent element. Computed outline-color matches parent/ancestor background. Outline paints over consent text at the element's content layer. Display, visibility, and opacity are all normal. Detected by checking outlineOffset < -20 and outlineColor === backgroundColor.
CriticalSA-CSS-OUTOFF-002 — :focus-within or :hover CSS rule applies inset outline to consent element when user interacts with install form. Consent is visible at page load (passes audit-time check) but covered during install interaction. Detected by scanning CSS rules for interaction-triggered outline-offset < -20 on consent-labeled elements.
HighSA-CSS-OUTOFF-003 — ::after or ::before pseudo-element absolutely positioned over consent element, with inset outline on the pseudo-element covering the consent text. Consent element's own computed CSS is clean. Detected by checking getComputedStyle(el, '::after').outlineOffset for large negative values.
CriticalSA-CSS-OUTOFF-004 — CSS variable-driven inset outline: outline: 500px solid var(--mcp-bg) where --mcp-bg is set by MCP script to match the host page background color. Background-agnostic attack. Detected at runtime by comparing computed outline-color against ancestor background colors and flagging CSS variable usage in outline declarations.

Related MCP consent attack research

Audit your MCP server for outline-offset inset overlay attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-OUTOFF findings.