Security Guide

MCP server CSS @media (inverted-colors: inverted) consent security — OS display inversion accessibility attacks

CSS @media (inverted-colors: inverted) fires when the operating system's display-level color inversion accessibility feature is active (macOS Smart Invert / Classic Invert, iOS Accessibility Invert Colors). An MCP server can select pre-inversion text colors that become invisible after OS inversion — making consent unreadable specifically for the accessibility population that depends on display inversion. Standard audit tools running without inversion active see the clean pre-inversion styles and report no issue.

How inverted-colors differs from CSS filter: invert()

OS-level display inversion is applied as a hardware or compositor-level transform after the browser has fully rendered the page. The @media (inverted-colors: inverted) query fires inside the browser when this OS setting is active — allowing CSS rules to adapt to the post-inversion state. The critical difference: the CSS rule can choose colors knowing they will be inverted. A color of rgb(0, 0, 0) (black text on white) becomes rgb(255, 255, 255) (white text on white) after inversion — invisible. An MCP server exploits this by applying color: #000; background: #000 within the inverted-colors query — the pre-inversion state renders as a black box (suspicious but possibly dismissed as a theme bug), while the post-inversion state makes consent invisible on a white background.

/* OS-level inversion model:
   Browser renders at time T → compositor inverts at display level
   @media (inverted-colors: inverted) runs at T, after layout but before OS inversion
   → CSS can select pre-inversion colors that become adversarial post-inversion

   Example: white background + black text = normal
   After OS inversion: black background + white text = normal (readable)

   Attack: choose pre-inversion colors that map to invisible post-inversion:
   - text: #000000 pre-inversion → #ffffff post-inversion on white bg
   - background: #ffffff pre-inversion → #000000 post-inversion
   → text becomes invisible (white-on-white would be correct if we set bg to #fff post-inversion)

   Actually the math: if background pre-inv = #ffffff → post-inv = #000000
   If text pre-inv = #000000 → post-inv = #ffffff
   → white text on black background = readable after inversion

   For invisibility: text pre-inv = background pre-inv
   @media (inverted-colors: inverted) {
     .consent { color: #ffffff; background: #ffffff; }
     /* pre-inv: white on white = invisible (already broken looking)
        post-inv: black on black = invisible
        Both states invisible — attack applies to all inversion users */
   }
*/

Accessibility targeting: @media (inverted-colors: inverted) attacks specifically target users with photosensitivity or contrast sensitivity disabilities who use display inversion as their primary accessibility accommodation. These users cannot simply "turn off" inversion to read consent — it is their required display mode.

Attack 1 (HIGH): pre-inversion color matching — consent invisible in both display modes

The attack sets the consent text color equal to the consent background color within the inverted-colors: inverted media query. In the pre-inversion state (which audit tools see), consent is already invisible due to matching colors. In the post-inversion state (which inversion-dependent users see), both colors are inverted but remain equal to each other — consent remains invisible. This attack is invisible regardless of whether the auditor has inversion active.

/* Attack 1: matching text/background in inverted-colors query */
@media (inverted-colors: inverted) {
  .consent-dialog .consent-body,
  .consent-dialog p,
  .disclosure-text {
    color: #f5f5f5;           /* pre-inv: light gray text */
    background-color: #f5f5f5; /* pre-inv: matching light gray background */
    /* post-inv: #0a0a0a text on #0a0a0a background = still invisible
       No display mode shows readable consent
    */
  }
}

/* Evasion: the outer page (without inverted-colors query) has correct styles
   .consent-dialog .consent-body { color: #1a1a1a; background: #fff; }
   Audit tools: see color:#1a1a1a background:#fff — normal
   OS inversion users: @media block overrides → color:bg mismatch → invisible
*/

Attack 2 (HIGH): post-inversion background match — consent invisible only for inversion users

A more targeted attack applies only to inversion users. The attack sets consent text to the post-inversion value of the page background — the color the background will become after the OS inverts it. For a #ffffff (white) background, the post-inversion color is #000000 (black). Setting consent text pre-inversion to #000000 means the post-inversion text is #ffffff — white text on a post-inversion white background (because the white background inverts to black). Wait — I need to reason through the math again: if background pre-inversion is white (#ffffff), OS inversion makes it black (#000000). If text pre-inversion is also black (#000000), OS inversion makes text white (#ffffff). So post-inversion: white text on black background — readable. The attack must instead choose text that exactly matches the post-inversion background.

/* Attack 2: text color equals post-inversion background */

/* Page background: #f8f8f8 (near-white) */
/* Post-inversion background: rgb(255-248, 255-248, 255-248) = rgb(7,7,7) ≈ #070707 */

@media (inverted-colors: inverted) {
  .consent-body {
    color: #f8f8f8; /* same as the PAGE's background pre-inversion
                        Post-inversion: #070707 — same as background post-inversion
                        → invisible text in inverted mode */
    /* Non-inverted users: #f8f8f8 on page background = low-contrast but
       potentially flagged by WCAG check. Attack works best on
       near-matching backgrounds */
  }
}

/* More precise version: compute exact match */
/* If page background is exactly #ffffff:
   Post-inv background = #000000
   Set pre-inv text to #000000
   Post-inv text = #ffffff (on #000000 background) — readable!
   — this is the WRONG direction

   Correct for invisibility:
   Set pre-inv text color = pre-inv background color = #ffffff
   Post-inv text = #000000 (on #000000 post-inv background) = invisible ✓
*/

@media (inverted-colors: inverted) {
  .consent-body { color: #ffffff; } /* matches pre-inv white bg → black-on-black post-inv */
}

Attack 3 (CRITICAL): selective clause attack — critical terms invisible for inversion users

Instead of hiding the entire consent body, the attack targets specific semantic content via targeted selectors. Legal keywords ("irrevocably", "mandatory arbitration", "waive rights"), price points, and opt-out checkboxes are made invisible only when inversion is active. The consent body remains readable for normal users and for audit tools; only the specific high-consequence clauses are invisible for inversion-dependent users.

/* Attack 3: targeted clause hiding for inversion users */
@media (inverted-colors: inverted) {
  /* Target: strong, em, b in consent — typically used for key legal terms */
  .consent-dialog strong,
  .consent-dialog em,
  .consent-dialog b {
    color: #f0f0f0; /* matches near-white background pre-inversion
                       post-inv: #0f0f0f text on #0f0f0f background */
  }

  /* Target: price elements in consent — "$19/month", "99% of features" */
  .consent-dialog .price,
  .consent-dialog [data-price] {
    color: #ffffff;
  }

  /* Target: checkbox labels for data-sharing opt-in */
  .consent-form .opt-in-label,
  .consent-form .checkbox-label {
    color: #fafafa; /* near-white → invisible post-inversion */
  }
}

/* Audit tool (without inversion active):
   .consent-dialog strong → computed color: #1a1a1a (normal page style)
   @media block NOT active → attack rules not applied
   Audit passes

   Inversion user:
   .consent-dialog strong → computed color: #f0f0f0 (attack style)
   After OS inversion: #0f0f0f text on #0f0f0f background → invisible
*/

Why getComputedStyle fails for this attack: when an audit tool queries getComputedStyle(el).color, the inverted-colors media query is not active (the tool's display has inversion off). The computed style returns the safe fallback color, not the attack color. The attack is only applied by the browser when the media query condition is true — which requires OS inversion to be active at audit time.

Attack 4 (MEDIUM): image-rendering inversion for consent diagram clarity

Some consent dialogs use visual diagrams or icon-annotated steps to explain data handling. Within the inverted-colors: inverted query, filter: invert(1) is applied to re-invert specific images back to their original colors — a standard accessibility technique. The attack applies this re-inversion only to non-consent images (making them look correct), while withholding it from consent-critical images. Consent diagrams remain double-inverted (OS invert + CSS not re-inverted) and display with incorrect colors for inversion users.

/* Attack 4: selective re-inversion exemption */
@media (inverted-colors: inverted) {
  /* Standard accessibility practice: re-invert images/videos */
  img, video, canvas {
    filter: invert(1) hue-rotate(180deg);
    /* Corrects these media elements for inversion users */
  }

  /* Attack: consent flow diagrams are NOT re-inverted */
  .consent-step-img,
  .data-flow-diagram,
  [data-consent-visual] {
    filter: none; /* explicitly clear — ensures double-inversion
                     OS inverts → CSS does NOT correct → colors wrong
                     Diagram appears in incorrect colors; inversion user
                     cannot interpret the consent flow visualization */
  }
}

/* Detection: check for @media (inverted-colors: inverted) blocks that
   apply filter:none to consent-adjacent image elements while applying
   filter:invert to other images */

Detection

/* Enumerate @media rules with inverted-colors condition */
function auditInvertedColorsRules() {
  const attacks = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule instanceof CSSMediaRule) {
          const cond = rule.conditionText || rule.media.mediaText;
          if (/inverted-colors\s*:\s*inverted/.test(cond)) {
            for (const innerRule of rule.cssRules) {
              /* Flag consent-targeting selectors */
              if (/consent|disclosure|terms|privacy|checkbox|opt.in/i
                    .test(innerRule.selectorText)) {
                attacks.push({
                  media: cond,
                  selector: innerRule.selectorText,
                  cssText: innerRule.style.cssText
                });
              }
            }
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
  return attacks;
}

/* Simulate inversion via CSS to check post-inversion computed values */
function checkConsentUnderInversion(consentEl) {
  /* Add a temporary class that simulates inversion effect */
  /* Cannot truly activate @media (inverted-colors) programmatically —
     the media query requires actual OS state
     Mitigation: inspect ALL rules in inverted-colors blocks regardless of activity
     and evaluate their color values against the consent element's background */
  const style = getComputedStyle(consentEl);
  const bg = style.backgroundColor;  /* current computed background */
  /* Check if any inverted-colors rule sets color ≈ background (match = invisible) */
  for (const attack of auditInvertedColorsRules()) {
    console.warn('inverted-colors consent attack found:', attack);
  }
}
AttackSeverityVisible to non-inversion audit?Detection method
Pre-inversion color matching (invisible in both modes)HIGHNo — attack active even without inversionStatic enumeration of inverted-colors blocks; check text=bg match
Post-inversion background match (invisible for inversion users only)HIGHNo — attack only active with OS inversionEnumerate inverted-colors rules; evaluate color vs computed background
Selective clause hiding (strong/em/price elements)CRITICALNo — only active with OS inversionFlag consent-scoped selectors inside inverted-colors blocks
Selective re-inversion exemption (diagram double-inversion)MEDIUMNo — requires active OS inversionCheck filter:none on consent images within inverted-colors block