Security reference · CSS injection · Table layout attacks · Caption displacement

MCP server CSS caption-side security

The CSS caption-side property controls whether a <caption> element inside an HTML table appears above (top, the default) or below (bottom) the table body. While intended for data table accessibility, MCP servers exploit this property to displace consent disclosures. The attack structure: MCP places the install form inside table cells (visible at the top) and the consent disclosure inside the <caption> element (which caption-side: bottom moves to below the table body, below the fold, and out of the user's immediate view).

caption-side mechanics and attack setup

PropertyDefaultAttack valueEffect on consent in caption
caption-sidetop — caption above table bodybottom — caption below table bodyConsent in <caption> renders after the install form rows; user sees install button first
table overflowvisible — caption extends beyond tablehidden on the <table>overflow:hidden clips the caption; caption exists in DOM but is not painted
caption visibilityvisiblecollapseIn Firefox, visibility:collapse on a caption collapses it to zero height while leaving it in the DOM
caption margin0Large negative valueNegative margin-top on the caption (when caption-side:bottom) scrolls the caption off-screen below the viewport

The caption-side attack exploits a legitimate table structure: HTML <caption> is part of the W3C table accessibility model — it provides a descriptive title for the table's data. Placing consent text in a caption is technically valid HTML. The attack is entirely in the CSS: caption-side: bottom is a standard, accepted property value. No CSS injection heuristic looking for display:none, visibility:hidden, or opacity:0 will catch this attack — the consent is visible to the accessibility tree but positioned below the actionable element.

CSS caption-side attack surface in MCP consent UIs

Attack patternCSS usedEffect on user
caption-side: bottom displacementtable { caption-side: bottom }Consent in caption appears after the install button; user interacts with button before reading consent
caption-side + overflow cliptable { caption-side: bottom; overflow: hidden; max-height: 200px }Table body (install form) visible; caption clipped by overflow hidden; consent in DOM but not rendered
caption-side + negative margincaption { margin-top: -200vh } with caption-side: bottomCaption positioned far below viewport; user scrolls to install button but consent is hundreds of viewport-heights below
visibility: collapse on captioncaption { visibility: collapse }In Firefox: caption collapses to zero height; in Chrome: treated as visibility:hidden — consent occupies no space

Attack 1: caption-side: bottom — consent displacement below install form

The primary caption-side attack: MCP structures the install UI as an HTML table with the install form (name, email, install button) in the table rows and the consent disclosure in the <caption> element. By setting caption-side: bottom, the CSS moves the caption below the table body. The install button — inside <td> rows at the top — is the first element the user sees. The consent disclosure — in the caption at the bottom — appears after the user has already initiated the install:

/* Malicious CSS — SA-CSS-CAPTION-001 */
/* caption-side: bottom moves caption below the table body */
/* Consent in <caption> is displaced below the install form */
.mcp-install-table {
  caption-side: bottom;
  width: 100%;
}

/* Malicious HTML structure: */
/* <table class="mcp-install-table">                                        */
/*   <caption>                                                              */
/*     <p class="consent-disclosure">                                      */
/*       By clicking "Install MCP Server" above, you grant this server      */
/*       access to your workspace, tools, and execution environment.        */
/*     </p>                                                                */
/*   </caption>                                                            */
/*   <tbody>                                                                */
/*     <tr><td><label>Name</label><input type="text"></td></tr>        */
/*     <tr><td><label>Email</label><input type="email"></td></tr>       */
/*     <tr><td><button class="install-btn">Install MCP Server</button></td></tr> */
/*   </tbody>                                                              */
/* </table>                                                                */

/* Visual result:
   [Name input]
   [Email input]
   [Install MCP Server button]    ← user sees this first; caption is BELOW
   By clicking "Install MCP Server" above, you grant this server access...
   ← consent is here, below the install button, after the user has already
     been prompted to click. The "above" reference in the consent text is
     intentional — it makes the below-fold consent reference the button above.

/* Why this evades consent-hiding detection:
   - No display:none, visibility:hidden, or opacity:0 — consent is fully rendered
   - Consent is IN the DOM and IN the accessibility tree
   - Screen readers read the caption — but after the table body content
   - The CSS property used (caption-side) is standard and non-suspicious
   - Automated consent-visibility checks that only test for visibility:computed
     will report this as "consent visible" — it is visible, but displaced

/* Detection: check for consent text inside table captions with caption-side: bottom */
function detectCaptionSideAttack() {
  const findings = [];
  for (const table of document.querySelectorAll('table')) {
    const caption = table.querySelector('caption');
    if (!caption) continue;
    const captionText = caption.textContent || '';
    if (!/consent|disclosure|terms|privacy|by installing|by clicking/i.test(captionText)) continue;
    /* Check computed caption-side */
    const cs = getComputedStyle(table).captionSide;
    if (cs === 'bottom') {
      findings.push({ id: 'SA-CSS-CAPTION-001', severity: 'high',
        message: `Table caption contains consent text and has caption-side:bottom — consent displaced below install form rows` });
    }
    /* Check if install button or form inputs exist in the table body (above the caption) */
    const hasInstallButton = !!table.querySelector('button, input[type="submit"], input[type="button"]');
    if (hasInstallButton && cs === 'bottom') {
      findings.push({ id: 'SA-CSS-CAPTION-001', severity: 'critical',
        message: `Install button in table body + consent in caption with caption-side:bottom — user interacts with install before reading consent` });
    }
  }
  return findings;
}

Consent is technically "visible" — but displaced: SA-CSS-CAPTION-001 is specifically designed to evade visibility-based consent scanners. The consent text has computed visibility: visible and computed display: block. It is in the accessibility tree. But it appears below the install button, meaning users are prompted to act before they can read the terms. Ths attack targets workflow order, not CSS rendering — a fundamentally different attack surface than display/visibility hiding.

Attack 2: caption-side + overflow:hidden — caption clipped by table container

The overflow clipping attack combines caption-side: bottom with overflow: hidden on the table container to prevent the caption from being painted at all. The caption exists in the DOM and the accessibility tree but is not visible — the table's clipping context crops it off. Unlike display:none, this technique is less likely to trigger consent-hiding heuristics because the DOM structure and computed display values appear normal:

/* Malicious CSS — SA-CSS-CAPTION-002 */
/* caption-side:bottom positions caption below table body */
/* overflow:hidden + max-height clips the caption from being painted */
.mcp-install-wrapper {
  overflow: hidden;
  max-height: 250px;  /* enough for form rows but not the caption below */
}

.mcp-install-table {
  caption-side: bottom;
}

/* Layout calculation:
   - Table rows (form inputs + install button): ~200px total height
   - max-height on wrapper: 250px → shows all table rows
   - Caption (consent): positioned after table body (bottom) at y=200px+
   - overflow:hidden clips everything after 250px → caption at 200px+ is visible
   - But if form rows + caption > max-height, the caption may be clipped

   Actually in most browsers, table overflow behavior with captions is quirky:
   The caption may overflow the table's overflow:hidden box depending on browser.
   This attack is most reliable on Chrome, where the caption IS clipped.

/* Alternative: absolute-position container to clip caption specifically */
.mcp-install-outer {
  position: relative;
  height: 220px;        /* show only the form rows */
  overflow: hidden;
}
.mcp-install-table {
  caption-side: bottom;
  position: absolute;
  top: 0;
  /* Table caption goes below the 220px visible area → clipped */
}

/* Detection: check for consent-containing captions in overflow-clipped containers */
function detectCaptionOverflowClipAttack() {
  const findings = [];
  for (const table of document.querySelectorAll('table')) {
    const caption = table.querySelector('caption');
    if (!caption) continue;
    if (!/consent|disclosure|terms|privacy/i.test(caption.textContent || '')) continue;
    /* Walk ancestors for overflow:hidden with explicit height */
    let ancestor = table.parentElement;
    while (ancestor && ancestor !== document.body) {
      const style = getComputedStyle(ancestor);
      if (style.overflow === 'hidden' || style.overflowY === 'hidden') {
        const rect = ancestor.getBoundingClientRect();
        const captionRect = caption.getBoundingClientRect();
        if (captionRect.top >= rect.bottom || captionRect.bottom <= rect.top) {
          findings.push({ id: 'SA-CSS-CAPTION-002', severity: 'high',
            message: `Table caption with consent is clipped by overflow:hidden ancestor — caption outside visible area of ${ancestor.tagName.toLowerCase()}` });
        }
      }
      ancestor = ancestor.parentElement;
    }
  }
  return findings;
}

Attack 3: caption-side: bottom + negative margin — off-screen consent

A large negative margin-top on the <caption> element, combined with caption-side: bottom, positions the consent text far below the visible viewport. The caption appears in the DOM directly below the table but scrolling down to it requires scrolling hundreds of viewport heights — effectively unreachable for any normal user interaction. The consent remains in the DOM and passes accessibility checks, but is practically invisible:

/* Malicious CSS — SA-CSS-CAPTION-003 */
/* caption-side: bottom already displaces consent below table body */
/* Large negative margin-top on caption moves it further off-screen  */
/* Wait — with caption-side: bottom, the caption is BELOW the table  */
/* A POSITIVE margin-top on the caption would push it down further   */
.mcp-install-table {
  caption-side: bottom;
}

.mcp-install-table caption {
  margin-top: 200vh;   /* 200 viewport heights below the table body */
  /* User would need to scroll 2 screen lengths just to reach the consent */
}

/* Alternatively, without caption-side: bottom, if caption is at top: */
.mcp-install-table caption {
  margin-top: -200vh;  /* Negative margin pushes caption above and off-screen */
  /* With caption-side: top (default), caption is above table; negative margin hides it */
}

/* Why margin-based hiding evades detection:
   - computed display: table-caption (normal for caption elements)
   - computed visibility: visible
   - Element IS in the document — it's just far outside the viewport
   - getBoundingClientRect() returns coordinates outside the viewport
   - But static innerHTML scans and accessibility tree checks report it as "present"

/* Combined approach: negative margin + clip */
.mcp-install-table {
  caption-side: top; /* caption above, but... */
}
.mcp-install-table caption {
  margin-top: -9999px; /* push far above viewport */
  position: relative;
}

/* Detection: check caption position relative to viewport */
function detectCaptionMarginOffscreenAttack() {
  const findings = [];
  for (const table of document.querySelectorAll('table')) {
    const caption = table.querySelector('caption');
    if (!caption) continue;
    if (!/consent|disclosure|terms|privacy/i.test(caption.textContent || '')) continue;
    const rect = caption.getBoundingClientRect();
    const vh = window.innerHeight;
    /* Caption is more than 2 viewport heights away from the viewport */
    if (rect.top > 2 * vh || rect.bottom < -2 * vh) {
      const style = getComputedStyle(caption);
      findings.push({ id: 'SA-CSS-CAPTION-003', severity: 'high',
        message: `Consent caption is positioned ${Math.round(rect.top / vh)} viewport heights from visible area — margin-based off-screen attack. margin: ${style.marginTop}` });
    }
  }
  return findings;
}

Attack 4: visibility:collapse on caption — browser-dependent collapse

CSS visibility: collapse has different behavior on different element types and browsers. For table elements like rows (tr), it collapses the element to zero height while keeping it in the DOM — similar to removing the row from layout without removing the content from the accessibility tree. For <caption> elements, browser behavior varies: Firefox collapses the caption to zero height; Chrome/Safari treat it as visibility: hidden (occupies space but not painted). MCP uses this inconsistency to create consent hiding that manifests differently in different auditing environments:

/* Malicious CSS — SA-CSS-CAPTION-004 */
/* visibility:collapse on caption: */
/* Firefox: collapses to zero height — consent removed from layout flow */
/* Chrome/Safari: treated as visibility:hidden — consent is invisible but occupies space */
.mcp-install-table caption {
  visibility: collapse;
}

/* Browser behavior comparison: */
/* Firefox      → caption height = 0; space is reclaimed; consent cannot be scrolled to */
/* Chrome 119+  → caption painted as visibility:hidden; space occupied; consent invisible */
/* Safari 17+   → same as Chrome */

/* Why this creates a detection challenge:
   - In Firefox: caption height = 0, but getComputedStyle().visibility = 'collapse'
   - In Chrome: caption is invisible (visibility:hidden semantics) but not display:none
   - An auditor checking computed visibility finds "collapse" — not "hidden" or "none"
   - Most consent-visibility checks test for display:none, visibility:hidden, opacity:0
   - "visibility:collapse" on a non-table-row element is rarely checked
   - The W3C spec says visibility:collapse on non-table-row elements should be treated
     as visibility:hidden, but Firefox historically collapses captions

/* Compound attack: table row collapse + caption collapse */
.mcp-install-table caption {
  visibility: collapse;
}
/* This hides consent in both Firefox (collapse = 0 height) and Chrome (collapse = hidden) */
/* while passing a naive display:none/visibility:hidden check */

/* Detection: check for visibility:collapse on table captions containing consent */
function detectCaptionVisibilityCollapseAttack() {
  const findings = [];
  for (const table of document.querySelectorAll('table')) {
    const caption = table.querySelector('caption');
    if (!caption) continue;
    if (!/consent|disclosure|terms|privacy/i.test(caption.textContent || '')) continue;
    const style = getComputedStyle(caption);
    if (style.visibility === 'collapse' || style.visibility === 'hidden') {
      findings.push({ id: 'SA-CSS-CAPTION-004', severity: 'critical',
        message: `Table caption with consent text has visibility:${style.visibility} — browser-dependent collapse hides consent. Firefox: 0 height; Chrome/Safari: invisible.` });
    }
    /* Also check for explicit visibility:collapse in stylesheets */
    for (const sheet of document.styleSheets) {
      try {
        for (const rule of sheet.cssRules) {
          if (/caption/.test(rule.selectorText || '') &&
              /visibility\s*:\s*collapse/.test(rule.cssText)) {
            if (/consent|disclosure|terms/i.test(caption.textContent || '')) {
              findings.push({ id: 'SA-CSS-CAPTION-004', severity: 'high',
                message: `CSS rule "${rule.selectorText}" applies visibility:collapse to caption with consent text` });
            }
          }
        }
      } catch (_) {}
    }
  }
  return findings;
}

Table captions in consent UI — a low-suspicion structure: HTML <caption> is a standard table element designed for accessibility. Developers rarely question its use. A code reviewer seeing consent text inside a <caption> may interpret it as a descriptive table label rather than an attack setup. The caption-side property similarly looks like a layout choice, not a hiding technique. SkillAudit's static analysis specifically checks for consent text inside table captions combined with CSS that affects caption position or visibility.

SkillAudit findings for CSS caption-side consent attacks

HighSA-CSS-CAPTION-001 — table { caption-side: bottom } with consent in <caption> and install form in table body. Consent is technically visible (rendered, in DOM) but appears after the install button. Standard consent-visibility checks that test computed visibility will pass. The attack exploits presentation order, not CSS rendering.
HighSA-CSS-CAPTION-002 — caption-side: bottom combined with overflow: hidden on a fixed-height ancestor. The caption (with consent) is positioned below the table body and then clipped by the overflow constraint. Consent is in the DOM but not painted in Chrome.
HighSA-CSS-CAPTION-003 — caption-side: bottom plus caption { margin-top: 200vh }. Large positive margin on a bottom-positioned caption places consent hundreds of viewport heights below the visible area. Consent is in the DOM, passes accessibility checks, but is unreachable in practice. Requires getBoundingClientRect() comparison against viewport for detection.
CriticalSA-CSS-CAPTION-004 — caption { visibility: collapse }. Browser-dependent behavior: Firefox collapses caption to zero height; Chrome/Safari treat as visibility: hidden. In both cases consent is invisible to the user but present in the DOM. Passes checks for display:none and visibility:hidden since the computed value is "collapse", not "hidden".

Related MCP consent attack research

Audit your MCP server for caption-side consent displacement attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-CAPTION findings.