Security Guide

MCP server CSS prefers-contrast security — forced-colors override hides button text in high-contrast mode, more-contrast path removes border making button invisible, less-contrast lowers text below readable threshold, JS conditionally hides consent for high-contrast users

CSS @media (prefers-contrast) queries the OS accessibility preference for higher or lower visual contrast. Attacks that exploit this target users who specifically rely on contrast enhancement — hiding consent elements only in high-contrast mode, where those users are least able to detect the bypass and most dependent on accessibility settings to read UI.

CSS prefers-contrast — media query overview

prefers-contrast is a CSS media feature with four values: no-preference (system default contrast), more (user requests higher contrast), less (user requests lower contrast), forced (OS forced-colors mode, e.g., Windows High Contrast). Each value represents a distinct accessibility population. Related: forced-colors, forced-color-adjust, color-scheme, prefers-color-scheme.

Attack 1: forced — button text color matches background in forced-colors mode

Under @media (prefers-contrast: forced) or @media (forced-colors: active), operating systems override CSS color declarations with system colors from the forced-colors palette. However, forced-color-adjust: none exempts an element from this override, allowing the page's own CSS colors to apply. An attacker can use forced-color-adjust: none on the consent button to prevent the OS from imposing readable colors, then set button text color to match the background — making the label invisible only in forced-colors mode.

/* Attack: forced-color-adjust:none + text matches background in forced mode */
@media (prefers-contrast: forced) {
  .consent-btn {
    forced-color-adjust: none; /* opt out of OS color override */
    color: ButtonFace;          /* text color = button background (Canvas) */
    background-color: ButtonFace;
    /* Both text and background use the same system color value 'ButtonFace'.
       The button renders as a blank rectangle — no visible label.
       The button exists in the DOM, has non-zero dimensions, and is in-viewport.
       But its text is invisible — consent label unreadable, action unclear.
       In forced-colors mode, users depend on forced-color-adjust to enforce readable colors.
       Opting out while setting matching colors targets this population specifically. */
  }
}

/* Variant: match to Canvas (page background) instead */
@media (prefers-contrast: forced) {
  .consent-btn {
    forced-color-adjust: none;
    color: Canvas;            /* text color = page background */
    background-color: Canvas; /* button bg also = page background */
    /* Button is completely invisible — same color as the page background.
       Zero contrast. Not detectable by opacity or visibility checks. */
  }
}
// Detection: check contrast ratio between button text and background
function getLuminance(r, g, b) {
  const [rs, gs, bs] = [r/255, g/255, b/255].map(v =>
    v <= 0.04045 ? v/12.92 : ((v+0.055)/1.055)**2.4);
  return 0.2126*rs + 0.7152*gs + 0.0722*bs;
}

function auditContrastRatio(el) {
  const cs = getComputedStyle(el);
  const color = cs.getPropertyValue('color');
  const bg    = cs.getPropertyValue('background-color');

  // Parse rgb/rgba values
  const parse = str => {
    const m = str.match(/\d+/g);
    return m ? [+m[0], +m[1], +m[2]] : null;
  };

  const fg = parse(color);
  const bgColor = parse(bg);
  if (!fg || !bgColor) return;

  const L1 = getLuminance(...fg);
  const L2 = getLuminance(...bgColor);
  const ratio = (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);

  if (ratio < 4.5) {
    console.warn('[SkillAudit] consent button contrast ratio:', ratio.toFixed(2),
      '(WCAG AA minimum: 4.5:1 for normal text) —',
      'text may be illegible; check prefers-contrast:forced path;',
      'color:', color, '| background:', bg, '| element:', el);
  }
}

Targeted accessibility bypass: Users with prefers-contrast: forced have the OS-level forced-colors mode enabled — this is the Windows High Contrast mode, or similar settings on macOS and iOS. These users DEPEND on forced-color-adjust to override CSS colors with readable system colors. Opting out via forced-color-adjust: none while setting colors to match the background targets users who are specifically relying on accessibility tooling to read the UI.

Attack 2: more-contrast — border and outline removal makes button invisible

Under @media (prefers-contrast: more), users expect enhanced visual separation between UI elements. A consent button that relies on its border or outline to be visually distinct from the page background becomes invisible if those borders are removed in the more-contrast path. Combined with a background color matching the page, the button loses all visual boundaries.

/* Attack: remove borders in more-contrast path, matching background */
.consent-btn {
  background-color: white;
  border: 2px solid #555;  /* visible in normal mode */
  color: #111;
  outline: none;
}

@media (prefers-contrast: more) {
  .consent-btn {
    border: none;            /* removes visual boundary */
    outline: none;           /* removes focus outline */
    box-shadow: none;        /* removes any shadow-based border */
    background-color: white; /* matches page background (white) */
    color: white;            /* text also white — invisible label */
    /* In more-contrast mode, user expects BETTER visual separation.
       Instead, they get zero borders and zero contrast text.
       The button is a blank white rectangle on a white page.
       Exists in DOM, in viewport, non-zero dimensions, opacity:1.
       Visually indistinguishable from empty space. */
  }
}
// Detection: audit border visibility in more-contrast path
function auditMoreContrastBorders(el) {
  const cs = getComputedStyle(el);

  // Check if any border or outline is visible
  const borders = [
    cs.getPropertyValue('border-top-width'),
    cs.getPropertyValue('border-right-width'),
    cs.getPropertyValue('border-bottom-width'),
    cs.getPropertyValue('border-left-width'),
    cs.getPropertyValue('outline-width'),
  ].map(v => parseFloat(v) || 0);

  const hasBorder = borders.some(w => w > 0);
  const boxShadow = cs.getPropertyValue('box-shadow');
  const hasShadow = boxShadow && boxShadow !== 'none' && boxShadow !== '';

  if (!hasBorder && !hasShadow) {
    // Also check contrast ratio
    const color = cs.getPropertyValue('color');
    const bg    = cs.getPropertyValue('background-color');
    console.warn('[SkillAudit] consent button has no border, outline, or box-shadow:',
      '— button boundary may be invisible against page background;',
      'color:', color, '| background:', bg,
      '| check @media (prefers-contrast:more) path for border removal;',
      '| element:', el);
  }
}

Attack 3: less-contrast — text opacity below readable threshold

Under @media (prefers-contrast: less), users prefer softer contrast. An attacker can abuse this to push button text opacity below a readable threshold — while appearing to "honor" the user's preference for less contrast. The button's text becomes too faint to read, but the element remains fully interactive. Users who have set less-contrast mode receive unreadable consent text.

/* Attack: aggressive opacity reduction under less-contrast */
@media (prefers-contrast: less) {
  .consent-btn {
    color: rgba(255, 255, 255, 0.1); /* nearly transparent text */
    background-color: rgba(0, 0, 0, 0.05); /* nearly transparent background */
    /* contrast ratio ≈ 1.05:1 (virtually no contrast)
       Button is visible (opacity:1, visibility:visible) but text unreadable.
       User cannot read what they are consenting to.
       Technically "honoring" less-contrast preference by reducing contrast.
       In practice: informed consent is impossible without readable text. */
  }
}

/* More subtle: just enough contrast to pass automated WCAG checks */
@media (prefers-contrast: less) {
  .consent-btn {
    color: #ccc;              /* light gray text */
    background-color: #bbb;   /* slightly darker gray background */
    /* Contrast ratio: ~1.3:1 — below WCAG AA (4.5:1)
       Below WCAG AAA (7:1) for large text
       Automated tools often flag at 1.0, not 1.3 — may pass some scanners */
  }
}
// Detection: check contrast under all prefers-contrast values
function auditAllContrastModes(el) {
  // We cannot simulate media queries in JS, but we can check current state
  const cs = getComputedStyle(el);
  const color = cs.getPropertyValue('color');
  const bg    = cs.getPropertyValue('background-color');

  const parse = str => {
    const m = str.match(/[\d.]+/g);
    return m ? [+m[0], +m[1], +m[2], m[3] !== undefined ? +m[3] : 1] : null;
  };

  const fgRGBA = parse(color);
  const bgRGBA = parse(bg);

  if (!fgRGBA || !bgRGBA) return;

  // Check effective opacity (color alpha × element opacity)
  const elemOpacity = parseFloat(cs.getPropertyValue('opacity'));
  const effectiveAlpha = fgRGBA[3] * elemOpacity;

  if (effectiveAlpha < 0.5) {
    console.warn('[SkillAudit] consent button text has low effective opacity:',
      effectiveAlpha.toFixed(2),
      '— text may be unreadable under prefers-contrast path;',
      'color:', color, '| element opacity:', elemOpacity,
      '| element:', el);
  }
}

Attack 4: JS reads contrast preference to conditionally hide consent elements

JavaScript can read window.matchMedia('(prefers-contrast: more)').matches to detect high-contrast mode. An attacker can use this to conditionally set display: none or visibility: hidden on the consent section — only when the user has the more-contrast (or forced) preference enabled. This selectively hides consent for users who have indicated they need enhanced accessibility support.

/* JS attack: hide consent on high-contrast devices */
const highContrast = window.matchMedia('(prefers-contrast: more)').matches ||
                     window.matchMedia('(prefers-contrast: forced)').matches ||
                     window.matchMedia('(forced-colors: active)').matches;

if (highContrast) {
  const consentSection = document.querySelector('.consent-section');
  if (consentSection) {
    consentSection.style.setProperty('display', 'none');
    /* Consent section hidden for high-contrast users.
       Normal users: consent shown, button functional.
       High-contrast users: consent hidden, action taken without consent UI.
       The attack only activates on devices where the user has specifically
       enabled accessibility settings for visual needs. */
  }
}

// Or more subtle: just opacity
if (highContrast) {
  document.querySelector('.consent-btn')?.style.setProperty('opacity', '0');
}
// Detection: look for prefers-contrast matchMedia in script source
// Static analysis: search for matchMedia + prefers-contrast + conditional hide
function auditContrastMatchMedia(scriptText) {
  if (scriptText.includes('prefers-contrast') || scriptText.includes('forced-colors')) {
    const hidePatterns = [
      /display\s*:\s*['"]?none/,
      /visibility\s*:\s*['"]?hidden/,
      /opacity\s*:\s*['"]?0/,
      /setProperty\s*\(\s*['"]display['"],\s*['"]none['"]/,
    ];
    const hasHidePattern = hidePatterns.some(p => p.test(scriptText));
    if (hasHidePattern) {
      console.warn('[SkillAudit] script contains prefers-contrast/forced-colors check',
        'combined with element hiding — verify consent elements are not conditionally',
        'hidden for high-contrast users');
    }
  }
}

// Apply to all script elements
document.querySelectorAll('script').forEach(s => {
  if (s.textContent) auditContrastMatchMedia(s.textContent);
});

Findings summary

High forced-colors path + forced-color-adjust:none + matching colors: button text and background set to same system color in forced-colors mode; label invisible; button passes opacity/visibility/display checks; detected by computing contrast ratio between color and background-color; targets users who specifically depend on forced-colors for accessibility.
High more-contrast path border/outline removal: button loses all visual boundaries and label contrast in high-contrast mode; effectively invisible against page background; all layout properties look correct; detected by checking for at least one visible border, outline, or box-shadow on the button and verifying contrast ratio is ≥ 4.5:1.
Medium less-contrast path text opacity reduction: text made unreadable by reducing color alpha or setting near-background color values; button interactive but consent text illegible; informed consent impossible; detected by checking effective text contrast ratio (including alpha channel) against WCAG AA minimum of 4.5:1.
High JS conditional hide on high-contrast devices: matchMedia('prefers-contrast:more/forced') used to conditionally hide consent section; normal users unaffected; high-contrast users see no consent UI; detected by static analysis of script source for prefers-contrast/forced-colors media queries combined with display:none, visibility:hidden, or opacity:0 on consent elements.

SkillAudit tests consent button visibility under all four prefers-contrast values, checks contrast ratios in each media query path, audits for forced-color-adjust: none combined with low-contrast color values, and static-analyzes JavaScript for conditional consent-hiding based on contrast media queries. Run a free audit on your MCP server.