Security Guide

MCP server CSS inverted-colors media query security — consent label invisible when text and background invert to same gray, button border removed under inversion causes it to blend into page, consent tooltip hidden via display:none under inversion, JS matchMedia swap targets inversion users

CSS @media (inverted-colors: inverted) matches macOS Smart Invert and iOS Color Invert accessibility modes. These modes physically invert display colors — what was white becomes black, what was dark blue becomes light orange. Attackers exploit this by setting consent button colors that, after inversion, become identical — making the label illegible — or by removing visual boundaries that keep the button distinguishable.

CSS inverted-colors media feature — overview

@media (inverted-colors) accepts none (normal display) and inverted (display colors are inverted). It matches macOS Accessibility → Display → Invert Colors (Classic Invert), macOS Smart Invert (inverts everything except images and video), and iOS Settings → Accessibility → Display & Text Size → Color Filters or Smart Invert. Users with light sensitivity, certain visual impairments, or who prefer reading white-text-on-black use these modes. CSS can both detect and compensate for inversion. Malicious CSS exploits it. Related: forced-colors, prefers-contrast, hover.

Attack 1: consent text and background both set to same mid-gray — identical after inversion

CSS color inversion maps each RGB channel C to (255 − C). A color where all three channels are 128 (mid-gray, #808080) inverts to (255−128, 255−128, 255−128) = #7f7f7f — essentially the same color. Setting both color and background-color of a consent button to the same mid-gray value under @media (inverted-colors: inverted) produces a button where text and background are the same shade after inversion. The label is invisible. The button's dimensions, opacity, and pointer-events are all correct.

/* Attack: consent label invisible via same-color inversion trap */
.consent-btn {
  color: #ffffff;              /* normal: white text */
  background-color: #1a1a2e;  /* normal: dark navy background */
  /* inverted: #000000 text on #e5e5d1 background — visible and readable */
}

@media (inverted-colors: inverted) {
  .consent-btn {
    color: #808080;            /* mid-gray text under inversion */
    background-color: #7f7f7f; /* mid-gray background under inversion */
    /* After OS inversion applied: #7f7f7f text on #808080 background.
       Contrast ratio: ~1.0:1 — text invisible against background.
       Button is physically present, correct size, correct opacity.
       Only the label is invisible, making consent appear to exist but
       the user cannot read what they are consenting to. */
  }
}
// Detection: check color contrast on inverted-colors elements
function auditInvertedColorsContrast(consentEl) {
  const isInverted = window.matchMedia('(inverted-colors: inverted)').matches;
  const sheets = Array.from(document.styleSheets);

  function getLuminance(r, g, b) {
    const sRGB = [r, g, b].map(c => {
      const s = c / 255;
      return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
    });
    return 0.2126 * sRGB[0] + 0.7152 * sRGB[1] + 0.0722 * sRGB[2];
  }

  function contrastRatio(c1, c2) {
    const l1 = getLuminance(...c1);
    const l2 = getLuminance(...c2);
    const lighter = Math.max(l1, l2);
    const darker = Math.min(l1, l2);
    return (lighter + 0.05) / (darker + 0.05);
  }

  for (const sheet of sheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.type !== CSSRule.MEDIA_RULE) continue;
        const mq = rule.conditionText || rule.media.mediaText;
        if (!/inverted-colors\s*:\s*inverted/.test(mq)) continue;
        for (const inner of rule.cssRules) {
          if (inner.type !== CSSRule.STYLE_RULE) continue;
          if (!consentEl.matches(inner.selectorText)) continue;
          const fg = inner.style.color;
          const bg = inner.style.backgroundColor;
          if (fg && bg) {
            console.warn('[SkillAudit] inverted-colors:inverted sets explicit color + background-color;',
              'verify contrast ratio after OS color inversion is applied (OS inversion is applied on top of CSS);',
              'color:', fg, '| background:', bg,
              '| if both are mid-gray (~#808080), text will be invisible after inversion;',
              'selector:', inner.selectorText);
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
}

Double inversion: The OS inverts CSS colors and then the @media (inverted-colors: inverted) rule can set new colors. The net effect is: new CSS color, then OS-inverted. Auditors must reason about both layers simultaneously. A mid-gray in the CSS rule stays mid-gray after OS inversion.

Attack 2: border and box-shadow removed under inversion — button blends into background

A consent button that is visually distinguishable by its border or box-shadow loses that distinction when those properties are removed under @media (inverted-colors: inverted). After OS inversion, a button whose background inverts to match the page background and has no border becomes invisible as a distinct element. Dimensions, opacity, and click handlers all remain intact — the button is simply invisible as a UI element.

/* Attack: remove visual boundary under inversion */
.consent-btn {
  border: 2px solid #3a86ff;  /* normal: visible blue border */
  box-shadow: 0 2px 8px rgba(0,0,0,0.3);
  background: #1e3a5f;         /* normal: dark blue */
}

@media (inverted-colors: inverted) {
  .consent-btn {
    border: none;
    box-shadow: none;
    /* After OS inversion: background becomes light orange (~#e1c5a0).
       Page background was dark (#0a0a0a) → inverts to light (#f5f5f5).
       Button background (~#e1c5a0) on page background (~#f5f5f5):
       low contrast, hard to distinguish.
       Without border or shadow: button boundary is invisible.
       User cannot locate the consent button on the page. */
  }
}
// Detection: border/box-shadow removed under inverted-colors
function auditInvertedBorderRemoval(consentEl) {
  const sheets = Array.from(document.styleSheets);
  for (const sheet of sheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.type !== CSSRule.MEDIA_RULE) continue;
        const mq = rule.conditionText || rule.media.mediaText;
        if (!/inverted-colors\s*:\s*inverted/.test(mq)) continue;
        for (const inner of rule.cssRules) {
          if (inner.type !== CSSRule.STYLE_RULE) continue;
          if (!consentEl.matches(inner.selectorText)) continue;
          const border = inner.style.border;
          const boxShadow = inner.style.boxShadow;
          if (border === 'none' || border === '0') {
            console.warn('[SkillAudit] inverted-colors:inverted removes border from consent element;',
              'button may blend into page background after OS color inversion;',
              'selector:', inner.selectorText, '| element:', consentEl);
          }
          if (boxShadow === 'none') {
            console.warn('[SkillAudit] inverted-colors:inverted removes box-shadow from consent element;',
              'depth cue removed; button may become visually indistinct;',
              'selector:', inner.selectorText);
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
}

Attack 3: consent tooltip hidden via display:none under inversion

A consent tooltip or detailed information panel that appears alongside the consent button is hidden under @media (inverted-colors: inverted). The button itself remains but the explanatory text — what the user is consenting to, what data is processed — is hidden. This is a partial consent bypass: the user can still click the button but without informed context.

/* Attack: consent explanation hidden under inversion */
.consent-tooltip,
.consent-description,
.consent-detail {
  display: block; /* visible normally */
}

@media (inverted-colors: inverted) {
  .consent-tooltip,
  .consent-description,
  .consent-detail {
    display: none;
    /* Button still present but detail text is hidden.
       User is clicking "I agree" without knowing to what.
       This is an informed consent bypass, not a full UI bypass. */
  }
}
// Detection: inverted-colors:inverted hiding consent informational elements
function auditInvertedTooltipHide() {
  const infoSelectors = [
    '.consent-tooltip', '.consent-description', '.consent-detail',
    '[class*="consent"][class*="text"]', '[class*="consent"][class*="info"]',
    '[data-consent-text]',
  ];
  const sheets = Array.from(document.styleSheets);
  for (const sheet of sheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.type !== CSSRule.MEDIA_RULE) continue;
        const mq = rule.conditionText || rule.media.mediaText;
        if (!/inverted-colors\s*:\s*inverted/.test(mq)) continue;
        for (const inner of rule.cssRules) {
          if (inner.type !== CSSRule.STYLE_RULE) continue;
          for (const sel of infoSelectors) {
            try {
              if (document.querySelector(sel) && document.querySelector(sel).matches(inner.selectorText)) {
                if (inner.style.display === 'none' || inner.style.opacity === '0') {
                  console.warn('[SkillAudit] inverted-colors:inverted hides consent informational element:',
                    inner.selectorText, '— users cannot read what they are consenting to');
                }
              }
            } catch (e) { /* invalid selector */ }
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
}

Attack 4: JS matchMedia inverted-colors swap

JavaScript can detect window.matchMedia('(inverted-colors: inverted)').matches and replace the consent button with a non-interactive element for inversion-mode users. This is a pure-JS bypass that does not appear in CSS CSSOM audits. Since Color Invert is primarily an accessibility feature for visually impaired users, this attack specifically targets a vulnerable population.

// Attack: JS inverted-colors detection + button swap
if (window.matchMedia('(inverted-colors: inverted)').matches) {
  const btn = document.querySelector('.consent-btn');
  if (btn) {
    const div = document.createElement('div');
    div.className = btn.className;
    div.textContent = btn.textContent;
    // No event listener — visual clone without interaction
    btn.parentNode.replaceChild(div, btn);
  }
}

// Or: auto-grant consent for inversion users
if (window.matchMedia('(inverted-colors: inverted)').matches) {
  window.__consentGranted = true;
}
// Detection: JS inverted-colors matchMedia + consent manipulation
function auditInvertedColorsJS() {
  for (const script of document.querySelectorAll('script')) {
    const src = script.textContent;
    if (!src || !/matchMedia/.test(src) || !/inverted-colors/.test(src)) continue;
    const hasManipulation = [
      /replaceChild|createElement|removeChild/,
      /__consent|consentGranted/,
      /display.*none/,
      /pointer-events.*none/,
    ].some(p => p.test(src));
    if (hasManipulation) {
      console.warn('[SkillAudit] script uses inverted-colors matchMedia with consent manipulation;',
        'verify consent button remains interactive for Color Invert accessibility users;',
        'script:', script.src || '(inline)');
    }
  }
}

Findings summary

High inverted-colors:inverted sets same-gray color and background — after OS inversion both values remain near-identical; consent label invisible; button has correct dimensions, opacity, and pointer-events; detected by checking for explicit color + background-color rules in inverted-colors blocks where both channels approach mid-gray (~128,128,128).
Medium border and box-shadow removed under inverted-colors:inverted — button loses visual boundary; after OS inversion of button and page backgrounds, button blends into page; detected by checking inverted-colors:inverted rules for border:none or box-shadow:none on consent elements.
Medium consent description/tooltip hidden under inverted-colors:inverted — button present but explanatory text hidden; user cannot read what they are consenting to; partial consent bypass; detected by checking inverted-colors:inverted blocks for display:none on consent informational elements.
High JS inverted-colors matchMedia swap — replaces consent button with non-interactive element or auto-grants consent for Color Invert accessibility users; specifically targets visually impaired population; detected by source scan for inverted-colors matchMedia combined with DOM manipulation or consent auto-grant patterns.

SkillAudit audits inverted-colors media query rules for same-gray color traps, border removal, tooltip hiding, and JS matchMedia swaps. It reasons about both CSS-level and OS-level inversion layers simultaneously. Run a free audit on your MCP server.