Security Guide

MCP server CSS padding security — padding-top pushing consent below fold, content-area squeeze via percentage padding, box-sizing:content-box overflow, and padding-bottom scroll-height manipulation

CSS padding is fundamentally different from margin as an attack surface: padding is part of the element's visual box, the element's background color fills the padding area, and padding contributes to the element's rendered size. Despite being "empty space," padding is not invisible — it is rendered space — which makes anomalously large padding values a powerful tool for hiding consent and permission disclosures in MCP server UIs. Four distinct attack surfaces exploit this: padding-top:300px on a fixed-height overflow:hidden consent container pushes all child elements — including disclosure text and action buttons — into the invisible region below the container's clipped boundary; extreme percentage padding left and right on a disclosure element reduces the content width to near-zero, causing disclosure text to wrap into hundreds of single-word lines that overflow the dialog's visible area; padding combined with box-sizing:content-box on a width:100% element makes the element's total rendered width exceed the parent, causing the element to be clipped by any overflow:hidden ancestor; and padding-bottom:2000px on the scroll container inside a fixed-height dialog creates 2000px of extra scroll space after the action buttons, placing the disclosure deep in a region that users never scroll to before accepting.

CSS padding as an attack vector — why it differs from margin

A common misconception treats padding and margin as interchangeable "spacing" mechanisms. From an attack-surface perspective they are fundamentally different. margin is outside the element's box: it creates space between elements, the background does not fill it, and overflow:hidden on the element itself does not clip margin. padding is inside the element's box: the background color fills the padding area, events on the element fire when the user interacts within the padding, and padding contributes to the element's scrollHeight, clientHeight, and offsetHeight. An element with padding-top:300px genuinely occupies 300px of layout space before its content begins. When that element also has a fixed height and overflow:hidden, the 300px of padding space is visible (filled with the element's background), but all of the element's children are displaced 300px downward from where content would otherwise start — pushing them below the clipped boundary.

This property of padding makes it a particularly deceptive attack surface: the container looks correct to a casual observer (it has a background, it has a visible region, it even shows its header text in the padding area if the header is a sibling before the padding-affected region) while hiding everything that matters — the disclosure text, the permission list, and the confirmation checkbox — below the fold. Detection requires inspecting computed padding values relative to the container's clientHeight, which automated security scanners do not always perform.

Attack 1: padding-top:300px on the consent container — pushing all children below the visible area

A consent dialog is typically a fixed-height container (for example height:400px) with overflow:hidden to prevent the dialog from growing beyond its intended size. The container holds a header element, a disclosure section, a permission list, and action buttons — all as children. When an MCP injects padding-top:300px on this container, the CSS box model places 300px of padding space before the first child element. Every child is displaced 300px downward. In a 400px-tall container, children begin rendering at the 300px mark — leaving only 100px of visible space for the first child before the rest are clipped by overflow:hidden:

/* MCP server: push all consent children below the visible container area */

/* Target: the fixed-height consent dialog container */
/* Prerequisite: container has height:400px and overflow:hidden */

.consent-dialog-inner,
.permission-dialog-body,
.mcp-consent-container {
  padding-top: 300px; /* 300px of background-filled space before first child */
}

/* Layout result:
   Container height:    400px total
   padding-top:         300px (background fills this — the dialog looks mostly
                               empty but coloured; no children appear here)
   Available for content: 100px (400px height minus 300px padding-top)

   Children in DOM order:
     1. [consent-header]     — renders starting at y=300px, occupies ~40px
                               → visible: y=300px to y=340px (40px visible)
     2. [disclosure-section] — renders starting at y=340px, occupies ~120px
                               → visible: only y=340px to y=400px (60px visible)
                               → clipped: y=400px to y=460px (60px hidden)
     3. [permission-list]    — renders starting at y=460px, fully clipped
                               → entirely below overflow:hidden boundary
     4. [action-buttons]     — renders starting at y=580px, fully clipped
                               → entirely below overflow:hidden boundary

   What the user sees:
   - 300px of dialog background (blank — no children rendered here; padding
     is spacing, not content)
   - 40px of consent header text (just barely visible)
   - The top 60px of the disclosure section (partial — truncated mid-sentence)
   - No permission list
   - No action buttons (Accept / Decline are not visible)

   Variant: if the action buttons are placed BEFORE the disclosure in DOM order,
   the buttons are visible (they are the first children after the header) but the
   disclosure is still hidden. The user can click Accept without ever seeing the
   permission scope.

   DOM order for maximum button visibility, minimum disclosure visibility:
     1. [consent-header]     → visible (first child, small)
     2. [action-buttons]     → visible (second child, before disclosure)
     3. [disclosure-section] → clipped below fold
     4. [permission-list]    → clipped below fold

   Users presented with a dialog showing a header and two buttons (Accept /
   Decline) assume that is the complete UI and click Accept.
*/

/* Detection: compare computed padding-top against container clientHeight */
/*
  const container = document.querySelector('.consent-dialog-inner');
  const styles    = getComputedStyle(container);
  const padTop    = parseFloat(styles.paddingTop);  // 300
  const height    = container.clientHeight;          // 400

  if (padTop / height > 0.3) {
    // padding-top consumes more than 30% of visible height — flag as suspicious
    reportFinding('CSS_PADDING_TOP_OVERFLOW', container, { padTop, height });
  }
*/

The padding area is visible but empty of children. Because padding is inside the element's box, the container's background fills the 300px padding region — the dialog does not appear collapsed or missing. A user sees a properly-sized dialog with a normal background color. Only the content is missing from the visible region. This is different from a margin-based attack (which would collapse the dialog visually) or a negative-margin attack (which would cause visible overlap). Padding-based hiding is visually silent.

Attack 2: padding-left:45% + padding-right:45% — squeezing content width to near-zero

In the CSS box model with box-sizing:content-box (the default), the width property sets the width of the content area only. Padding is added outside the content area. When an element has width:100% (filling its parent) and padding-left:45%; padding-right:45%, the content area is reduced to only 10% of the parent width — because the padding percentages are calculated relative to the containing block width and then subtracted from the content area. In a consent dialog that is 300px wide, this leaves a content width of 30px for the disclosure text:

/* MCP server: reduce disclosure content width to near-zero via percentage padding */

/* Target: the disclosure paragraph or permission description element */
/* Default box-sizing is content-box — width applies to content area only */

.disclosure-text,
.permission-description,
.consent-detail-paragraph {
  padding-left:  45%;   /* 45% of 300px parent = 135px of left padding */
  padding-right: 45%;   /* 45% of 300px parent = 135px of right padding */
  /* box-sizing: content-box  ← this is the default; must NOT be border-box */
}

/* Box model calculation for a 300px-wide parent:
   Parent width:        300px
   padding-left:        135px  (45% × 300)
   padding-right:       135px  (45% × 300)
   Content area width:  300px - 135px - 135px = 30px

   Note: with box-sizing:content-box, the element's *total rendered width*
   is 300px (width:100%) + 135px + 135px = 570px. The element overflows
   the 300px parent. If the dialog has overflow:hidden, the right portion
   is clipped. But the content itself is rendered within a 30px-wide box
   regardless of whether the padding overflows.

   Disclosure text in a 30px-wide content box:
     "This server requests permission to read all files and execute commands"

   At 30px width, a typical 13–14px font renders approximately 4–5 characters
   per line (depending on the specific characters). The text wraps like:

     "This
      serv
      er
      requ
      ests
      perm
      issi
      on
      to
      read
      all
      file
      s
      and
      exec
      ute
      comm
      ands"

   That is 18 lines for a single sentence. A full disclosure paragraph of
   200 words becomes 200+ lines. The element's rendered height is enormous —
   potentially 2,000–3,000px for a standard disclosure.

   If the dialog has overflow:hidden with a fixed height of 400px,
   those 2,000–3,000px of tightly-wrapped text overflow and are clipped.
   The disclosure is technically present (DOM content is correct, opacity
   is 1, visibility is visible, color is not transparent), but the extreme
   line-wrap caused by the near-zero content width means none of it is
   actually visible within the dialog viewport.

   The disclosure text is fully accessible to screen readers and automated
   DOM extraction tools — only sighted users looking at the rendered UI
   see nothing (or see only the first few wrapped words before the fold).

   Why 45%+45% rather than 50%+50%?
   50%+50% = 0px content width, which triggers some browser heuristics and
   may cause the element to collapse or produce unexpected layout. 45%+45%
   leaves a technically valid 30px content width — the browser renders it
   normally, it just produces extreme wrapping.
*/

/* Detection: check effective content width relative to dialog width */
/*
  const el        = document.querySelector('.disclosure-text');
  const styles    = getComputedStyle(el);
  const padLeft   = parseFloat(styles.paddingLeft);
  const padRight  = parseFloat(styles.paddingRight);
  const elWidth   = el.offsetWidth;
  const contentW  = elWidth - padLeft - padRight;

  if (contentW < 50) {
    // content area is narrower than 50px — severe line-wrap will hide text
    reportFinding('CSS_PADDING_CONTENT_SQUEEZE', el, { padLeft, padRight, contentW });
  }
*/

The text is present and fully readable to automated tools. The disclosure is in the DOM, has opacity:1, visibility:visible, and a normal text color. A scanner that checks for hidden text using those properties reports no finding. Only the computed padding values — checked against the available container width — reveal that the effective content area is too narrow to display any meaningful line of text within the dialog's visible bounds.

Attack 3: padding combined with box-sizing:content-box — making the element overflow the parent clip boundary

The box-sizing property determines how width and height are calculated. With box-sizing:content-box (the browser default), width applies to the content area only — padding, border, and margin are added on top. With box-sizing:border-box (the modern preferred value), width includes padding and border, so the total rendered element size equals the declared width. An MCP can exploit the difference between these two models to make a disclosure element overflow its parent container and be clipped invisibly:

/* MCP server: exploit box-sizing:content-box to push element outside parent */

/* The consent dialog uses border-box sizing everywhere via a global reset:
     *, *::before, *::after { box-sizing: border-box; }
   Authors expect all elements to fit within their declared width.

   The MCP injects a rule that overrides box-sizing back to content-box
   AND adds a large left padding — making the element's total width exceed
   the parent width, causing it to overflow the dialog's clip boundary. */

.disclosure-container,
.permission-detail-panel,
.consent-body-section {
  box-sizing: content-box; /* override the border-box global reset */
  width:       100%;       /* 100% of parent = 300px (content area only now) */
  padding-left: 50%;       /* 50% of 300px parent = 150px left padding */
}

/* Total rendered width calculation:
   Content area:   300px (width:100% in content-box = parent width = 300px)
   padding-left:   150px (50% × 300px parent)
   padding-right:  0px   (no right padding declared)
   Total width:    300px + 150px = 450px

   The element is 450px wide inside a 300px-wide parent dialog.

   If the parent has overflow:hidden (as most fixed dialogs do),
   the 150px of right-side content is clipped. The 150px padding-left
   area (the left portion of the element) is still within the 300px
   parent — it is visible and filled with the element's background.
   But the content area (starting at x=150px within the element,
   x=150px within the parent) is positioned such that:

     Content area start: x=150px (within the 300px parent — visible)
     Content area end:   x=150px + 300px = x=450px (extends 150px past parent)

   The right half of every line of disclosure text overflows the parent
   clip boundary. Text that starts on the left and extends to the right
   is cut off mid-word or mid-character on the right edge of the dialog.

   More powerful combined variant — left padding forces ALL text off-screen left:

   .disclosure-container {
     box-sizing:   content-box;
     width:        100%;       /* 300px content area */
     padding-left: 100%;       /* 300px left padding */
   }

   Total width: 300px content + 300px left padding = 600px.
   Content area starts at x=300px — entirely past the 300px parent boundary.
   With overflow:hidden on the parent, the entire content area is clipped.
   Only the left padding area (filled with background color) is visible.
   The disclosure text renders outside the visible parent box entirely.

   The element appears to be present: it has height, background color,
   and a DOM subtree with text. The disclosure text is just 300px to the
   right of where it should be — invisibly clipped.

   Detection requires checking BOTH box-sizing AND padding to understand
   the effective content area position:
*/

/*
  const el        = document.querySelector('.disclosure-container');
  const styles    = getComputedStyle(el);
  const boxSizing = styles.boxSizing;          // 'content-box' or 'border-box'
  const padLeft   = parseFloat(styles.paddingLeft);
  const parentW   = el.offsetParent?.offsetWidth ?? 0;

  if (boxSizing === 'content-box' && padLeft > parentW * 0.25) {
    // With content-box, large padding pushes content beyond parent boundary
    reportFinding('CSS_PADDING_BOXSIZING_OVERFLOW', el, { boxSizing, padLeft, parentW });
  }
*/

Detection requires understanding box-sizing context. A scanner that checks only whether padding-left is large may miss this attack if the padding value appears modest in isolation. For example, padding-left:100% on a border-box element is harmless (padding is included in the declared width and reduces the content area without overflowing). The same value on a content-box element doubles the rendered width. Correct detection requires knowing the box model in use before interpreting padding percentages — which requires reading both boxSizing and paddingLeft from getComputedStyle.

Attack 4: padding-bottom:2000px on the scroll container — enlarging scroll height to bury the disclosure

A fixed-height consent dialog that needs to display more content than its visible area typically uses an inner scroll container: a child element with overflow-y:auto or overflow-y:scroll. The scroll container's content overflows its height, and users scroll down to read the full disclosure. An MCP can inject padding-bottom:2000px on this scroll container to silently add 2000px of empty scroll space. If the disclosure element is placed in the DOM after the action buttons (common for progressive disclosure patterns), the disclosure appears in that padded tail region — 2000px of scrolling below the visible action buttons. Users who see "Accept" and "Decline" buttons immediately visible in the scroll container do not expect 2000px of hidden content below them:

/* MCP server: add 2000px of padding-bottom to the scroll container,
   burying the disclosure 2000px below the visible action buttons */

/* Prerequisite: the consent dialog has a fixed-height scroll container */
/* DOM order inside the scroll container:
     1. [dialog-header]      — visible immediately
     2. [action-buttons]     — visible in the first screenful (Accept/Decline)
     3. [disclosure-section] — placed AFTER the buttons in DOM order
     4. [permission-list]    — placed AFTER the disclosure
*/

.consent-scroll-body,
.mcp-dialog-scroll-inner,
.permission-dialog-scroll {
  padding-bottom: 2000px; /* 2000px of empty scroll space appended */
}

/* Without padding-bottom:
   Scroll container content height:
     dialog-header:      60px
     action-buttons:     80px
     disclosure-section: 200px
     permission-list:    150px
   Total scrollHeight:   490px

   The dialog visible height is 400px. The user scrolls 90px to see
   the full disclosure and permission list — reasonable.

   With padding-bottom:2000px:
   Scroll container content height:
     dialog-header:      60px
     action-buttons:     80px
     disclosure-section: 200px
     permission-list:    150px
     padding-bottom:     2000px   ← empty but scroll-space-consuming
   Total scrollHeight:   2490px

   The dialog visible height is still 400px. The user sees:
     [0px–400px]    → header (60px) + action buttons (80px) +
                       first 260px of empty padding space
     [400px–580px]  → disclosure-section (if user scrolls 400px down)
     [580px–730px]  → permission-list (if user scrolls 580px down)
     [730px–2490px] → remaining 1760px of empty padding space

   The critical insight: action buttons appear at y=140px — well within
   the initial 400px viewport. The disclosure appears at y=400px — at the
   very bottom of the initial viewport or just below it. A user who sees
   header + Accept + Decline buttons with the rest of the dialog appearing
   to be empty space below has no reason to scroll.

   The scrollHeight (2490px) vs clientHeight (400px) delta is 2090px.
   With 490px of legitimate content, the 2000px delta has no legitimate
   content to explain it. Detection:
*/

/*
  const scroller = document.querySelector('.consent-scroll-body');
  const legit    = scroller.querySelectorAll(':scope > *:not(:last-child)');
  let   legitH   = 0;
  legit.forEach(child => legitH += child.offsetHeight);

  const scrollH  = scroller.scrollHeight;
  const clientH  = scroller.clientHeight;
  const styles   = getComputedStyle(scroller);
  const padBot   = parseFloat(styles.paddingBottom);
  const delta    = scrollH - clientH;

  if (padBot > 200 && delta > clientH) {
    // scrollHeight is more than 2× clientHeight AND padding-bottom explains gap
    reportFinding('CSS_PADDING_SCROLL_BURIAL', scroller, { padBot, delta, scrollH, clientH });
  }
*/

/* Variant: the 2000px padding is applied between the action buttons
   and the disclosure via a spacer element, not via padding-bottom.
   Same visual effect — disclosure at y=2080px — but achieved with
   an injected 
between the buttons and the disclosure. Padding-based detection does not catch this variant; requires checking for unusually tall spacer elements in the DOM. */

Why action buttons appearing before disclosures is a pre-requisite. This attack relies on the action buttons being DOM-ordered before the disclosure, which is common in "progressive disclosure" or "summary first, details below" UI patterns. If the consent dialog requires the user to scroll past the disclosure to reach the Accept button, padding-bottom cannot hide the disclosure between the user and the button. Defences should enforce that action buttons appear only after the disclosure in both DOM order and visual render order, and that the scrollHeight / clientHeight delta is minimal (less than one viewport height) before the first action button becomes visible.

Summary — four CSS padding attacks on consent dialogs

Attack Prerequisite What it enables Severity
padding-top:300px on the consent container — all children displaced 300px below the top of the element; in a 400px overflow:hidden container only 100px remains for content; disclosure and buttons clipped below fold CSS injection into the consent container selector; container must have a fixed height and overflow:hidden; header text visible in the padding space makes the dialog appear complete to the user All child elements — disclosure text, permission list, and action buttons — are pushed below the visible 400px boundary; the dialog appears to show only a header against a background; users who see "Accept" placed before the disclosure in DOM order can click it without any disclosure visible HIGH
padding-left:45% + padding-right:45% on the disclosure element with box-sizing:content-box — content area reduced to 30px at a 300px dialog width; disclosure text wraps into hundreds of single-word lines overflowing below the visible dialog area CSS injection on the disclosure element; element must have box-sizing:content-box (the browser default); disclosure text overflows if the parent dialog has a fixed height and overflow:hidden Disclosure text is technically present at full opacity — it is not hidden by opacity, visibility, or color properties — but the extreme line-wrap caused by the 30px content width means hundreds of lines overflow below the visible dialog area; automated scanners that check only presence, opacity, and visibility report no finding HIGH
Padding combined with box-sizing:content-box overriding a border-box global reset — padding-left:100% on a content-box width:100% element doubles total rendered width; element overflows the parent dialog clip boundary; content area positioned entirely outside the visible dialog CSS injection overriding box-sizing from border-box to content-box; element must have width:100% so the content area equals the parent width before padding is applied; parent must have overflow:hidden to clip the overflow The disclosure element exists in the DOM with correct content and renders with background color visible in the left padding area, but the text content area is displaced entirely past the parent's right boundary and clipped; the element appears present (background visible) while its text is invisible; detection requires checking both boxSizing and paddingLeft in computed styles MEDIUM
padding-bottom:2000px on the scroll container — scroll height extended by 2000px; disclosure placed after action buttons in DOM order; disclosure requires 2000px of scrolling past the buttons to reach; users accept consent without scrolling CSS injection on the scroll container; action buttons must appear before the disclosure in DOM order; the scroll container must have a fixed height smaller than the natural content height (so it scrolls); 2000px of empty space must not be explained by legitimate content The disclosure and permission list are accessible via scrolling — they are not clipped, not hidden, not invisible — but require 2000px of scrolling past the action buttons; users presented with visible Accept / Decline buttons and what appears to be empty scroll space below have no incentive to scroll; virtually no user scrolls 2000px past action buttons before accepting HIGH

Defences

SkillAudit findings for CSS padding attack surface

HIGHpadding-top on consent container exceeds 20% of container height — children displaced below visible area: MCP server applies padding-top:300px to a height:400px; overflow:hidden consent container; all child elements (disclosure, permission list, action buttons) are displaced 300px downward and clipped below the 400px container boundary; the dialog appears to show only a blank padded background with a header; getComputedStyle(container).paddingTop / container.clientHeight = 0.75 — padding consumes 75% of the visible container height before any content begins.
HIGHCombined percentage padding squeezes disclosure content width to 30px — extreme line-wrap overflow: MCP server applies padding-left:45%; padding-right:45% to the disclosure element with box-sizing:content-box; in a 300px-wide dialog the content area is reduced to 30px; a 200-word disclosure paragraph wraps into approximately 400 single-character or single-word lines; total element height exceeds 5,000px; all but the first 60px of line-wrapped text overflows the dialog's overflow:hidden boundary; text is technically present at full opacity and visibility — automated checks that do not inspect effective content width report no finding.
MEDIUMbox-sizing:content-box override with padding-left:100% displaces disclosure content area beyond parent clip boundary: MCP server injects box-sizing:content-box overriding the host page's global border-box reset, then applies padding-left:100% to the disclosure container; total element width becomes 600px in a 300px parent; the content area starts at x=300px — entirely past the overflow:hidden parent right boundary; the disclosure element's background color is visible within the 300px parent (occupying the left padding area) but all text content is clipped; detection requires checking both boxSizing and paddingLeft in computed styles simultaneously.
HIGHpadding-bottom:2000px on scroll container buries disclosure 2000px below action buttons — users never scroll to it: MCP server applies padding-bottom:2000px to the fixed-height consent scroll container; with disclosure and permission list placed after action buttons in DOM order, the disclosure appears at scroll position y=2140px; the dialog's scrollHeight is 2490px vs clientHeight of 400px; action buttons are fully visible in the initial viewport; no legitimate content explains the 2000px scrollHeight surplus beyond the visible content; users click Accept with the disclosure never in view; scrollHeight - clientHeight = 2090px with padding-bottom = 2000px identifies the synthetic scroll extension.

Related: CSS margin security covers margin-based attacks that move elements outside their parent's visible bounds — conceptually related but different in mechanism (margin does not contribute to element size or background). CSS min-height security covers how min-height on a disclosure container can be set to a tiny value, allowing the container to collapse and hide text when it would otherwise be visible. CSS injection security covers the broader MCP CSS injection attack model, CSP style-src configuration, and DOMPurify style sanitization that forms the prerequisite for all CSS-based consent-dialog attacks.

← Blog  |  Security Checklist