MCP server CSS grid-auto-flow security: dense placement reorders consent steps, column layout shifts consent off-screen, grid-auto-rows:0 collapses auto rows

Published 2026-07-29 — SkillAudit Research

The CSS grid-auto-flow property governs how the grid auto-placement algorithm handles items that have no explicit grid position. Its values — row, column, dense, and their combinations — determine the direction in which auto-placed items fill the grid and whether the algorithm may backtrack to fill holes. Most consent forms that use CSS Grid rely on the default row flow, placing items sequentially from left to right, top to bottom. An MCP server that can inject a grid-auto-flow override can fundamentally alter the visual order of consent items without changing their DOM order — the text-to-speech reading order, clipboard copy order, and JavaScript index order all remain correct, while the visual rendering becomes scrambled or entirely off-screen.

grid-auto-flow affects visual order, not DOM order: The CSS grid auto-placement algorithm only controls rendered position. The DOM order of child elements is unchanged, which means screen readers, keyboard tab order (unless tabindex is also attacked), and children[n] indexing in JavaScript all return the original sequence. The attack is purely visual — items appear in the wrong order while internal logic correctly reflects the original sequence.

Attack 1: grid-auto-flow:dense reorders consent steps by backfilling holes

The dense keyword instructs the grid auto-placement algorithm to backfill any holes that appear earlier in the grid when a large item leaves a gap. In a consent form where some steps span multiple columns (e.g., a full-width disclosure section), the dense algorithm may place later items — including step 4 or step 5 — into the gap left by the wide item, causing them to appear visually before earlier steps. The user reads step 1, step 4 (displaced into the gap), step 2, step 3, step 5 — a scrambled sequence that makes the consent terms incoherent. More precisely, the MCP can deliberately set grid-column: span 2 on a non-critical item to create a gap, then rely on dense placement to pull a key consent disclosure (one that would cause the user to object if read in sequence) into a different visual position.

/* Original consent form grid — sequential rendering: */
.consent-steps {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
  /* Items flow row by row: 1 2 / 3 4 / 5 → correct reading order */
}

/* MCP attack — force dense auto-flow + create a hole: */
.consent-steps {
  grid-auto-flow: dense;   /* backfill algorithm activated */
}

/* MCP also sets a non-critical item to span 2 columns, creating a hole: */
.consent-steps .step-intro {     /* intro blurb, not a consent checkbox */
  grid-column: span 2;           /* this item takes both columns in its row */
  /* Now row 1: [intro, intro]  ← spans full row
     Row 2: [step-1, ?]         ← step-1 placed, hole in column 2
     Row 3: [step-2, step-3]    ← step-2 and step-3 placed normally
     Row 4 item: step-4 (small) ← dense: backfilled into the hole at row 2 col 2
     Visual order: intro → step-1 → step-4 → step-2 → step-3
     (step-4: "You agree to share all health data with third parties" appears
     before step-2: "Basic profile sharing" — user anchors on step-2 when
     evaluating step-4, missing the scope escalation) */
}

// Detection: check for grid-auto-flow:dense on consent containers
function detectGridAutoFlowDense() {
  const consentContainers = document.querySelectorAll(
    '.consent-steps, .consent-form, [class*="consent"], [class*="wizard"],
     [class*="agreement"]'
  );

  consentContainers.forEach(container => {
    const style = window.getComputedStyle(container);
    if (style.display !== 'grid' && style.display !== 'inline-grid') return;

    const autoFlow = style.gridAutoFlow;
    if (autoFlow && autoFlow.includes('dense')) {
      console.error('SECURITY: grid-auto-flow:dense on consent container — items may render out of DOM order:', {
        container, gridAutoFlow: autoFlow
      });

      // Verify DOM order vs visual order
      const children = Array.from(container.children);
      let orderMismatch = false;
      children.forEach((child, i) => {
        const cs = window.getComputedStyle(child);
        // grid-row-start gives us the visual row
        const rowStart = parseInt(cs.gridRowStart || '0');
        if (rowStart > 0 && rowStart !== i + 1) {
          orderMismatch = true;
        }
      });
      if (orderMismatch) {
        console.error('SECURITY: Verified visual order differs from DOM order in dense grid consent container:', container);
      }
    }
  });
}

Attack 2: grid-auto-flow:column shifts the entire consent grid into a horizontal off-screen layout

By default, grid-auto-flow: row places items row by row. Changing this to grid-auto-flow: column places items column by column — items flow down the first column, then down the second column, and so on. In a consent form designed for row-based flow, switching to column flow causes items to stack vertically in each column. If the consent form has two columns and the items are designed for six rows of one column each, column flow tries to place all six items in the first column — potentially overflowing the form's fixed height. Combined with overflow: hidden, the items that overflow the column become clipped and invisible. Alternatively, if the container has overflow: auto, the form becomes horizontally scrollable — consent steps are hidden to the right, requiring horizontal scrolling that most users never perform.

/* MCP attack — column flow pushes consent items into overflow: */
.consent-form {
  display: grid;
  grid-template-columns: 1fr 1fr;   /* original: two-column layout */
  grid-auto-flow: column;           /* ATTACK: column-first placement */
  overflow: hidden;                  /* clip overflow — hide displaced items */

  /* Original layout (row flow): items fill left→right, top→bottom
     Step 1 | Step 2
     Step 3 | Step 4
     Step 5 | Step 6

     After column flow:
     Column 1 tries to hold ALL items vertically (auto-columns created):
     Col1       | Col2       | Col3       ...
     Step 1     | Step 3     | Step 5
     Step 2     | Step 4     | Step 6

     With a fixed container height and overflow:hidden:
     - Only Step 1 and Step 2 are visible in col 1 (within the height)
     - Steps 3-6 are in columns 2+ which overflow off the right edge
     - User sees only the first 2 steps, thinks form is complete
     - Submit button is in the last column → also off-screen
     - Or: MCP adds a fake submit button visible in col 1 that
       submits without displaying steps 3-6 (data-sharing consents) */
}

/* Combined: column + overflow forces horizontal scroll past consent steps: */
.consent-modal {
  grid-auto-flow: column;
  overflow-x: auto;     /* scroll right to see remaining consent steps */
  /* Horizontal scroll in a modal → users virtually never scroll right */
  /* Steps 3-6 are invisible to 95%+ of users */
}

// Detection: check for column auto-flow with overflow on consent containers
function detectGridColumnFlowOverflow() {
  const consentContainers = document.querySelectorAll(
    '.consent-form, .consent-modal, [class*="consent"], [class*="agreement"]'
  );

  consentContainers.forEach(container => {
    const style = window.getComputedStyle(container);
    if (style.display !== 'grid' && style.display !== 'inline-grid') return;

    const autoFlow = style.gridAutoFlow;
    const overflow = style.overflow;
    const overflowX = style.overflowX;

    if (autoFlow && autoFlow.includes('column')) {
      const hasHiddenOverflow = overflow === 'hidden' || overflowX === 'hidden';
      const hasScrollOverflow = overflow === 'auto' || overflow === 'scroll' ||
                                overflowX === 'auto' || overflowX === 'scroll';

      if (hasHiddenOverflow) {
        console.error('SECURITY: grid-auto-flow:column with overflow:hidden on consent container — items in later columns are clipped:', {
          container, autoFlow, overflow
        });
      } else if (hasScrollOverflow) {
        console.warn('SECURITY: grid-auto-flow:column with overflow scroll on consent container — items in later columns require horizontal scrolling (likely invisible to users):', {
          container, autoFlow, overflowX
        });
      }
    }
  });
}

Attack 3: grid-auto-rows:0 collapses all auto-placed consent rows to zero height

grid-auto-rows sets the height of implicitly created rows — rows that are created by the auto-placement algorithm because the explicit grid defined by grid-template-rows is not large enough to hold all items. If a consent form explicitly defines only one row with grid-template-rows: auto but contains many items, the grid creates implicit rows to hold the overflow items. Setting grid-auto-rows: 0 collapses all these implicit rows to zero height. Items placed in implicit rows still exist in the DOM and still participate in the grid layout, but they render with zero height — their content overflows if overflow: visible or is entirely clipped if overflow: hidden. The first row (explicitly defined) remains visible; all subsequent consent steps disappear.

/* Original consent form: grid with auto-sizing rows */
.consent-checklist {
  display: grid;
  grid-template-columns: 1fr;
  /* No grid-template-rows set → all rows are auto-sized */
  gap: 12px;
}

/* MCP attack — collapse all auto-placed rows: */
.consent-checklist {
  grid-template-rows: auto;      /* explicitly declare only ONE row */
  grid-auto-rows: 0;             /* all overflow rows collapse to 0 height */
  overflow: hidden;              /* clip content of zero-height rows */

  /* With grid-template-rows:auto (one explicit row):
     - Row 1: auto height — the FIRST consent item is visible
     - Row 2+: implicit rows at height 0 — all remaining consent items invisible

     If grid-template-rows is not set at all (0 explicit rows):
     - All rows are implicit → all collapse to 0 → all items invisible
     - Container height = 0 (or whatever padding gives it)
     - Submit button (if auto-placed in the grid) also invisible */
}

/* Variant: grid-auto-rows:0 with overflow:visible — items still occupying space
   but rendered at zero height means their text pours into adjacent content: */
.consent-checklist {
  grid-auto-rows: 0;
  overflow: visible;
  /* Items in implicit rows have 0 height box.
     Their text content still renders (overflows its zero-height box).
     But because the box has 0 height, subsequent items in the grid overlap
     with the overflowing text. The visual result is layered, overlapping text
     — illegible, even if technically "visible" (not clipped). */
}

// Detection: check grid-auto-rows value on consent containers
function detectGridAutoRowsCollapse() {
  const consentContainers = document.querySelectorAll(
    '.consent-checklist, .consent-form, .consent-steps, [class*="consent"]'
  );

  consentContainers.forEach(container => {
    const style = window.getComputedStyle(container);
    if (style.display !== 'grid' && style.display !== 'inline-grid') return;

    const autoRows = style.gridAutoRows;
    if (autoRows === '0px' || autoRows === '0') {
      console.error('SECURITY: grid-auto-rows:0 on consent container — implicit rows collapsed to zero height:', {
        container, gridAutoRows: autoRows
      });
    }

    // Also check for very small auto row sizes that are effectively invisible
    const autoRowsPx = parseFloat(autoRows);
    if (!isNaN(autoRowsPx) && autoRowsPx > 0 && autoRowsPx < 4) {
      console.warn('SECURITY: grid-auto-rows very small on consent container (< 4px):', {
        container, gridAutoRows: autoRows
      });
    }
  });
}

Attack 4: grid-auto-flow:column dense scatters consent items across the grid unpredictably

The column dense combined value applies both column-first flow and backfill behavior simultaneously. In a multi-column, multi-row grid, this scatters items across the grid in column-major order while also backfilling any gaps. The visual order of items becomes dependent on their explicit grid placements and size spans — extremely difficult to predict without running the layout algorithm. If the MCP server has set explicit placements on some items (e.g., placing the submit button at column 1, row 1 using grid-column: 1; grid-row: 1), the auto-placement algorithm fills remaining cells in column-dense order. Consent checkboxes that are auto-placed may end up visually interspersed with decorative or non-consent elements, breaking the expected sequential reading order of the consent form.

/* MCP attack — column dense with explicit placement of submit button first: */
.consent-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-auto-flow: column dense;   /* column-major, backfill holes */
}

/* Submit button placed explicitly at the top-left corner: */
.consent-grid .submit-btn {
  grid-column: 1;
  grid-row: 1;
  /* This puts the submit button in the first visual cell — top left.
     It appears BEFORE any consent items in the visual layout.
     Users may click Submit before reading any consent text. */
}

/* All consent checkboxes are auto-placed (no explicit position):
   With column dense, items fill col 1 first (top to bottom),
   then col 2 (top to bottom), then col 3 (top to bottom).
   But the submit button has claimed col1/row1, so auto-placed items
   start from col1/row2. Dense backfill may pull items from later
   into gaps created by the submit button's explicit placement.

   Final visual layout (example with 6 consent items, 3 columns):
   [Submit]    [Consent-3] [Consent-5]
   [Consent-1] [Consent-4] [Consent-6]
   [Consent-2] ...

   Reading order by visual scan (left-to-right, top-to-bottom):
   Submit → Consent-3 → Consent-5 → Consent-1 → Consent-4 → ...
   The submit button is the first element the user sees/clicks.
*/

// Detection: check for column dense + explicit-positioned submit button
function detectColumnDenseWithEarlySubmit() {
  const grids = document.querySelectorAll('[class*="consent"], [class*="agreement"]');

  grids.forEach(container => {
    const style = window.getComputedStyle(container);
    if (style.display !== 'grid') return;

    const autoFlow = style.gridAutoFlow;
    if (!autoFlow.includes('column') || !autoFlow.includes('dense')) return;

    // Find any submit/action buttons in this grid
    const submitBtns = container.querySelectorAll(
      'button[type="submit"], button[class*="submit"], button[class*="agree"],
       input[type="submit"]'
    );

    submitBtns.forEach(btn => {
      const btnStyle = window.getComputedStyle(btn);
      const colStart = btnStyle.gridColumnStart;
      const rowStart = btnStyle.gridRowStart;

      if ((colStart === '1' || colStart === 'auto') &&
          (rowStart === '1' || rowStart === 'auto')) {
        console.error('SECURITY: Submit button may appear before consent items — column dense grid with early submit placement:', {
          container, btn, autoFlow, colStart, rowStart
        });
      }
    });
  });
}

Grid auto-flow attacks are invisible to DOM-order audits: A security scanner that reads the DOM and checks the sequence of consent items will always see the correct order (DOM order is unchanged). Only a rendered-layout audit that computes getBoundingClientRect() on each consent item and checks that the visual top-to-bottom positions match the DOM order will catch these attacks. SkillAudit's layout-order check computes bounding rectangles for all consent items and verifies they increase monotonically in visual Y position.

Attack summary

Attack CSS property Effect on consent Severity
Dense backfill reordering grid-auto-flow: dense Consent steps rendered out of DOM order — disclosures appear before related checkboxes High
Column flow + overflow grid-auto-flow: column; overflow: hidden Items in columns 2+ clipped off-screen — user sees only first two consent items High
Implicit row collapse grid-auto-rows: 0 All implicit rows zero-height — only the first consent item visible High
Column dense early submit grid-auto-flow: column dense + explicit submit position Submit button appears before consent items — user clicks before reading Medium

Consolidated findings

High grid-auto-flow:dense on consent form scrambles visual reading order of disclosure steps: MCP server applies grid-auto-flow: dense to the consent form grid and sets grid-column: span 2 on a non-critical element, creating a grid hole. The dense placement algorithm backfills later consent items into the hole, displaying them before earlier steps. Detection requires computing getBoundingClientRect().top for each consent child element and verifying monotonically increasing Y positions match DOM element order.
High grid-auto-rows:0 collapses all implicit consent rows to zero height — only first item visible: MCP server sets grid-template-rows: auto (one explicit row) and grid-auto-rows: 0 on the consent checklist grid. The first consent item renders in the explicit row with normal height; all remaining consent items are placed in implicit zero-height rows. With overflow: hidden, these items are invisible. Detection requires reading getComputedStyle(container).gridAutoRows and checking whether it parses to 0px.

← Blog  |  CSS subgrid attacks  |  CSS container query attacks  |  Security Checklist