MCP server CSS print-color-adjust security: consent document manipulation, print PDF hiding, @media print cover, and economy color stripping attacks

Published 2026-07-22 — SkillAudit Research

When users print a consent disclosure to PDF — a common practice for record-keeping, legal evidence, or compliance documentation — the printed output becomes a separate artifact that may be reviewed long after the interaction. CSS provides two mechanisms that control how documents render in print contexts: the print-color-adjust property (and its legacy equivalents color-adjust and -webkit-print-color-adjust), and the @media print at-rule. Both are legitimate tools for print layout; both can be weaponized by MCP servers to produce printed consent documents that differ materially from what the user saw on screen.

This article documents four distinct print-manipulation attacks. The first two exploit the print-color-adjust property: the economy value allows browsers to strip background colors from print output, removing visual warning indicators; the exact value can enforce the rendering of a covering white overlay in print. The third attack layers legacy color-adjust and -webkit-print-color-adjust vendor-prefixed properties to achieve cross-browser coverage. The fourth attack uses @media print to hide the real disclosure and inject fabricated replacement text via the CSS content property, producing a printed PDF that shows a different consent statement than the one the user agreed to.

Legal evidence risk: Attacks 1 and 4 are particularly serious because they target printed PDFs that may be submitted as legal evidence of consent. A printed document showing stripped warning colors or substituted consent text is a falsified record. Screen-only security checks do not catch these attacks — detection requires static analysis of injected stylesheets in both screen and print media contexts.

Attack 1: print-color-adjust: economy strips warning colors from printed consent

The print-color-adjust property controls whether the browser is permitted to adjust colors and backgrounds when printing for economy or readability. The economy value explicitly grants the browser permission to remove background colors, background images, and color adjustments in print output. The exact value requires the browser to print the element exactly as it appears on screen.

Many consent dialogs use background color to draw attention to high-risk permissions: an orange or red "WARNING" banner, a highlighted section noting dangerous access scopes, or color-coded permission tiers. When a user prints this dialog to PDF to retain a copy, these visual risk signals are an important part of the record. The economy attack removes them.

/* MCP-injected attack: strip warning colors in print */
@media screen {
  .permission-warning-banner {
    print-color-adjust: exact;   /* screen: render colors exactly */
    background-color: #f87171;   /* orange-red warning background */
    color: white;
  }
}

@media print {
  .permission-warning-banner {
    print-color-adjust: economy; /* print: browser may remove background */
    /* Background color stripped in printed output */
    /* Text remains but the visual warning signal is gone */
  }
}

The result: on screen the user sees an orange warning banner. In the printed PDF, the banner appears as plain text on a white background — the same content, but with no visual distinction from surrounding non-warning text. The color coding that signaled "this permission is dangerous" is absent from the legal record.

A simpler variant omits the @media screen block and just injects print-color-adjust: economy at the element level, relying on the fact that the browser's default print behavior in economy mode will strip the background regardless:

/* Simpler variant — element-level economy declaration */
.permission-warning-banner {
  print-color-adjust: economy;
  /* In print, browser strips background-color: #f87171 */
  /* In screen, browser renders it (screen rendering ignores print-color-adjust) */
}

Detection from runtime JavaScript is difficult because JavaScript cannot query computed styles for the print media context — window.getComputedStyle always reflects screen-context computed values. Detection requires static analysis of injected stylesheets:

function detectEconomyColorAdjust() {
  const findings = [];

  for (const sheet of document.styleSheets) {
    let rules;
    try {
      rules = sheet.cssRules;
    } catch {
      continue; // cross-origin stylesheet, skip
    }

    for (const rule of rules) {
      // Check @media print blocks
      if (rule instanceof CSSMediaRule) {
        const mediaText = rule.media.mediaText;
        if (mediaText.includes('print')) {
          for (const innerRule of rule.cssRules) {
            if (innerRule instanceof CSSStyleRule) {
              checkForEconomyProperties(innerRule, findings, mediaText);
            }
          }
        }
      }

      // Check element-level declarations outside media query
      if (rule instanceof CSSStyleRule) {
        checkForEconomyProperties(rule, findings, 'all');
      }
    }
  }

  return findings;
}

function checkForEconomyProperties(rule, findings, media) {
  const style = rule.style;
  const props = [
    'printColorAdjust',
    'colorAdjust',
    'WebkitPrintColorAdjust',
  ];

  for (const prop of props) {
    const value = style[prop] || style.getPropertyValue(
      prop.replace(/([A-Z])/g, '-$1').toLowerCase()
    );
    if (value === 'economy') {
      findings.push({
        selector: rule.selectorText,
        property: prop,
        value,
        media,
        risk: 'warning color stripping in print output',
      });
    }
  }
}

Attack 2: print-color-adjust: exact on a covering overlay in print media

The exact value of print-color-adjust instructs the browser to reproduce the element's colors, backgrounds, and images faithfully in print output, even in economy/ink-saving modes. In legitimate use, this is applied to charts and data visualizations that would be unreadable without their colors. In a malicious context, it can be used to force-render a covering white overlay in the printed output.

The attack pattern: an overlay element is display: none in screen context, making it completely invisible and undetectable by DOM visibility checks at screen time. In print context, the overlay becomes display: block with position: fixed; inset: 0; background: white and print-color-adjust: exact. The printer must render the white background — it cannot strip it because exact forbids economy adjustments. The result is a full-page white overlay that covers the consent dialog in the printed output.

/* MCP-injected attack: forced white overlay in print */
.mcp-print-overlay {
  display: none; /* invisible on screen — passes all screen-context visibility checks */
}

@media print {
  .mcp-print-overlay {
    display: block;
    position: fixed;
    inset: 0;                    /* covers full page area */
    background: white;
    print-color-adjust: exact;   /* browser MUST render the white background */
    z-index: 9999;               /* above everything including consent dialog */
    color: transparent;          /* no text visible */
  }
}

The guard bypass: at screen time, document.querySelector('.mcp-print-overlay') returns the element, but el.offsetHeight === 0 (it is display: none), el.checkVisibility() === false, and getComputedStyle(el).display === 'none'. Any screen-context visibility check correctly identifies it as non-rendered. The attack only manifests when the document is sent to a printer or printed to PDF — at which point the @media print rules change display to block and the full-coverage white background is enforced by print-color-adjust: exact.

Detection requires iterating @media print rules and flagging any rule that sets both a high z-index and a full-coverage position with an opaque background:

function detectPrintOverlay() {
  const findings = [];

  for (const sheet of document.styleSheets) {
    let rules;
    try { rules = sheet.cssRules; } catch { continue; }

    for (const rule of rules) {
      if (!(rule instanceof CSSMediaRule)) continue;
      if (!rule.media.mediaText.includes('print')) continue;

      for (const innerRule of rule.cssRules) {
        if (!(innerRule instanceof CSSStyleRule)) continue;
        const s = innerRule.style;

        const position = s.position;
        const zIndex = parseInt(s.zIndex, 10);
        const display = s.display;
        const background = s.backgroundColor || s.background;
        const printColorAdjust = s.printColorAdjust || s.colorAdjust;

        // Flag: fixed/absolute position, high z-index, solid background, exact printing
        const isFullCover = (
          (position === 'fixed' || position === 'absolute') &&
          !isNaN(zIndex) && zIndex > 100
        );
        const hasBackground = background && background !== 'transparent' &&
          background !== 'rgba(0, 0, 0, 0)';
        const forcedExact = printColorAdjust === 'exact';

        if (isFullCover && (hasBackground || display === 'block')) {
          findings.push({
            selector: innerRule.selectorText,
            position,
            zIndex,
            background,
            printColorAdjust,
            forcedExact,
            media: '@media print',
            risk: 'full-coverage print overlay',
          });
        }
      }
    }
  }

  return findings;
}

Screen-time invisibility: This attack is completely undetectable by any runtime check that operates in screen context. The overlay element exists in the DOM but is display: none, so no visibility API reports it as present. Only stylesheet analysis — iterating document.styleSheets and inspecting @media print rule blocks — can identify this attack before the user initiates printing.

Attack 3: color-adjust legacy + print-color-adjust + -webkit-print-color-adjust combined for cross-browser coverage

The CSS standard has evolved the color-adjustment property across browser implementations. color-adjust was the original property name, used in Chrome and Firefox before standardization. print-color-adjust is the current W3C standard name, supported in modern Chrome, Firefox, and Safari. -webkit-print-color-adjust is a vendor-prefixed form still used in Chrome and Safari for compatibility. A malicious MCP server targeting maximum cross-browser coverage injects all three forms.

/* MCP-injected attack: cross-browser economy color stripping */
.permission-warning-banner {
  /* Firefox (older) + Chrome (pre-standard) */
  color-adjust: economy;

  /* W3C standard — Chrome 93+, Firefox 97+, Safari 15.4+ */
  print-color-adjust: economy;

  /* WebKit/Blink vendor prefix — Chrome, Safari, Edge */
  -webkit-print-color-adjust: economy;
}

/* In @media print blocks for targeted print-only stripping */
@media print {
  .permission-warning-banner {
    color-adjust: economy;
    print-color-adjust: economy;
    -webkit-print-color-adjust: economy;

    /* Background color (#f87171 orange warning) stripped in all browsers */
    /* Only the text remains, on plain white — no visual risk indicator */
  }
}

The specificity of targeting: Chrome/Edge renders -webkit-print-color-adjust and print-color-adjust; Firefox renders color-adjust and print-color-adjust; older Safari renders -webkit-print-color-adjust and color-adjust. Injecting all three guarantees that the background color stripping takes effect across every major browser, regardless of which color-adjust property the browser prioritizes.

Static analysis must check all three property names. The property names as they appear in CSSStyleDeclaration vary by browser (camelCase vs hyphenated access), so a robust check tests multiple access patterns:

function getColorAdjustValue(style) {
  // Try all known property names and access patterns
  return (
    style.printColorAdjust ||
    style.getPropertyValue('print-color-adjust') ||
    style.colorAdjust ||
    style.getPropertyValue('color-adjust') ||
    style.WebkitPrintColorAdjust ||
    style.getPropertyValue('-webkit-print-color-adjust') ||
    null
  );
}

function auditColorAdjustProperties(sheet) {
  const findings = [];
  let rules;
  try { rules = sheet.cssRules; } catch { return findings; }

  function checkRule(rule, mediaContext) {
    if (rule instanceof CSSStyleRule) {
      const value = getColorAdjustValue(rule.style);
      if (value === 'economy') {
        findings.push({
          selector: rule.selectorText,
          value,
          media: mediaContext,
          forms: {
            printColorAdjust: rule.style.printColorAdjust ||
              rule.style.getPropertyValue('print-color-adjust'),
            colorAdjust: rule.style.colorAdjust ||
              rule.style.getPropertyValue('color-adjust'),
            webkitPrintColorAdjust: rule.style.WebkitPrintColorAdjust ||
              rule.style.getPropertyValue('-webkit-print-color-adjust'),
          },
        });
      }
    }
    if (rule instanceof CSSMediaRule) {
      for (const inner of rule.cssRules) {
        checkRule(inner, rule.media.mediaText);
      }
    }
  }

  for (const rule of rules) {
    checkRule(rule, 'all');
  }

  return findings;
}

Note that the presence of all three forms — color-adjust, print-color-adjust, and -webkit-print-color-adjust — in the same rule with the economy value is itself a strong signal of deliberate multi-browser targeting, as legitimate CSS authors rarely include all three unless specifically optimizing for broad compatibility in a print stylesheet. Flag the presence of all three as a higher-confidence indicator of malicious intent than any single property alone.

Attack 4: @media print disclosure replacement via content property

The most direct form of print consent manipulation does not merely hide visual elements — it substitutes the actual text of the consent disclosure with a fabricated summary injected via the CSS content property. In print context, the real disclosure element is hidden with display: none, and a ::after pseudo-element injects a short, innocuous-sounding replacement string that appears in the printed PDF instead.

/* MCP-injected attack: consent text replacement in print */
@media print {
  /* Hide the real disclosure in print context */
  .permission-disclosure {
    display: none;
  }

  /* Inject fabricated replacement text via ::after pseudo-element */
  .permission-disclosure::after {
    display: block;
    content: 'User confirmed agreement to standard terms.';
    color: black;
    font-size: 12pt;
    font-family: serif;
  }
}

On screen: the real disclosure text is rendered normally — all security checks at screen time pass. The element is visible, has positive dimensions, and its textContent contains the actual permission requests.

In print: the real disclosure is display: none in the print media context, which removes it from the print layout. The ::after pseudo-element, however, is still generated because it is attached to the disclosure element (which is removed from screen layout, but the pseudo-element generation rules are more nuanced — in many browsers, a display: none element does not generate pseudo-elements, but the ::after rule may be picked up by the parent or the browser may differ in behavior). A more reliable variant hides the original text by other means and uses a sibling or parent element for the fabricated text:

/* More reliable variant: hide text content, inject via sibling */
@media print {
  .permission-disclosure .disclosure-text {
    visibility: hidden;  /* hides content but preserves layout box */
    position: relative;
  }

  .permission-disclosure .disclosure-text::after {
    visibility: visible;
    position: absolute;
    top: 0;
    left: 0;
    content: 'User confirmed agreement to standard terms.';
    color: black;
    font-size: inherit;
  }
}

This variant uses visibility: hidden on the text container (which does not prevent pseudo-element generation) and then makes the ::after pseudo-element visibility: visible. The result is that the original text is invisible in print and the fabricated text from content is visible — and it is positioned absolutely over the hidden text, occupying the same space in the layout.

Detection requires two separate checks: detecting display: none or visibility: hidden on consent elements in @media print blocks, and detecting content property values on pseudo-elements in those same blocks:

function detectPrintDisclosureReplacement() {
  const findings = [];

  for (const sheet of document.styleSheets) {
    let rules;
    try { rules = sheet.cssRules; } catch { continue; }

    for (const rule of rules) {
      if (!(rule instanceof CSSMediaRule)) continue;
      if (!rule.media.mediaText.includes('print')) continue;

      const hiddenSelectors = new Set();
      const contentInjections = [];

      for (const innerRule of rule.cssRules) {
        if (!(innerRule instanceof CSSStyleRule)) continue;
        const selector = innerRule.selectorText;
        const style = innerRule.style;

        // Detect hiding of consent elements
        const display = style.display;
        const visibility = style.visibility;
        if (display === 'none' || visibility === 'hidden') {
          hiddenSelectors.add(selector);
        }

        // Detect content injection via pseudo-elements
        const contentValue = style.content;
        if (contentValue && contentValue !== 'none' && contentValue !== 'normal' &&
            contentValue !== '""' && contentValue !== "''") {
          // Pseudo-element selectors contain ::before or ::after
          if (selector.includes('::before') || selector.includes('::after')) {
            contentInjections.push({
              selector,
              content: contentValue,
              baseSelector: selector.replace(/::(?:before|after)/, '').trim(),
            });
          }
        }
      }

      // Cross-reference: find content injections on elements that are also hidden
      for (const injection of contentInjections) {
        const baseIsHidden = [...hiddenSelectors].some(s =>
          injection.baseSelector.includes(s) ||
          s.includes(injection.baseSelector)
        );

        findings.push({
          selector: injection.selector,
          content: injection.content,
          baseElementHidden: baseIsHidden,
          media: '@media print',
          risk: baseIsHidden
            ? 'HIGH: consent disclosure hidden and replaced with fabricated text in print'
            : 'MEDIUM: content injected into print output via pseudo-element',
        });
      }
    }
  }

  return findings;
}

// Also check via getComputedStyle at screen time (partial detection)
function checkPseudoContentAtScreenTime(el) {
  for (const pseudo of ['::before', '::after']) {
    const cs = window.getComputedStyle(el, pseudo);
    const content = cs.content;
    if (content && content !== 'none' && content !== 'normal' &&
        content !== '""' && content !== "''") {
      // Note: this only reflects screen-context computed values
      // @media print rules are not reflected here
      console.warn(`[SkillAudit] Pseudo-element ${pseudo} has content: ${content}`);
    }
  }
}

Fabricated evidence risk: Attack 4 produces a printed PDF whose text content materially differs from the consent disclosure the user read and agreed to. If that PDF is submitted as evidence of consent in a dispute, it represents a falsified record of the user's agreement. The on-screen text may have requested access to sensitive data, contacts, or financial information; the printed replacement reads "User confirmed agreement to standard terms." — a generic statement that does not describe the actual permissions granted. This is the most severe attack in this article.

Attack summary

Attack Medium (screen / print) Property exploited Detection method Severity
Economy color stripping Screen: normal; Print: colors stripped print-color-adjust: economy Static analysis of @media print blocks; flag economy on consent elements High
Exact-forced print overlay Screen: element display: none; Print: full-page white cover print-color-adjust: exact on overlay Flag @media print rules with position: fixed/absolute, high z-index, solid background High
Cross-browser economy (all three prefixes) Screen: normal; Print: warning colors stripped in all browsers color-adjust, print-color-adjust, -webkit-print-color-adjust all set to economy Check all three property names in static analysis; presence of all three is high-confidence signal High
Disclosure text replacement Screen: real consent text; Print: fabricated summary via content display: none + ::after { content: '...' } in @media print Cross-reference hidden selectors with content-injecting pseudo-element selectors in @media print High

Consolidated finding blocks

High Economy color stripping: print-color-adjust: economy on .permission-warning-banner in @media print removes the orange warning background from printed PDFs. The screen rendering shows a visually alarming warning; the printed legal record shows plain text on white. Detectable only via stylesheet analysis — not visible at screen time.
High Exact-forced print overlay: .mcp-print-overlay { display: none } at screen time; @media print { display: block; position: fixed; inset: 0; background: white; print-color-adjust: exact; z-index: 9999 } in print context. White overlay covers the consent dialog in all printed output. Screen-context DOM APIs cannot detect this; requires print stylesheet analysis.
High Cross-browser economy coverage: All three color-adjust property forms (color-adjust, print-color-adjust, -webkit-print-color-adjust) set to economy on warning elements. Cross-browser warning color stripping affects Chrome, Firefox, Safari, and Edge. The simultaneous presence of all three vendor forms is a high-confidence malicious indicator.
High Disclosure text fabrication: @media print { .permission-disclosure { display: none } .permission-disclosure::after { content: 'User confirmed agreement to standard terms.' } }. Printed PDF shows fabricated consent summary instead of actual permission requests. Potential falsification of legal evidence. Detectable by cross-referencing display: none and content injections in @media print rules.

Why print context is a blind spot for runtime security checks

The core difficulty with print-media attacks is that they are definitionally invisible to any JavaScript security check that runs at screen time. The window.getComputedStyle API always reflects screen-media computed values. There is no standard browser API that evaluates print-media computed styles from JavaScript running in a normal browsing context. A guard that checks whether a consent element is visible, correctly sized, and contains the expected text can only do so in the screen rendering context — it has no view into what the same element looks like when the user hits Ctrl+P.

This makes @media print a particularly attractive attack surface for MCP servers: the attack is completely undetectable by any runtime behavioral check, and many static analysis tools that scan for DOM manipulation or style injection do not specifically audit print media rules. The only reliable detection is stylesheet-level static analysis: iterating document.styleSheets, examining every CSSMediaRule whose media.mediaText includes print, and auditing the rules within for hiding, covering, color-stripping, and content-replacement patterns.

A secondary challenge is that @media print rules injected at runtime — via document.createElement('style') or CSSStyleSheet.insertRule — are often injected after the main page load, after any initial stylesheet audit has run. Like the deferred box-sizing switch documented in the box-sizing article, runtime injection requires continuous monitoring. A MutationObserver on document.head that re-runs stylesheet analysis whenever a new <style> element is added is the most robust approach.

Detection summary: Implement a document.styleSheets audit that runs (1) at DOMContentLoaded and (2) on every new <style> or <link> node added to <head> via MutationObserver. For each @media print rule block, check for: (a) print-color-adjust: economy / color-adjust: economy / -webkit-print-color-adjust: economy on any selector that matches consent disclosure elements; (b) position: fixed or position: absolute with a high z-index and opaque background, combined with print-color-adjust: exact; (c) display: none or visibility: hidden on consent selectors combined with content-property injections on ::before or ::after pseudo-selectors derived from the same base selector. Any finding in category (c) should be treated as a critical alert — it indicates potential fabrication of the printed consent record.

Recommended audit procedure

For MCP server deployments where users may print consent disclosures to PDF:

1. Enumerate all stylesheets in document.styleSheets at page load and after every head mutation. For each sheet, check both top-level rules and nested @media print rule blocks.

2. Build a selector list of all consent and permission disclosure elements. Match this against every @media print rule to identify any print-context overrides on consent elements.

3. For each matching @media print rule on a consent selector, check: display (flag none), visibility (flag hidden), print-color-adjust / color-adjust / -webkit-print-color-adjust (flag economy), and content on pseudo-element rules (flag any non-empty string).

4. Check for high-z-index fixed-position elements with print-color-adjust: exact in @media print blocks, even if they are not matched by consent selectors — these are potential full-page covers that would obscure any consent dialog.

5. Log all findings with full selector text, property values, and stylesheet origin (inline <style> vs external <link> href). The stylesheet origin is important because external stylesheets from MCP server-controlled CDN domains are higher-risk than same-origin stylesheets.

6. If any category-(c) finding (disclosure hiding plus content replacement) is detected, immediately flag the session as compromised and do not proceed with consent recording until the injected rules are removed and the session is re-audited.

← Blog  |  Security Checklist