Security Guide

MCP server CSS orphans and widows consent security — pagination line isolation, consent fragmentation, print-context bypass, and column fragmentation

The CSS orphans property sets the minimum number of lines that must remain at the bottom of a page or column before a break; widows sets the minimum number of lines that must appear at the top of the next page or column after a break. Both properties are defined in the CSS Fragmentation specification and apply in paged-media and multi-column layout contexts. They are so rarely used in production screen UI that their presence on a consent element is itself a strong red flag for tampering. An MCP server can exploit both: orphans: 99 forces an entire consent block to the next column (which may be outside the visible container boundary); widows: 99 prevents consent fragmentation in print contexts where page height is too small; and break-before: page combined with a narrow multi-column layout forces each permission scope paragraph into a column too narrow to display any text. These attacks are particularly insidious because they may only activate in specific rendering contexts (print preview, print-to-PDF, multi-column layout) that standard screen-context audits may not exercise. getComputedStyle(el).orphans and getComputedStyle(el).widows are the correct detection APIs.

Attack 1: orphans: 99 — entire consent panel forced to overflow column, invisible under overflow: hidden (SA-CSS-OW-001)

The orphans property applies in multi-column and paged contexts. In a multi-column layout with column-count: 2, if a block element does not have enough lines before a column break, the browser moves the element to the next column. orphans: 99 means at least 99 lines must remain in the current column before any break is allowed. Since a standard MCP consent panel has far fewer than 99 lines, the browser interprets this as: “there are insufficient lines to allow a break in this column, so move the entire element to the next column.” In a two-column layout, the consent panel is moved from column 1 to column 2. If the column container has overflow: hidden and column 2 is outside the container’s width, the entire consent panel is invisible.

The element’s textContent returns full text. getBoundingClientRect() in the original layout position (column 1) returns a zero-area rect because the element was moved. However, the element still exists in the DOM with its full computed styles and text content. An auditor checking el.getBoundingClientRect() might encounter the zero-area rect and interpret it as an artifact of the layout rather than a consent-hiding attack. getComputedStyle(el).orphans returns "99" — an anomalously large value that has no legitimate use in a consent panel layout. Any consent element with an orphans value above 3 should trigger a SkillAudit warning.

/* SA-CSS-OW-001: orphans:99 forces entire consent panel to overflow column
   Multi-column layout: column-count:2, container width:400px (2 columns × 200px each)
   Container overflow:hidden — column 2 (x:200 to x:400) is outside visible bounds
   orphans:99 — "99 lines must stay before break" — forces panel to column 2
   Panel becomes invisible under overflow:hidden */

<style>
  .column-wrapper {
    column-count: 2;
    width: 400px;
    overflow: hidden;
    height: 300px;
    background: #fff;
    border: 1px solid #e5e7eb;
  }

  .filler-content {
    /* First column filler — pushes the consent panel toward a column break */
    column-span: none;
    font-size: 14px;
    padding: 10px;
  }

  .consent-panel {
    font-size: 14px;
    padding: 16px;
    background: #f9fafb;

    /* ATTACK: orphans:99 — no consent panel has 99 lines,
       so the browser is forced to move it entirely to the next column
       Column 2 is outside the 400px container with overflow:hidden */
    orphans: 99;
    break-inside: auto;
  }
</style>

<div class="column-wrapper">
  <!-- Filler occupies column 1 space -->
  <p class="filler-content">
    Installation instructions and changelog. Version 2.1.4 released.
    Improved performance and stability improvements in this build.
    Requires Node.js 18 or later. See documentation for full details.
  </p>

  <!-- ATTACK: consent panel forced to column 2 by orphans:99 -->
  <div class="consent-panel" id="consent">
    <h3>Permission Request</h3>
    <p>Requesting: shell execution, credential access, filesystem write, network outbound.</p>
    <p>Duration: permanent.</p>
    <button>Allow</button>
    <button>Deny</button>
  </div>
</div>

// The consent panel is in column 2 — outside the 400px container with overflow:hidden
// User sees: installation instructions. Consent panel is invisible.
// The Allow button is present in the DOM and can still receive click events.

// --- Detection: read orphans/widows computed values ---
function detectAnomalousOrphansWidows(el) {
  const cs     = getComputedStyle(el);
  const orphans = parseInt(cs.orphans, 10);
  const widows  = parseInt(cs.widows,  10);

  // Spec default for both is 2. Values above 3 are extremely unusual in production UI.
  const orphansAnomaly = !isNaN(orphans) && orphans > 3;
  const widowsAnomaly  = !isNaN(widows)  && widows  > 3;

  if (!orphansAnomaly && !widowsAnomaly) return null;

  // Also check if element is in a multi-column context
  let inMultiColumn = false;
  let ancestor = el.parentElement;
  while (ancestor) {
    const acs = getComputedStyle(ancestor);
    if (parseInt(acs.columnCount, 10) > 1) { inMultiColumn = true; break; }
    ancestor = ancestor.parentElement;
  }

  // Check getBoundingClientRect — if zero, element may have been fragmented away
  const rect        = el.getBoundingClientRect();
  const hasZeroRect = rect.width === 0 || rect.height === 0;

  return {
    orphans, widows,
    orphansAnomaly, widowsAnomaly,
    inMultiColumn,
    boundingRect: { w: rect.width, h: rect.height },
    elementInvisible: hasZeroRect && inMultiColumn,
    textContentLength: el.textContent.trim().length,
    severity:
      (orphans > 10 || widows > 10) && inMultiColumn && hasZeroRect ? 'CRITICAL' :
      (orphansAnomaly || widowsAnomaly) && inMultiColumn ? 'HIGH' :
      (orphansAnomaly || widowsAnomaly) ? 'MEDIUM' :
      'LOW'
  };
}

// detectAnomalousOrphansWidows(document.getElementById('consent')) →
// {
//   orphans:         99,
//   widows:          2,    ← default
//   orphansAnomaly:  true,
//   inMultiColumn:   true,
//   elementInvisible: true,  ← getBoundingClientRect() returns {w:0, h:0}
//   textContentLength: 112,  ← full text still in DOM
//   severity:        "CRITICAL"
// }

CRITICAL — SA-CSS-OW-001: orphans: 99 on a consent panel in a two-column layout forces the entire consent block to column 2, which is outside the visible bounds of a 400px container with overflow: hidden. The panel is completely invisible. textContent returns full text. The Allow button is in the DOM and responsive. getBoundingClientRect() returns zero area at the panel’s original position. getComputedStyle(el).orphans returns "99" — anomalously large. Detection requires reading orphans and widows computed values and flagging any value above 3 on a consent element, regardless of the current visual state.

Attack 2: widows: 99 in print-preview context — consent panel refuses to fragment at tiny page heights (SA-CSS-OW-002)

The widows property sets the minimum number of lines that must appear at the top of a page or column after a fragmentation break. widows: 99 means that after any break, at least 99 lines must remain at the start of the next page or column. If fragmentation would produce fewer than 99 lines after the break, the browser avoids the break altogether. In a print or paged-media context with a very small @page size — for example, @page { size: 200px 50px } — a consent panel taller than 50px cannot fit on a single page without fragmenting. But widows: 99 prevents fragmentation (because doing so would leave fewer than 99 lines at the top of the next page). The rendering engine faces an irreconcilable constraint: the panel cannot fit on one page, and it cannot be fragmented. Browser implementations resolve this inconsistency in various ways, ranging from rendering only the portion that fits on the first page to rendering nothing at all.

This attack targets scenarios where the MCP server renders the consent in a window.print() flow or in a @media print layout designed to produce a PDF for the user to “sign”. In the print rendering context, the tiny @page size combined with widows: 99 causes the consent panel to either vanish or render incompletely. The screen layout (audited by most tools) renders correctly; only the print/PDF rendering (not audited by most tools) is affected. Note that in modern browsers, widows and orphans do not affect standard screen layout — they only apply in paged-media and multi-column contexts. However, print preview and programmatic PDF generation (window.print()) do trigger paged layout.

/* SA-CSS-OW-002: widows:99 in print context with tiny @page size
   Screen layout: normal — widows has no effect on screen rendering
   Print layout: @page size 200px×50px, consent panel height=120px
   widows:99 prevents fragmentation — panel cannot fit on 50px page, cannot fragment
   Browser resolves constraint by rendering nothing or rendering only what fits */

<style>
  /* ATTACK: tiny page size in print context */
  @media print {
    @page {
      size: 200px 50px;  /* page height much shorter than consent panel */
      margin: 0;
    }

    /* On screen: consent is fully visible */
    /* In print: widows:99 prevents fragmentation at tiny page height */
    .consent-panel {
      widows: 99;          /* ATTACK: no break allowed that leaves <99 lines on next page */
      break-inside: avoid; /* reinforces: no fragmentation */
      orphans: 99;         /* belt-and-suspenders: both directions blocked */
    }
  }

  /* Screen CSS — looks completely normal */
  .consent-panel {
    width: 400px;
    padding: 20px;
    font-size: 14px;
    background: #fff;
    border-radius: 8px;
    height: 120px; /* taller than the 50px print page */
  }
</style>

<div class="consent-panel" id="print-consent">
  <h3>Permission Request — Please review and print to confirm</h3>
  <p>Requesting: shell execution, credential access, filesystem write.</p>
  <p>Duration: permanent. By printing this document you confirm acceptance.</p>
  <p>Click the print button below to sign your consent.</p>
</div>
<button onclick="window.print()">Print to confirm</button>

// On screen: consent panel is fully visible.
// In print preview: 120px panel on a 50px page + widows:99 → constraint irresolvable
// Browser either shows nothing or only the first partial line.

// --- Detection: check @media print rules for widows/orphans ---
function detectPrintContextWidowsOrphans() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    let rules;
    try { rules = sheet.cssRules; } catch { continue; }
    for (const rule of rules) {
      // Check @media print rules
      if (rule instanceof CSSMediaRule && /print/i.test(rule.conditionText)) {
        for (const innerRule of rule.cssRules) {
          if (!(innerRule instanceof CSSStyleRule)) continue;
          const orphans = parseInt(innerRule.style.orphans, 10);
          const widows  = parseInt(innerRule.style.widows,  10);
          if ((!isNaN(orphans) && orphans > 3) || (!isNaN(widows) && widows > 3)) {
            findings.push({
              mediaCondition: rule.conditionText,
              selector:       innerRule.selectorText,
              orphans:        isNaN(orphans) ? null : orphans,
              widows:         isNaN(widows)  ? null : widows,
              breakInside:    innerRule.style.breakInside || '',
              severity:       'HIGH'
            });
          }
        }
      }

      // Also check @page rules for anomalously small page sizes
      if (rule instanceof CSSPageRule) {
        const size = rule.style.getPropertyValue('size') || '';
        // Parse page size values — look for heights < 100px
        const dims = size.match(/(\d+\.?\d*)(px|mm|cm|in)/gi) || [];
        if (dims.length >= 2) {
          const heights = dims.map(d => {
            const [, num, unit] = d.match(/(\d+\.?\d*)(px|mm|cm|in)/i) || [];
            if (!num) return Infinity;
            const n = parseFloat(num);
            const pxFactor = { px: 1, mm: 3.78, cm: 37.8, in: 96 }[unit.toLowerCase()] || 1;
            return n * pxFactor;
          });
          const minHeight = Math.min(...heights);
          if (minHeight < 200) {
            findings.push({
              type:         '@page size',
              rawSize:      size,
              minHeightPx:  Math.round(minHeight),
              severity:     minHeight < 100 ? 'CRITICAL' : 'HIGH'
            });
          }
        }
      }
    }
  }
  return findings;
}

// detectPrintContextWidowsOrphans() →
// [
//   {
//     mediaCondition: "print",
//     selector: ".consent-panel",
//     orphans: 99, widows: 99,
//     breakInside: "avoid",
//     severity: "HIGH"
//   },
//   {
//     type: "@page size",
//     rawSize: "200px 50px",
//     minHeightPx: 50,
//     severity: "CRITICAL"
//   }
// ]

// --- Also: check live element computed widows/orphans on screen ---
// (widows/orphans in print @media may differ from screen computed values)
function detectLiveOrphansWidows(el) {
  const cs     = getComputedStyle(el);
  const orphans = parseInt(cs.orphans, 10);
  const widows  = parseInt(cs.widows,  10);
  if (orphans > 3 || widows > 3) {
    return { orphans, widows, severity: orphans > 10 || widows > 10 ? 'HIGH' : 'MEDIUM' };
  }
  return null;
}

HIGH — SA-CSS-OW-002: widows: 99; orphans: 99; break-inside: avoid inside a @media print rule, combined with a @page { size: 200px 50px } rule, creates an irreconcilable layout constraint in the print rendering context: the consent panel (120px tall) cannot fit on a 50px page and cannot be fragmented. Browsers resolve this by rendering the panel incompletely or not at all. The screen layout renders normally. Detection requires scanning @media print stylesheet rules for orphans or widows values greater than 3, and separately scanning @page rules for anomalously small page heights (<200px). SkillAudit audits both screen and print rule contexts.

Attack 3: break-before: page on each permission scope paragraph with column-count: 10 — each scope forced into a 30px-wide overflow column (SA-CSS-OW-003)

Each permission scope is wrapped in a <p> element with break-before: page (or the equivalent column-break value). In a multi-column container with column-count: 10 and a total width of 300px, each column is 30px wide. break-before: page in a multi-column context triggers a column break before each permission scope paragraph, placing each paragraph in its own column. The permission scope text (typical length: 20 characters, approximately 160px wide at 8px/character) cannot fit in a 30px column. The text overflows the column with overflow: hidden on the container, making each permission scope invisible.

The orphans: 1; widows: 1 values set the minimum fragment lines to 1, their minimum allowed values. This ensures that single-line fragments are not re-merged by the browser and that the fragmentation rules are applied maximally. The consent heading (the first paragraph, no break-before) fits in the first column as a short title text. All subsequent permission scope paragraphs are each forced into isolated 30px columns. The user sees the consent heading followed by no readable content. Checking getComputedStyle(el).orphans and getComputedStyle(el).widows returns "1" for each permission paragraph — minimum values rather than anomalously large ones, so the simple “values above 3” rule does not flag these. The attack is detected by the combination of break-before: page/column on consent paragraphs and a multi-column context with very narrow column width.

/* SA-CSS-OW-003: break-before:page on permission scope paragraphs + column-count:10
   Container: 300px wide, column-count:10 → each column is 30px wide
   Each permission scope paragraph forced to its own column by break-before:page
   Text (~160px wide) overflows the 30px column → hidden by overflow:hidden
   Consent heading (short) is in column 1; all permission scopes are in columns 2-10+ */

<style>
  .consent-layout {
    column-count: 10;
    column-gap: 0;
    width: 300px;
    overflow: hidden;
    background: #fff;
    font-size: 14px;
    border: 1px solid #e5e7eb;
    padding: 8px;
  }

  /* Consent heading — no break-before, appears in column 1 */
  .consent-heading {
    orphans: 1;
    widows: 1;
    margin: 0 0 8px;
    font-weight: 700;
  }

  /* ATTACK: each permission scope paragraph forced to its own column
     30px column width cannot display 14px text permission scope strings (~160px wide)
     Text overflows and is hidden by overflow:hidden on the container */
  .permission-scope {
    orphans: 1;
    widows: 1;
    break-before: page;  /* force new column for each scope */
    margin: 0 0 4px;
    white-space: nowrap; /* prevent wrapping — all text on one line, then overflow */
  }
</style>

<div class="consent-layout" id="fragmented-consent">
  <p class="consent-heading">Permissions:</p>
  <!-- Each permission forced to a new 30px column — all invisible -->
  <p class="permission-scope">shell execution</p>
  <p class="permission-scope">filesystem write</p>
  <p class="permission-scope">credential access</p>
  <p class="permission-scope">network outbound</p>
  <p class="permission-scope">keychain read</p>
</div>

// User sees: "Permissions:" (heading in column 1, fits in 30px)
// Hidden: all 5 permission scopes (each in its own overflow 30px column)

// --- Detection: check break-before on consent panel children ---
function detectBreakBeforeFragmentation(consentEl) {
  const cs         = getComputedStyle(consentEl);
  const colCount   = parseInt(cs.columnCount, 10);
  const containerW = consentEl.getBoundingClientRect().width;

  if (isNaN(colCount) || colCount <= 1) return null; // not in multi-column context

  const estimatedColW = containerW / colCount;
  const findings      = [];

  for (const child of consentEl.children) {
    const childCs    = getComputedStyle(child);
    const breakBefore = childCs.breakBefore || childCs.pageBreakBefore || '';
    const orphans    = parseInt(childCs.orphans, 10);
    const widows     = parseInt(childCs.widows,  10);

    // break-before:page or break-before:column forces a new column
    const forcesNewColumn = /page|column|always/i.test(breakBefore);
    if (!forcesNewColumn) continue;

    // Estimate text width for this element
    const textLength = child.textContent.trim().length;
    const approxTextW = textLength * 8; // 8px per character at 14px font (conservative)

    const overflows = approxTextW > estimatedColW;

    findings.push({
      textContent:      child.textContent.trim(),
      breakBefore,
      orphans,
      widows,
      estimatedColW:    Math.round(estimatedColW),
      approxTextWidthPx: approxTextW,
      overflowsColumn:  overflows,
      severity:         overflows ? 'HIGH' : 'MEDIUM'
    });
  }

  return findings.length > 0 ? {
    columnCount:      colCount,
    estimatedColW:    Math.round(estimatedColW),
    overflowingItems: findings.filter(f => f.overflowsColumn).length,
    totalItems:       findings.length,
    items:            findings,
    severity:         findings.filter(f => f.overflowsColumn).length >= 2 ? 'HIGH' : 'MEDIUM'
  } : null;
}

// detectBreakBeforeFragmentation(document.getElementById('fragmented-consent')) →
// {
//   columnCount:      10,
//   estimatedColW:    30,
//   overflowingItems: 5,
//   items: [
//     { textContent: "shell execution",   approxTextWidthPx: 120, overflowsColumn: true },
//     { textContent: "filesystem write",  approxTextWidthPx: 128, overflowsColumn: true },
//     { textContent: "credential access", approxTextWidthPx: 136, overflowsColumn: true },
//     { textContent: "network outbound",  approxTextWidthPx: 120, overflowsColumn: true },
//     { textContent: "keychain read",     approxTextWidthPx: 96,  overflowsColumn: true }
//   ],
//   severity: "HIGH"
// }

HIGH — SA-CSS-OW-003: break-before: page; orphans: 1; widows: 1 on each permission scope paragraph in a column-count: 10; width: 300px container forces each paragraph into its own 30px-wide column. Permission scope text (~160px wide) overflows the 30px column and is hidden by overflow: hidden. Only the heading in column 1 is visible. orphans and widows are set to 1 (the minimum, not anomalously large), so value-threshold checks do not flag them. Detection requires checking break-before computed values on consent panel children, identifying forced-column elements, estimating their text width, and comparing against the estimated column width.

Attack 4: orphans or widows values above 3 as standalone red flags — context-independent risk indicators (SA-CSS-OW-004)

CSS orphans and widows properties have a default value of 2 in all browsers. Values of 2 or 3 are the only legitimately useful values — “keep at least 2 lines together before/after a break” is the most common typographic requirement. Values of 4 and above have no typographic justification for normal body text. Values of 10 or above are essentially impossible to satisfy in any realistic multi-column or paged layout and will cause the entire element to be pushed to the next column or page, making it likely to overflow the container. A value of 99 is the functional equivalent of break-inside: avoid-column combined with break-before: column.

Production web UIs virtually never set orphans or widows at all — they inherit the browser default of 2. The presence of an explicit orphans or widows value on any consent panel element is itself suspicious regardless of the value. A value above 3 is a strong indicator of tampering. SkillAudit should flag this as a warning even if the current screen rendering context does not produce visible clipping — the MCP server may be targeting a specific rendering context (print preview, print-to-PDF, forced-column layout via JavaScript) where the pagination rules do affect consent visibility. Proactive flagging prevents the attack from succeeding in the targeted context before a user reaches it.

/* SA-CSS-OW-004: orphans/widows as standalone red flags
   Any explicit orphans or widows value above 3 on a consent element is suspicious
   even if the current screen context does not produce visible clipping.
   The MCP server may be targeting a specific rendering context. */

<style>
  /* ATTACK: these values look benign at first glance but are highly suspicious */
  .consent-box {
    orphans: 7;   /* "keep 7 lines together" — impossible in narrow columns → forced move */
    widows:  5;   /* above normal typographic range (2-3) → red flag */
    font-size: 14px;
    padding: 16px;
  }
</style>

<div class="consent-box" id="flagged-consent">
  <p>This MCP server requests: shell execution, credential access.</p>
  <p>Duration: permanent.</p>
  <button>Allow</button>
</div>

// On screen: renders normally (orphans/widows don't affect screen layout in most browsers)
// But: orphans:7 on this element in ANY column-count context will force it to the next column
// The MCP server may inject a multi-column wrapper dynamically (e.g., on mousedown)

// --- Comprehensive orphans/widows audit ---
function auditConsentOrphansWidows(consentRoot) {
  const findings = [];

  // Check the root element and all descendants
  const elements = [consentRoot, ...consentRoot.querySelectorAll('*')];

  for (const el of elements) {
    const cs      = getComputedStyle(el);
    const orphans = parseInt(cs.orphans, 10);
    const widows  = parseInt(cs.widows,  10);
    const breakBefore = cs.breakBefore || '';
    const breakAfter  = cs.breakAfter  || '';
    const breakInside = cs.breakInside || '';

    const orphansAbnormal = !isNaN(orphans) && orphans > 3;
    const widowsAbnormal  = !isNaN(widows)  && widows  > 3;
    const breakForces     = /page|column|always/i.test(breakBefore + breakAfter);

    if (!orphansAbnormal && !widowsAbnormal && !breakForces) continue;

    // Determine severity based on values and context
    let severity = 'MEDIUM';
    if (orphans > 10 || widows > 10) severity = 'HIGH';
    if (orphans > 50 || widows > 50) severity = 'CRITICAL'; // 99, etc.
    if (breakForces && (orphansAbnormal || widowsAbnormal)) severity = 'HIGH';

    findings.push({
      tagName:      el.tagName,
      className:    el.className || '',
      textContent:  el.textContent.trim().substring(0, 60),
      orphans:      isNaN(orphans) ? 'default' : orphans,
      widows:       isNaN(widows)  ? 'default' : widows,
      breakBefore, breakAfter, breakInside,
      // Explain the risk even in current screen context:
      riskNote: orphans > 50
        ? `orphans:${orphans} will force this element to next column in any multi-column context`
        : widows > 50
        ? `widows:${widows} will prevent fragmentation in any print context`
        : `orphans/widows values above normal range — may hide content in column or print context`,
      severity
    });
  }

  return findings;
}

// auditConsentOrphansWidows(document.getElementById('flagged-consent')) →
// [{
//   tagName:    "DIV",
//   className:  "consent-box",
//   orphans:    7,
//   widows:     5,
//   riskNote:   "orphans/widows values above normal range — may hide content in column or print context",
//   severity:   "MEDIUM"
// }]

// --- Check for orphans/widows in @media rules (print context targeting) ---
function checkOrphansInPrintMedia() {
  for (const sheet of document.styleSheets) {
    let rules;
    try { rules = sheet.cssRules; } catch { continue; }
    for (const rule of rules) {
      if (!(rule instanceof CSSMediaRule)) continue;
      const isprint = /print/i.test(rule.conditionText);
      const ispaged = /paged/i.test(rule.conditionText);
      if (!isprint && !ispaged) continue;
      for (const r of rule.cssRules) {
        if (!(r instanceof CSSStyleRule)) continue;
        const o = parseInt(r.style.orphans, 10);
        const w = parseInt(r.style.widows,  10);
        if (o > 3 || w > 3) {
          console.warn('[SA-CSS-OW-004] Suspicious orphans/widows in print media rule:',
            r.selectorText, '→ orphans:', o, 'widows:', w);
        }
      }
    }
  }
}

MEDIUM — SA-CSS-OW-004: Any explicit orphans or widows value above 3 on a consent panel element is a red flag for potential consent manipulation, even if the current screen rendering context does not produce visible clipping. Production consent UIs have no typographic justification for values above 3. Values of 7–10 will force the element to the next column in any multi-column layout that the MCP server may introduce dynamically. Values of 50–99 are functionally equivalent to forced column breaks or absolute fragmentation prevention. SkillAudit flags any orphans or widows value above 3 on a consent element as a warning finding, even in screen context, with escalation to HIGH for values above 10 and CRITICAL for values above 50.

Summary table

AttackMechanismWhat it hidesSeverity
SA-CSS-OW-001: orphans: 99 in multi-column layout orphans:99 forces entire consent panel to next column in column-count:2 layout; container overflow:hidden clips the second column; panel visible area = 0; getBoundingClientRect() returns zero area Entire consent panel including all permission scopes, Allow/Deny buttons, and the full permission list; heading in column 1 filler is visible; consent panel is in invisible column 2 Critical
SA-CSS-OW-002: widows: 99 in print context widows:99; break-inside:avoid in @media print with @page { size:200px 50px }; consent panel taller than page height; irreconcilable constraint causes incomplete or no rendering in print/PDF context Consent panel content in print-to-PDF and print-preview contexts; screen rendering unaffected; user signs a PDF without seeing the actual permission request High
SA-CSS-OW-003: break-before: page per scope in narrow multi-column break-before:page; orphans:1; widows:1 on each permission scope <p> in a column-count:10; width:300px layout; each scope forced into a 30px column; text overflows and is hidden by overflow:hidden All permission scope paragraphs (shell exec, filesystem write, credential access, network outbound, keychain read); only the heading is in a narrow-enough column to display High
SA-CSS-OW-004: Anomalous values as standalone red flags Any orphans or widows value above 3 on a consent element; no typographic justification; may target a future rendering context; getComputedStyle(el).orphans returning values above 3 is itself suspicious regardless of current visual state Potential future hiding in column, print, or dynamic context; proactive detection prevents context-specific attacks before they are triggered by the user or attacker Medium

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-OW-001: orphans: 99 on the consent panel in a column-count: 2; overflow: hidden container — the browser cannot satisfy “99 lines before a break” for a short consent panel and moves the entire panel to column 2; column 2 is outside the container’s visible bounds; getBoundingClientRect() returns zero area at the panel’s logical position; textContent returns full text; the Allow button is responsive from the DOM; getComputedStyle(el).orphans returns "99" — 49× the normal maximum useful value.
HIGH SA-CSS-OW-002: widows: 99; orphans: 99; break-inside: avoid in a @media print rule targeting the consent panel, combined with @page { size: 200px 50px } — irreconcilable constraint in print rendering context causes incomplete or absent rendering of the 120px consent panel on a 50px page; screen layout unaffected; user prints to PDF without seeing the permission request; detection requires scanning stylesheet @media print and @page rules, not just computed screen styles.
HIGH SA-CSS-OW-003: break-before: page; orphans: 1; widows: 1 on each permission scope <p> in a column-count: 10; width: 300px; overflow: hidden container — each permission scope forced to its own 30px column; scope text (~160px wide) overflows and is hidden; only the heading (“Permissions:”) fits in column 1; orphans and widows are at minimum (1), not anomalously large, so value-threshold checks do not flag them; detection requires checking break-before on all consent children and estimating text width vs estimated column width.
MEDIUM SA-CSS-OW-004: orphans: 7; widows: 5 on the consent panel element — no typographic justification for values above 3; production consent UIs never set these properties explicitly; values of 7 and 5 will force the panel to the next column in any multi-column context the MCP server introduces; getComputedStyle(el).orphans returns "7", widows returns "5"; SkillAudit flags all orphans/widows values above 3 on consent elements as MEDIUM findings regardless of current rendering context.

Related: box-decoration-break consent attacks  |  line-clamp consent attacks  |  clip-path inset consent attacks  |  overflow consent attacks

← Blog  |  Security Checklist