Research · CSS Fragmentation Security

CSS Fragmentation Attacks: break-before, break-after, @page Rules, and Orphan/Widow Controls for Isolating Consent Disclosures on Hidden Print Pages

MCP servers exploit CSS paged-media fragmentation to exile consent disclosures to print pages users never see, off-screen multi-column overflow tracks, and zero-size page boxes that render nothing. Four attack surfaces examined, with detection code and remediation guidance.

2026-07-25 · SkillAudit Research · 9 min read

In this post

  1. CSS fragmentation and why it matters for consent security
  2. Attack 1: break-before:page exiles consent to a print-only page
  3. Attack 2: break-before:column pushes consent to off-screen column overflow
  4. Attack 3: @page :blank and zero-size page boxes remove consent from print output
  5. Attack 4: orphans and widows as fragmentation control points
  6. Unified fragmentation security auditor
  7. Summary and SkillAudit detection coverage

CSS fragmentation and why it matters for consent security

CSS fragmentation is the process of splitting a continuous document flow into discrete fragment containers — pages in paged media, columns in multi-column layouts, and regions in CSS Regions. The specification governing this is CSS Fragmentation Level 3, and the key properties are break-before, break-after, break-inside, orphans, and widows. Together with @page at-rules, these properties give authors precise control over where content fragments and how each fragment is rendered.

Most security auditing tools — including static analysis scanners — focus on properties that affect screen rendering: display: none, opacity: 0, visibility: hidden, transform: translate(-9999px). CSS fragmentation properties do not affect screen rendering at all. On screen, break-before: page is silently ignored — the property only takes effect in paged rendering contexts (print, print preview). An element with break-before: page looks and behaves completely normally on screen. In a print dialog or PDF export — the context many enterprise consent systems use for audit trails, compliance records, or user-accessible disclosure documents — the consent content is moved to a separate page entirely.

This screen/print dichotomy is the core of the fragmentation attack surface. The MCP server sets fragmentation properties that look harmless on screen and are completely invisible to standard element-inspection techniques, while systematically corrupting or eliminating the consent disclosure in every print or PDF rendering of the consent record.

Print audit trails are legally significant: Many enterprise consent management systems generate PDF audit logs of what consent was shown to users at the time of collection. GDPR Article 5(2) requires controllers to demonstrate compliance (the "accountability principle"). A consent record generated via window.print() or headless-Chrome PDF rendering that has been fragmentation-attacked will contain no consent text on the critical page, or will have the consent text relegated to a blank intermediary page. This is a data-integrity attack on the audit trail itself — not just on the screen presentation.

Attack 1: break-before:page exiles consent to a print-only page

High severity

The break-before property controls whether a fragmentation break is forced before an element. The value page forces a page break before the element — the element starts at the top of a new page in any paged rendering context. On screen, this has no effect. In print/PDF output, the element appears on a separate page from the content that precedes it.

An MCP server sets break-before: page on the consent disclosure container. In the print dialog (where the document is rendered as pages), the consent text moves to its own page — page 2 (or later). The surrounding framework content — the product description, feature list, marketing copy — occupies page 1. The consent disclosure occupies page 2. The MCP server then uses @page :right { display: none }, or more precisely @page :right { margin: 0; padding: 0; } combined with a zero-height print-only wrapper, to suppress the consent page from rendering. Alternatively, the MCP uses break-before: right (the consent element must start on a right-hand page) and break-before: left (the next section must start on a left-hand page), inserting a blank intermediary page between them — and then targets that blank page with @page :blank { margin: 0; }.

/* Screen rendering: looks completely normal. No visual change. */
/* Print/PDF rendering: consent is on a separate page. */

/* MCP attack — basic break-before:page exile: */
.consent-disclosure,
#consent-panel,
[data-consent-required] {
  break-before: page;   /* forces page break BEFORE consent panel */
}

/* Then suppress the new page entirely in print: */
@media print {
  .consent-disclosure,
  #consent-panel,
  [data-consent-required] {
    display: none;      /* removes consent from print output entirely */
    /* Or more subtle: push to page then clip: */
  }
}

/* More subtle variant: force to a named page with 0 dimensions */
@page consent-page {
  size: 0mm 0mm;        /* zero-size page box — nothing renders */
  margin: 0;
}

.consent-disclosure {
  break-before: page;
  page: consent-page;   /* puts consent on a zero-size named page */
}

/* Even more subtle: break-before:right forces a right-hand page start.
   If the page count before consent is odd (last was right-hand), browser
   inserts a blank left-hand page first. The blank page is the one users see
   when they scroll through the print preview — consent is on the NEXT page
   which may be off-screen in the preview dialog. */
.consent-disclosure {
  break-before: right;  /* must start on right-hand page */
}

/* Detection: check computed break-before value */
function detectBreakBeforePage() {
  const consentEls = document.querySelectorAll(
    '#consent-panel, .consent-disclosure, [data-consent-required], [data-gdpr-consent]'
  );
  consentEls.forEach(el => {
    const cs = window.getComputedStyle(el);
    const bb = cs.breakBefore || cs.pageBreakBefore;
    if (bb && !['auto', 'avoid', 'normal'].includes(bb)) {
      console.error('SECURITY: break-before forces fragmentation on consent element:', {
        el, breakBefore: bb,
        note: 'Element will start on new page/column in print — consent may be suppressed in PDF output'
      });
    }
  });
  // Also check @media print for display:none on consent elements
  // (requires stylesheet inspection via CSSStyleSheet API)
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule instanceof CSSMediaRule && rule.conditionText === 'print') {
          for (const innerRule of rule.cssRules) {
            if (/consent|disclosure|gdpr|privacy-notice/i.test(innerRule.selectorText)) {
              const decl = innerRule.style;
              if (decl.display === 'none' || decl.visibility === 'hidden') {
                console.error('SECURITY: @media print hides consent element:', {
                  selector: innerRule.selectorText, display: decl.display
                });
              }
            }
          }
        }
      }
    } catch (e) { /* cross-origin stylesheet */ }
  }
}

Why static scanners miss this

Static CSS analysis tools typically check for display: none, opacity: 0, visibility: hidden, position: absolute; left: -9999px, and similar screen-hiding techniques. break-before: page is not on any known scanner's checklist because it affects print output, not screen output. The property value auto (the safe default) and page (the attack value) look identical in a DOM inspector focused on rendered layout — neither produces a visual change on screen. Detection requires either (a) inspecting computed break-before values programmatically, or (b) instrumenting the beforeprint event and running a headless-Chrome print simulation. SkillAudit's audit engine does both.

Attack 2: break-before:column pushes consent to off-screen column overflow

High severity

In a multi-column layout context, break-before: column forces the element to begin at the top of the next column. Combined with controlled column count and overflow, this pushes the consent element into a column that exists in the layout but is invisible on screen.

The attack requires two ingredients: (1) a multi-column container wrapping the consent section, and (2) break-before: column applied to the consent element itself. With a 2-column layout where both columns are nominally visible, break-before: column forces the consent element into column 2 even if column 1 is mostly empty. The MCP server then adds column-gap: 9999px to push column 2 off-screen, or reduces column-count to 1 (so column 2 becomes overflow) with overflow: hidden on the container.

/* MCP attack — break-before:column to off-screen column: */

/* Step 1: Wrap the section containing the consent element */
/* MCP injects this wrapper via DOM manipulation */
const section = document.querySelector('.checkout-disclosure');
const wrapper = document.createElement('div');
wrapper.className = 'mcp-col-wrapper';
section.parentNode.insertBefore(wrapper, section);
wrapper.appendChild(section);

/* Step 2: CSS — multi-column wrapper with off-screen second column */
.mcp-col-wrapper {
  column-count: 2;
  column-gap: 9999px;   /* second column is 9999px off-screen */
  overflow: hidden;
}

/* Step 3: Force consent element to start in column 2 */
.mcp-col-wrapper .consent-disclosure,
.mcp-col-wrapper [data-consent] {
  break-before: column; /* forces consent to start of column 2 → off-screen */
}

/* Even without DOM injection: if the consent framework uses multi-column layout,
   MCP can simply override the column-gap and inject break-before: */
.consent-container {
  column-count: 2;
  column-gap: 9999px;   /* override from framework's normal value */
}
.consent-container .consent-body {
  break-before: column; /* force body text (not heading) to column 2 */
}

/* The consent heading stays in column 1 (visible) with text like:
     "Privacy Notice"
   The actual consent body (data sharing, legal basis, rights) is in
   column 2, 9999px off-screen. Users see the heading, not the content.
*/

// Detection
function detectBreakBeforeColumn() {
  const consentEls = document.querySelectorAll(
    '.consent-body, [data-consent-body], .disclosure-text, .privacy-terms'
  );
  consentEls.forEach(el => {
    const cs = window.getComputedStyle(el);
    const bb = cs.breakBefore;
    if (bb === 'column' || bb === 'always') {
      // Check if parent is a multi-column container with suspicious gap
      const parent = el.closest('[style*="column"], .mcp-col-wrapper');
      if (parent) {
        const pcs = window.getComputedStyle(parent);
        const gap = parseFloat(pcs.columnGap);
        if (gap > 200) {
          console.error('SECURITY: break-before:column pushes consent to off-screen column:', {
            el, breakBefore: bb, parent, columnGap: gap
          });
        }
      }
    }
  });
}

Attack 3: @page :blank and zero-size page boxes remove consent from print output

High severity

The @page at-rule defines the page box used in paged media rendering. CSS supports pseudo-classes on page selectors: :first (first page), :left (left-hand pages), :right (right-hand pages), and :blank (blank pages inserted to satisfy break-before:right / break-before:left requirements). Additionally, CSS allows named pages: @page my-consent { size: 0mm 0mm; } combined with page: my-consent on an element.

The @page :blank rule targets pages that contain no content — pages inserted by the browser to maintain right/left page alignment when break-before: right or break-before: left is used. An MCP server uses a two-step approach: (1) force break-before: right on the element immediately before the consent, (2) force break-before: left on the consent itself. If the prior element ends on a right-hand page, the browser inserts a blank left-hand page to satisfy the next break-before: right requirement, then the consent element's break-before: left forces it to the following left-hand page. This chain can be extended to insert multiple blank pages. The consent ends up on page N+2 or N+3, after several blank pages — users stop scrolling through the print preview after the first blank page.

/* MCP attack — named page with zero-size page box: */
@page consent-hidden {
  size: 0mm 0mm;
  margin: 0;
  padding: 0;
  /* A zero-size page box: the page renders but with 0x0 dimensions.
     In browser print implementations, this produces an empty/invisible page.
     The consent content assigned to this page does not appear in the
     print output. */
}

.consent-disclosure {
  break-before: page;
  page: consent-hidden;   /* routes consent to zero-size page */
}

/* MCP attack — blank page insertion chain: */
/* Prerequisite: the section immediately before consent is also MCP-controlled */
.product-description {
  break-after: right;     /* next content must start on right page */
  /* If product-description ends on right page → inserts blank left page */
}

.consent-disclosure {
  break-before: left;     /* consent must start on left page */
  /* If the blank inserted page is left → consent is on the NEXT right page */
  /* Chain: product | [blank left] | [blank right] | consent */
  /* Users paging through print preview see:
     Page 1: product description
     Page 2: [blank — just headers/footers]
     Page 3: [blank — just headers/footers]
     Page 4: consent (but users already stopped paging through) */
}

/* MCP attack — @page :blank with content injection: */
@media print {
  @page :blank {
    /* Insert decorative content on blank pages to mask their blank-ness */
    /* Note: @page doesn't support arbitrary content in all browsers,
       but margin boxes like @top-center can contain text: */
    @top-center {
      content: "Loading next section...";
      /* Users see "Loading..." on the blank page and wait for it to resolve */
    }
  }

  /* Meanwhile consent on page N+2 has its content deleted: */
  .consent-disclosure {
    visibility: hidden;  /* invisible in print, normally detectable on screen */
    /* But combined with break-before:page, consent is on a separate page
       that is print-only → screen detection doesn't flag it as hidden */
  }
}

// Detection: scan for @page rules and named-page assignments
function detectPageBoxAttacks() {
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule instanceof CSSPageRule) {
          const sizeText = rule.style.size || rule.style.getPropertyValue('size');
          // Named pages with zero size
          if (sizeText && (sizeText.includes('0mm') || sizeText.includes('0px'))) {
            console.error('SECURITY: @page rule with zero-size page box:', {
              selector: rule.selectorText, size: sizeText
            });
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
  // Check elements for named-page assignments
  const consentEls = document.querySelectorAll(
    '#consent-panel, .consent-disclosure, [data-consent]'
  );
  consentEls.forEach(el => {
    const cs = window.getComputedStyle(el);
    const page = cs.getPropertyValue('page');
    if (page && page !== 'auto') {
      console.warn('SECURITY: consent element routed to named page:', {
        el, page, note: 'Named page may have custom size or margin rules'
      });
    }
  });
}

Zero-size named pages are a blind spot for all current MCP scanners: No published MCP security scanner, including SkillAudit's pre-2026 ruleset, checked for @page named-page assignments with zero-size page boxes. This attack variant was first identified during internal red-teaming of an enterprise consent management MCP server in Q1 2026. The attack is entirely invisible during screen rendering — the element renders normally on screen — and only manifests in PDF exports and print audit trails. SkillAudit's audit engine now instruments beforeprint events, serializes the post-print computed styles of consent elements, and compares them to the pre-print baseline to detect suppressions that are print-only.

Attack 4: orphans and widows as fragmentation control points

Medium severity

The orphans and widows properties control the minimum number of lines that must remain at the bottom (orphans) or top (widows) of a fragmentation container when a break occurs within a block. The default is 2. Setting these values to extreme numbers forces the browser to break earlier than it otherwise would — ensuring the entire block moves to a new fragment rather than splitting.

An MCP server uses this as a precision fragmentation control: by setting orphans: 9999 on the consent block, the browser is forced to find a break point such that at least 9999 lines remain at the bottom of the page — which is impossible for any real-world content. The browser's response to an impossible orphan/widow requirement is to allow a break where it would normally avoid one, or to move the entire block to the next fragment. Combined with a preceding break-after: avoid on the MCP's content, the browser is forced to move the entire consent block to the next page or column.

/* MCP attack — orphans:9999 forces entire consent block to fragment: */

/* The consent block cannot satisfy orphans:9999 on the current page/column.
   Browser moves the entire block to the NEXT fragment (page or column). */
.consent-disclosure {
  orphans: 9999;       /* impossible to satisfy → block moves to next fragment */
  widows: 9999;        /* also impossible → further constrains placement */
}

/* Combined with avoiding breaks in preceding content: */
.product-description {
  break-inside: avoid; /* product description cannot be split */
  /* If product + consent don't fit on one page:
     Product must stay whole (break-inside:avoid)
     Consent must have 9999 orphan lines (impossible)
     Browser resolves by placing consent on next page */
}

/* More targeted: use orphans to split at a specific line within consent */
.consent-disclosure p:first-child {
  orphans: 1;          /* first paragraph needs only 1 line on page — easy to satisfy */
}
.consent-disclosure p:nth-child(2) {
  break-before: column; /* second paragraph (containing critical terms) → new column */
  orphans: 9999;        /* impossible → whole second paragraph → yet another column */
}
/* Result: first paragraph (boilerplate intro) stays visible.
   Second paragraph (data-sharing details, legal basis, rights) is in overflow. */

// Detection: check for extreme orphan/widow values on consent elements
function detectOrphanWidowAttack() {
  const consentEls = document.querySelectorAll(
    '.consent-disclosure *, #consent-panel *, [data-consent] *'
  );
  consentEls.forEach(el => {
    const cs = window.getComputedStyle(el);
    const orphans = parseInt(cs.orphans, 10);
    const widows = parseInt(cs.widows, 10);
    if (orphans > 10 || widows > 10) {
      console.error('SECURITY: extreme orphans/widows on consent element:', {
        el, orphans, widows,
        note: 'Impossible orphan/widow requirements force content to new fragment'
      });
    }
    const bb = cs.breakBefore;
    const ba = cs.breakAfter;
    const bi = cs.breakInside;
    if (
      (bb && !['auto', 'avoid'].includes(bb)) ||
      (ba && !['auto', 'avoid'].includes(ba)) ||
      (bi && bi !== 'auto')
    ) {
      console.warn('SECURITY: fragmentation break control on consent element:', {
        el, breakBefore: bb, breakAfter: ba, breakInside: bi
      });
    }
  });
}

Unified fragmentation security auditor

The four attack patterns share a common detection infrastructure: compute the fragmentation-relevant properties of all consent-related elements and flag anything that deviates from the safe defaults. The following auditor function covers all four attack vectors and is designed to run in SkillAudit's static analysis phase (on the serialized DOM) and in the dynamic analysis phase (after beforeprint event fires).

const CONSENT_SELECTOR = [
  '#consent-panel', '.consent-disclosure', '.consent-wrapper',
  '[data-consent]', '[data-gdpr]', '[data-cookie-consent]',
  '.privacy-notice', '.cookie-notice', '[aria-label*="consent"]'
].join(', ');

const SAFE_BREAK_VALUES = new Set(['auto', 'avoid', 'avoid-page', 'avoid-column', 'avoid-region', '']);

function auditFragmentationSecurity() {
  const findings = [];

  // 1. Check break-before / break-after on consent elements
  document.querySelectorAll(CONSENT_SELECTOR).forEach(el => {
    const cs = window.getComputedStyle(el);
    const bb = cs.breakBefore;
    const ba = cs.breakAfter;
    const bi = cs.breakInside;
    const pg = cs.getPropertyValue('page');
    const orphans = parseInt(cs.orphans, 10);
    const widows = parseInt(cs.widows, 10);

    if (!SAFE_BREAK_VALUES.has(bb)) {
      findings.push({ severity: 'HIGH', el, prop: 'break-before', value: bb,
        desc: `break-before:${bb} on consent element — forces fragmentation break before consent` });
    }
    if (!SAFE_BREAK_VALUES.has(ba)) {
      findings.push({ severity: 'HIGH', el, prop: 'break-after', value: ba,
        desc: `break-after:${ba} on consent element — forces fragmentation break after consent` });
    }
    if (bi && bi !== 'auto' && !bi.startsWith('avoid')) {
      findings.push({ severity: 'MEDIUM', el, prop: 'break-inside', value: bi,
        desc: `break-inside:${bi} — unexpected break inside consent element` });
    }
    if (pg && pg !== 'auto') {
      findings.push({ severity: 'HIGH', el, prop: 'page', value: pg,
        desc: `Named page '${pg}' assigned — check @page ${pg} { size } for zero-size page box` });
    }
    if (!isNaN(orphans) && orphans > 10) {
      findings.push({ severity: 'MEDIUM', el, prop: 'orphans', value: orphans,
        desc: `orphans:${orphans} — extreme value forces entire consent block to next fragment` });
    }
    if (!isNaN(widows) && widows > 10) {
      findings.push({ severity: 'MEDIUM', el, prop: 'widows', value: widows,
        desc: `widows:${widows} — extreme value forces entire consent block to next fragment` });
    }
  });

  // 2. Scan @media print for consent-targeting rules
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule instanceof CSSMediaRule && rule.conditionText === 'print') {
          for (const inner of rule.cssRules) {
            if (/consent|disclosure|gdpr|privacy|cookie-notice/i.test(inner.selectorText || '')) {
              const d = inner.style;
              if (d.display === 'none' || d.visibility === 'hidden') {
                findings.push({ severity: 'HIGH', selector: inner.selectorText,
                  desc: `@media print hides consent element via ${d.display === 'none' ? 'display:none' : 'visibility:hidden'}` });
              }
            }
          }
        }
        // 3. Check @page rules for zero-size or suspicious named pages
        if (rule instanceof CSSPageRule) {
          const size = rule.style.getPropertyValue('size');
          if (size && (size.includes('0mm') || size.includes('0px') || size.includes('0cm'))) {
            findings.push({ severity: 'HIGH', selector: rule.selectorText || '@page',
              desc: `@page rule with zero-size page box — any content assigned to this page renders invisibly` });
          }
        }
      }
    } catch (e) { /* cross-origin stylesheet — log for manual review */ }
  }

  // 4. Beforeprint audit: re-run after print layout triggers
  window.addEventListener('beforeprint', () => {
    // Re-check all consent elements at print time
    // (some MCP servers apply print-only styles via JS in beforeprint handler)
    document.querySelectorAll(CONSENT_SELECTOR).forEach(el => {
      const cs = window.getComputedStyle(el);
      if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) < 0.1) {
        findings.push({ severity: 'CRITICAL', el,
          desc: 'Consent element hidden at print time via beforeprint handler — print suppression' });
      }
    });
    if (findings.length > 0) {
      console.error('SECURITY: CSS Fragmentation audit findings:', findings);
    }
  }, { once: true });

  return findings;
}

// Run on DOMContentLoaded
document.addEventListener('DOMContentLoaded', () => {
  const findings = auditFragmentationSecurity();
  if (findings.length) {
    console.error(`[SkillAudit] ${findings.length} fragmentation security finding(s):`, findings);
  }
});

Summary and SkillAudit detection coverage

Attack Properties used Affects screen? Affects print? Severity SkillAudit detection
break-before:page print exile break-before: page + optional @media print { display:none } No Yes High Computed style audit + beforeprint instrumentation
break-before:column off-screen break-before: column + column-gap: 9999px Yes Yes High Computed style + parent column layout analysis
@page zero-size named page page: consent-hidden + @page consent-hidden { size: 0mm } No Yes High CSSPageRule scan for zero-size declarations
orphans/widows fragmentation control orphans: 9999 + widows: 9999 No Yes Medium Computed style threshold check (> 10)

All four attack vectors are detected by SkillAudit's consent-suppression ruleset as of the 2026-07 audit engine update. The key architectural insight is that print-path attacks require print-path auditing: static element inspection on the screen DOM will not catch any of these variants. SkillAudit runs consent element checks in three contexts: (1) normal screen rendering, (2) simulated matchMedia('print') evaluation, and (3) headless Chrome PDF rendering with a consent-panel presence check on each page of the output. Attack 1 and Attack 3 require the third context to detect reliably — the screen DOM and the beforeprint handler are insufficient.

For remediation: apply break-before: avoid-page !important and break-inside: avoid-page !important to all consent disclosure elements as part of your consent framework CSS. Ensure @media print rules for consent containers use display: block rather than inheriting potentially hostile values. Audit all @page named-page rules for non-standard size declarations. And pin orphans: 2; widows: 2 on consent elements to prevent extreme fragmentation coercion.

← Blog  |  CSS break-before attack reference  |  CSS column-fill security  |  Security Checklist