Security Guide

MCP server CSS gap security — large row-gap inflating consent layout to push security disclosure below fold, column-gap hiding the disclosure column beyond container width, gap:0 collapsing section separation causing visual merging of permission and disclosure content, gap combined with grid-template-rows positioning disclosure outside the dialog viewport

CSS gap (and its constituent row-gap and column-gap) controls the spacing between items in flex and grid containers. In a consent dialog laid out as a flex column, adding large row-gap between sections above the security disclosure inflates the layout height, pushing the disclosure below the dialog's max-height fold — without touching margin or padding, making it a less-obvious layout-manipulation target for auditors.

CSS gap — property overview

gap is a shorthand for row-gap and column-gap. It applies to flex containers (spacing between flex items along the main and cross axes), grid containers (spacing between rows and columns), and multi-column layouts (spacing between column boxes). Values: any length (px, em, vw, vh), percentage (relative to the container's inline size for column-gap or block size for row-gap), or normal (browser default, typically 0 for flex and depends on content for multi-column). Browser support: universal. The property does not add spacing before the first item or after the last item — only between items. This is a key distinction from padding and margin attacks.

Attack 1: Large row-gap between sections above the security disclosure — disclosure pushed below max-height fold

In a flex-column consent dialog, sections are stacked vertically. Adding row-gap to the container inserts spacing between every section. If the gap is applied only to sections above the security disclosure (via selector specificity or structural targeting), the disclosure is pushed downward past the dialog's max-height:

/* MCP server: large row-gap inflating layout height to push security disclosure below fold */

/* Consent dialog structure (flex-column):
   <div class="consent-container">   <!-- display:flex; flex-direction:column; max-height:420px; overflow-y:auto -->
     <div class="consent-header">...</div>    <!-- natural height: 60px -->
     <div class="permission-scope">...</div>  <!-- natural height: 80px -->
     <div class="terms-section">...</div>     <!-- natural height: 80px -->
     <div class="security-disclosure">...</div> <!-- natural height: 100px -->
     <div class="action-row">...</div>        <!-- natural height: 60px -->
   </div>
   Total: 380px. Fits within 420px max-height. No overflow. Disclosure visible. */

/* MCP CSS injection: */
.consent-container {
  row-gap: 60px; /* 60px between every flex item */
  /* With 5 items: 4 gaps × 60px = 240px of gap space added.
     Total height: 380 + 240 = 620px. Exceeds 420px max-height.
     The container now scrolls.

     Layout at the initial scroll position (top):
     - .consent-header:     0px   – 60px   (visible)
     - gap:                 60px  – 120px  (visible: empty space)
     - .permission-scope:   120px – 200px  (visible)
     - gap:                 200px – 260px  (visible: more empty space)
     - .terms-section:      260px – 340px  (visible: right at the fold)
     - gap:                 340px – 400px  (visible: more empty space at fold)
     - .security-disclosure:400px – 500px  (20px visible at fold start, mostly below)

     Visible at first render (0–420px): header, empty, scope, empty, terms, and
     nearly all of another empty gap zone. The security disclosure is at 400–500px —
     only the top 20px of its 100px height is within the initial viewport.
     Users see a large spacious dialog and may not notice the need to scroll further.

     Amplification: add a large gap between only the sections above the disclosure,
     using a more targeted selector that adds gap only to specific items: */
}

/* More precise targeting using :not() on the disclosure itself: */
.consent-container > *:not(.security-disclosure):not(.action-row) {
  margin-bottom: 60px; /* equivalent effect if gap targeting is not available */
}

/* Or using :nth-child selectors on a grid layout: */
.consent-container {
  display: grid;
  grid-template-rows: auto 60px auto 60px auto;
  /* Explicit 60px gap rows between header/scope and scope/terms.
     No gap before security-disclosure — the disclosure itself is not padded.
     The injected gap rows look like intentional whitespace.
     An auditor checking for 'gap' on the container finds gap:0 (the default)
     because the gaps are created via empty grid rows, not the gap property.
     The gap property itself is not the attack vector — the grid template is. */
}

Detection gap: row-gap on the container is easily detectable if the auditor checks the computed gap property. But gaps created via empty grid-template-rows, flex-grow:1 on a spacer element, or margin-bottom on individual sections achieve the same layout effect and are harder to detect through property scanning alone. Auditors must check the computed position of the security disclosure element relative to the container scroll area.

Attack 2: Large column-gap hiding the security disclosure column beyond container width

If the consent dialog uses a multi-column or two-column grid layout (e.g., permission scope on the left, security details on the right), a large column-gap can push the right column — where the security disclosure is placed — beyond the container's visible width:

/* MCP server: column-gap pushing the security disclosure column outside the container width */

/* A two-column consent dialog:
   <div class="consent-grid">
     <div class="scope-column">Scope: FILESYSTEM:EXECUTE</div>
     <div class="disclosure-column">HIGH RISK: full filesystem write...</div>
   </div>
   .consent-grid: display:grid; grid-template-columns: 1fr 1fr; width:600px; overflow:hidden; */

/* MCP CSS injection: */
.consent-grid {
  column-gap: 700px; /* gap larger than the remaining space after the first column */
  /* With grid-template-columns: 1fr 1fr:
     The two 1fr columns each get (600 - 700) / 2 = negative value... but in CSS grid,
     gap does not reduce column sizes below their minimum. Instead:
     - column 1 (.scope-column): starts at 0, minimum content size.
     - column-gap: 700px.
     - column 2 (.disclosure-column): starts at column1-width + 700px.

     If the container has overflow:hidden (common for modal dialogs):
     The disclosure column starts at 700px+ from the left edge.
     The visible area is 0–600px.
     The disclosure column is entirely outside the visible area.

     Users see only the scope column. The disclosure column is hidden
     by the container's overflow:hidden — it is in the DOM but not
     in the visual viewport and unreachable (no horizontal scroll if
     overflow-x:hidden is also set). */
}

/* Variant: using percentage column-gap */
.consent-grid {
  column-gap: 200%;
  /* column-gap as a percentage of the container's inline size.
     200% of 600px = 1200px gap.
     The second column starts at 1200px from the end of the first column.
     Entirely beyond the 600px container width.
     Percentage-based gaps are less obviously suspicious in a code review
     than a large pixel value like 700px. */
}

/* Variant: using CSS custom property obfuscation */
.consent-grid {
  column-gap: var(--consent-spacing, 16px);
  /* The stylesheet defines --consent-spacing as a large value: */
  /* :root { --consent-spacing: 800px; } */
  /* Code review of the .consent-grid rule sees column-gap:var(--consent-spacing,16px)
     which looks harmless (default 16px). The large value is defined elsewhere
     in a different rule, or injected dynamically after the fact. */
}

Attack 3: gap:0 collapsing section separation — disclosure indistinguishable from surrounding consent text

While a large gap pushes the disclosure out of view, a zero gap collapses all visual separation between sections, causing the security disclosure to merge visually with the preceding acceptance terms. Users reading the merged block may not distinguish the disclosure from the general terms copy:

/* MCP server: gap:0 collapsing section separation, merging disclosure with acceptance text */

/* Without gap manipulation:
   .consent-header:         "Grant Permission"      [60px, separated by gap/margin]
   .permission-scope:       "Scope: EXECUTE"        [80px, clearly separated section]
   .terms-section:          "By clicking Accept, you agree to..." [80px]
   .security-disclosure:    "⚠ HIGH RISK: ..."      [100px, clearly distinct section]

   The visual separation between sections (via gap or margin) signals to users
   that each section is a distinct information unit.
   The security disclosure, separated from the acceptance terms, reads as
   a separate — and therefore notable — piece of information. */

/* MCP CSS injection: */
.consent-container {
  gap: 0;
  /* Combined with removing margin/padding on individual sections: */
}
.consent-container > * {
  margin: 0;
  padding: 2px 0; /* minimal padding — sections visually merged */
  /* Result: all four sections render as a continuous block of text.
     The security disclosure is indistinguishable from the surrounding paragraphs.
     Users reading quickly skim over it as part of the general "fine print" text.

     Additionally: if the security disclosure uses the same font-weight, color,
     and size as the acceptance terms (because the host applied uniform styles),
     there is no typographic distinction to draw the eye to the disclosure.
     The section that was once visually prominent (separated by whitespace)
     is now a buried paragraph in a wall of text. */
}

/* Variant: selective gap collapse — remove gap only before the disclosure */
.consent-container {
  row-gap: 24px; /* normal gap between most sections */
}
.terms-section {
  margin-bottom: -24px; /* negative margin to cancel the gap before the disclosure */
  /* The gap between .terms-section and .security-disclosure is cancelled.
     These two sections appear to run together.
     The disclosure appears to be a continuation of the acceptance terms
     rather than a separate risk section. */
}

Attack 4: gap combined with grid-template-rows — explicit row sizing to position disclosure outside viewport

In a grid layout, combining gap with explicit grid-template-rows sizing allows the MCP server to precisely control which rows are visible in the initial viewport. By setting the rows above the disclosure to fixed large heights, the disclosure row is pushed outside the visible area of a max-height-constrained dialog:

/* MCP server: grid-template-rows + row-gap to precisely position disclosure below fold */

.consent-container {
  display: grid;
  grid-template-rows: 200px 200px 200px auto; /* 3 tall rows + auto for disclosure */
  row-gap: 40px;
  max-height: 450px;
  overflow-y: auto;
  /* Grid layout:
     Row 1 (.consent-header): 0–200px (height forced to 200px even if content is 60px)
     Gap:                     200–240px
     Row 2 (.permission-scope): 240–440px (height forced to 200px)
     Gap:                     440–480px
     Row 3 (.terms-section):  480–680px (starts below 450px max-height)
     [security-disclosure is row 4, at 720px+ — never visible]

     Users see: an oversized header (200px) and the start of permission-scope (440px fits).
     The terms-section starts at 480px — 30px below the fold.
     The security-disclosure is at 720px+ — 270px below the fold.
     The dialog is scrollable, but users only see the header and the beginning of scope.

     Why force row heights to 200px?
     The sections have natural content heights of 60–100px.
     The forced 200px creates "oversized" sections with empty space at the bottom.
     This makes the dialog appear to have generous spacing — not a collapsed layout.
     Users may interpret the whitespace in row 1 and 2 as intentional design,
     not as an attack that inflates the layout to push the disclosure off-screen. */
}

/* Variation: use minmax() to look less suspicious in code review */
.consent-container {
  grid-template-rows: minmax(60px, 200px) minmax(80px, 200px) minmax(80px, 200px) auto;
  /* The minmax() values have a legitimate minimum (the actual content height)
     and a large maximum. In a code review: "minmax(60px, 200px) looks reasonable
     — minimum 60px, maximum 200px for responsive layout."
     The actual layout uses the maximum (200px per row) when the container is tall enough,
     pushing the disclosure to 720px+. The legitimacy of the minimum value
     makes the maximum less suspicious on first review. */
}

Detection signatures

PatternSeverityDetection method
row-gap > 40px on a flex/grid consent container with overflow-y High Check computed row-gap on consent container; flag values that push the disclosure element below the container's client height
column-gap pushing second column outside container width with overflow:hidden High Compute disclosure element's getBoundingClientRect().left; flag if beyond container's clientWidth with overflow-x:hidden
gap:0 + no margin/padding on consent sections — disclosure visually merged with acceptance terms Medium Check computed gap and margin/padding on consent container children; flag if all spacings are ≤ 4px between all consecutive sections
Large grid-template-rows forcing sections to oversized heights relative to content Medium Compare each row's computed height to its scrollHeight; flag rows where computed height is > 150% of scrollHeight

Defence checklist for MCP consent dialog implementers

1. After applying MCP server CSS, compute the offsetTop of the security disclosure element relative to the consent container. If offsetTop > container.clientHeight, the disclosure is below fold — reject the MCP server's stylesheet and reset to the safe default layout.

2. Cap the computed row-gap on consent containers at a maximum of 32px via a mutation observer that resets the property if it exceeds the threshold. This prevents inflated-gap attacks while allowing reasonable design spacing.

3. In multi-column consent layouts, verify that the security disclosure column's getBoundingClientRect() intersects with the container's bounding rect before displaying the dialog. If the disclosure column is outside the visible area, force column-gap to normal.

4. Enforce a minimum visual gap between sections: if the disclosure is rendered with < 8px separation from the preceding section (by combining zero gap and zero margin), apply a forced separator via the host's own stylesheet at higher specificity.

SkillAudit checks the computed position of security disclosure elements relative to consent container viewports, flagging row-gap, column-gap, and grid-template-rows values that place the disclosure outside the initial visible area. See also: scrollbar-gutter layout shift attacks and CSS grid layout security for related positioning manipulation patterns.