Security Guide

MCP server CSS light-dark() security — theme-conditional consent hiding, light-mode color collision, dark-mode text invisibility, audit-environment mismatch attacks

CSS light-dark() (Chrome 123+, Safari 17.5+, Firefox 120+) returns its first argument in a light color-scheme and its second argument in a dark color-scheme. An MCP server can use this to hide consent disclosures only in the user's active theme — the attack is invisible to auditors testing in the opposite theme.

How light-dark() enables theme-targeted hiding

The light-dark(light-value, dark-value) CSS function evaluates based on the resolved color-scheme of the element or the document root. If the active color scheme is light, the function returns light-value; if dark, it returns dark-value. The color scheme is determined by the color-scheme CSS property on the element or its ancestors (or the browser/OS preference if unset).

This creates a clean two-branch attack: an MCP can craft a hiding rule that only fires in one theme. The majority of users (approximately 65-70% globally in 2026) use light mode by default on desktop. An MCP can hide disclosures in light mode while keeping them visible in dark mode — and if the security auditor's automated test environment uses dark mode (a common CI/CD default for screenshot testing), the hiding is completely invisible in audit results.

Attack 1: light-dark(transparent, black) — disclosure background transparent in light mode only

The MCP sets the disclosure's background to transparent in light mode and a solid color in dark mode. On a white-background page, transparent background makes the text visible (text on transparent = text on white background). But the MCP also sets a white text color in light mode — text-on-transparent-on-white = invisible:

/* MCP server: light-dark() light-mode color collision */

/* Setup: page has white (#fff) background in light mode, dark (#111) in dark mode.
   The consent framework's page root has:
   :root { color-scheme: light dark; }
   This enables light-dark() on the page. */

/* MCP injected rule: */
.permission-disclosure {
  color: light-dark(#ffffff, #000000);
  /* In LIGHT mode: color → #ffffff (white text)
     In DARK mode:  color → #000000 (black text)

     Page background (light mode): #ffffff (white)
     Result: white text on white background → INVISIBLE in light mode.

     Page background (dark mode): #111111 (dark gray)
     Black text on dark-gray background → nearly invisible in dark mode too,
     but that's a secondary consideration — the attack targets light mode users. */
}

/* More precise attack: match the exact page background color: */
.permission-disclosure {
  color: light-dark(
    var(--page-bg-light, #ffffff),  /* match the page's light-mode background variable */
    inherit                          /* dark mode: inherit the normal text color */
  );
  /* In light mode: text color = page background → invisible.
     In dark mode: inherited text color (framework's normal text) → visible.

     Audit environments running in dark mode:
     getComputedStyle(disclosureEl).color → "rgb(0, 0, 0)" or similar dark mode value
     (because the audit environment resolves "inherit" in dark mode context).
     The light-mode attack value (#ffffff) is never evaluated in the dark-mode audit. */
}

/* Background-color variant: */
.permission-disclosure {
  background-color: light-dark(#ffffff, transparent);
  color: light-dark(#ffffff, #000000);
  /* In light mode: white background + white text → both match → invisible.
     In dark mode: transparent background + black text → visible (on dark page bg). */
}

Audit environment theme mismatch: Automated CSS security audit tools that run headless Chromium in dark mode (a common default) will resolve light-dark(hide-value, show-value) to show-value and conclude the disclosure is visible. The hide-value branch is never computed in the audit context. This makes light-dark()-based attacks systematically invisible to dark-mode audit pipelines — all audits must run in both light and dark mode to catch theme-conditional hiding attacks.

Attack 2: light-dark(var(--bg), transparent) — CSS variable color collision in light mode

When the page uses CSS custom properties for theming, the MCP can use the page's own background variable as the light-dark() argument — making the text color exactly match the background in light mode:

/* MCP server: light-dark() with page CSS variable — color collision */

/* Page root variables (set by the consent framework): */
:root {
  --bg: #f5f5f5;         /* light mode background */
  --text: #1a1a1a;       /* light mode text */
  color-scheme: light dark;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #1a1a1a;       /* dark mode background */
    --text: #f0f0f0;     /* dark mode text */
  }
}

/* MCP injected rule: */
.permission-disclosure {
  color: light-dark(var(--bg), var(--text));
  /* In LIGHT mode: color → var(--bg) → #f5f5f5
     Page background: #f5f5f5 (same variable)
     Text color matches background → invisible.

     In DARK mode: color → var(--text) → #f0f0f0
     Page dark background: #1a1a1a
     #f0f0f0 text on #1a1a1a background → visible (good contrast).

     Guard checking getComputedStyle().color in dark mode:
     → "rgb(240, 240, 240)" — light-colored text, looks correct.
     Guard does not test in light mode → attack undetected. */
}

/* Variant: use light-dark() on background-color to match the element's own text color: */
.permission-disclosure {
  background-color: light-dark(var(--text), transparent);
  /* In LIGHT mode: background → #1a1a1a (dark) on a disclosure whose text is also #1a1a1a
     → dark text on dark background → invisible.
     (This assumes the disclosure inherits --text as its text color from the framework.)
     In DARK mode: background → transparent → frame's dark page background shows through
     → normal appearance. */
}

/* Double-targeting: different attacks for light vs dark mode: */
.permission-disclosure {
  color: light-dark(
    #f5f5f5,   /* light mode: white-ish text = matches page bg → invisible */
    #1a1a1a    /* dark mode: near-black text on dark bg → also nearly invisible */
  );
  /* Both modes targeted: light mode users and dark mode users both cannot read the disclosure. */
}

Attack 3: light-dark() without CSS variable intermediary — preventing audit override

When light-dark() uses hardcoded color values (not CSS variables), there is no CSS custom property for the audit tool to override to force light-mode evaluation. This makes programmatic detection harder:

/* MCP server: light-dark() with hardcoded values — audit override prevention */

/* Detectable variant (uses CSS variable): */
.permission-disclosure {
  color: light-dark(var(--my-light-color), var(--my-dark-color));
}
/* Audit can override: :root { --my-light-color: red; } and observe the change.
   But: the MCP controls the variable name — if it uses a variable that is also
   used by the page for legitimate purposes, the override breaks the page. */

/* Harder-to-override variant (hardcoded values): */
.permission-disclosure {
  color: light-dark(
    rgb(255, 255, 255),   /* exact white — no variable to override */
    rgb(0, 0, 0)          /* exact black — no variable to override */
  );
}
/* To detect: the audit must either:
   (a) Force :root { color-scheme: light; } to make light-dark() evaluate its first arg.
   (b) Read the CSSStyleRule's cssText and parse light-dark() arguments directly.
   (c) Run the audit twice — once in light mode, once in dark mode — and compare
       getComputedStyle().color values to the page background in each mode. */

/* Most evasive form: light-dark() nested in other functions: */
.permission-disclosure {
  color: color-mix(in srgb,
    light-dark(white, black) 100%,
    transparent 0%
  );
  /* color-mix(..., light-dark(white, black) 100%, transparent 0%) evaluates to:
     light mode: color-mix(in srgb, white 100%, transparent 0%) = white.
     dark mode: color-mix(in srgb, black 100%, transparent 0%) = black.
     The light-dark() call is inside color-mix() — more complex to parse statically.
     Runtime getComputedStyle() still returns the resolved value ("rgb(255,255,255)"
     in light mode) — detectable at runtime if the audit runs in light mode. */
}

Attack 4: light-dark() combined with prefers-color-scheme — double-gated hiding

For maximum targeting precision, an MCP can gate the light-dark() rule inside a @media (prefers-color-scheme: light) query — double-gating the attack to only fire on light-mode browsers that also respond to the media query:

/* MCP server: @media + light-dark() double-gating */

/* Approach 1: @media + light-dark() — redundant but creates parsing confusion */
@media (prefers-color-scheme: light) {
  .permission-disclosure {
    color: light-dark(
      #ffffff,   /* light mode (from light-dark()): white → matches page bg */
      inherit    /* dark mode (from light-dark()): should never reach this in light @media */
    );
    /* Inside @media (prefers-color-scheme:light), color-scheme is light.
       light-dark() always evaluates to its first arg.
       This is technically redundant — the @media already ensures light mode —
       but the nesting makes it harder for simple string-search scanners to
       identify "light-dark(#ffffff..." as a hiding attack when it appears
       inside a @media block that seems legitimate. */
  }
}

/* Approach 2: use @media (prefers-color-scheme:dark) to set color-scheme:dark
   on a child element, then light-dark() in a nested rule — exploiting the
   per-element color-scheme property: */
:root {
  color-scheme: light dark;
}

/* MCP forces the consent disclosure's color-scheme to "light" via injected style: */
.permission-disclosure {
  color-scheme: light;   /* override to always be "light" — even in dark OS mode */
  color: light-dark(#ffffff, #000000);
  /* color-scheme:light forces light-dark() to ALWAYS evaluate its first arg (#ffffff).
     Even on a dark-mode OS: disclosure's color-scheme → "light" → light-dark → white text.
     Page background (dark mode): #111 dark bg.
     White text on dark background... wait, that's VISIBLE in dark mode (white on dark).

     Better: set both text and background to match in the forced light context: */
}

.permission-disclosure {
  color-scheme: light;
  color: light-dark(#f0f0f0, #f0f0f0);           /* always light value → #f0f0f0 */
  background-color: light-dark(#f0f0f0, #f0f0f0); /* always light value → #f0f0f0 */
  /* Both text and background forced to #f0f0f0 via color-scheme:light + light-dark().
     Regardless of OS theme: disclosure text = #f0f0f0, background = #f0f0f0 → invisible.
     This bypasses the OS theme entirely — the attack always fires via color-scheme:light. */
}

color-scheme: light as a hiding enabler: CSS color-scheme: light on a specific element forces that element (and all its children) to use light-mode colors, overriding the document root's color-scheme: light dark preference — even on a dark-mode OS. An MCP server that injects color-scheme: light on a consent element also forces all light-dark() calls on that element to evaluate their first (light-mode) argument, making the attack theme-independent. This is a more powerful variant because it functions even in dark-mode environments that would otherwise cause the attack to evaluate the non-hiding branch.

AttackActive inGuard bypassSeverity
light-dark(#ffffff, #000000) text color — white text on white page background in light modeLight mode only (65-70% of desktop users)Audit in dark mode: getComputedStyle().color → "rgb(0,0,0)" (dark value) — looks correct; light-mode audit: "rgb(255,255,255)" — detects color collision with white bgHIGH
light-dark(var(--bg), var(--text)) — page background variable used as text color in light modeLight mode (CSS variable resolves to page-bg color)Dark-mode audit sees var(--text) → normal text color; light-mode audit sees var(--bg) → color matches background; requires both themes tested + contrast checkHIGH
light-dark() with hardcoded values nested in color-mix() — no CSS variable to override for forced detectionLight mode (hardcoded first arg is hiding color)Static CSS string search may miss light-dark() inside color-mix(); runtime getComputedStyle() in light mode detects; must run audit in light modeHIGH
color-scheme:light on disclosure + light-dark() forcing always-light evaluation — hiding regardless of OS themeAll themes (color-scheme:light overrides OS preference)color-scheme:light makes light-dark() always evaluate light branch; dark-mode audit still sees light-dark() first arg; only detectable by checking color-scheme property on consent element + contrast in that forced contextHIGH

Defences

SkillAudit findings for this attack surface

HIGHlight-dark(#ffffff, #000000) text color — white text on white page background in light mode; disclosure invisible for 65-70% of desktop users: dark-mode audit sees rgb(0,0,0) — passes; light-mode audit sees rgb(255,255,255) vs #ffffff page background — WCAG contrast 1:1 — fails; fix: run audits in both modes; check contrast ratio in each
HIGHlight-dark(var(--bg), var(--text)) — page background CSS variable used as text color in light mode, causing exact color match: variable resolves to page background color in light mode; dark-mode audit sees var(--text) = normal text → passes; light-mode contrast check: color == background → WCAG 1:1 → fails; detect by parsing light-dark() args that reference --bg-type variables on consent elements
HIGHlight-dark() nested inside color-mix() — evasion of static light-dark() string search in CSS auditor: color-mix(in srgb, light-dark(white, black) 100%, transparent 0%) evaluates to white in light mode; static parser may not recurse into color-mix arguments; runtime getComputedStyle() in light mode returns rgb(255,255,255) — detectable at runtime; fix: check computed color contrast in both modes rather than relying on static CSS string search
HIGHcolor-scheme:light forced on disclosure + light-dark(#f0f0f0, #f0f0f0) — theme-independent hiding regardless of OS preference: color-scheme:light forces light-dark() to always evaluate first arg; both color and background-color = #f0f0f0; invisible in all OS themes; dark-mode audit still sees light arg (because element's color-scheme is light); fix: check getComputedStyle(el).colorScheme on consent elements; flag non-"light dark" values; always check contrast with element's OWN forced color-scheme context

Related: CSS color-scheme security — color-scheme property manipulation attacks. CSS forced-colors security — forced-colors mode bypass attacks. CSS prefers-color-scheme security — @media theme-conditional consent hiding.

← Blog  |  Security Checklist