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.
| Attack | Active in | Guard bypass | Severity |
|---|---|---|---|
| light-dark(#ffffff, #000000) text color — white text on white page background in light mode | Light 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 bg | HIGH |
| light-dark(var(--bg), var(--text)) — page background variable used as text color in light mode | Light 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 check | HIGH |
| light-dark() with hardcoded values nested in color-mix() — no CSS variable to override for forced detection | Light 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 mode | HIGH |
| color-scheme:light on disclosure + light-dark() forcing always-light evaluation — hiding regardless of OS theme | All 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 context | HIGH |
Defences
- Run CSS security audits in BOTH light mode and dark mode. Force the test environment's color scheme with
:root { color-scheme: light; }for one pass and:root { color-scheme: dark; }for another. ComparegetComputedStyle(disclosureEl).colorvs the page background color in each mode. A disclosure that passes in dark mode but fails the contrast check in light mode reveals alight-dark()attack. - Parse injected stylesheet rules for
light-dark()function calls on consent-element selectors. Iterate the injectedCSSStyleRuledeclarations and checkcssTextfor the stringlight-dark(. Anycolor,background-color, orbackgrounddeclaration containinglight-dark()on a consent element is suspicious — legitimate consent framework styles do not use theme-conditional color functions in MCP-injected stylesheets. - Check
getComputedStyle(el).colorSchemeon consent elements. If a consent element hascolor-scheme: light(ordark) explicitly set — forcing it to always evaluatelight-dark()in one branch — flag it. Legitimate consent elements should inherit the document'scolor-schemeor setlight dark(both). - Check the luminance contrast ratio between
getComputedStyle(el).colorandgetComputedStyle(el).backgroundColorin both light and dark modes. A contrast ratio below 3:1 (WCAG AA for large text, 4.5:1 for small text) in either mode indicates the disclosure text is not readable. Run this check after forcing:root { color-scheme: light; }and again after forcingdark. - SkillAudit flags:
light-dark()function calls incolororbackground-colordeclarations on consent-element selectors in injected stylesheets;color-scheme: lightorcolor-scheme: dark(non-both) forced on consent elements; contrast ratio below WCAG threshold in either light or dark mode for consent elements;@media (prefers-color-scheme)rules containing consent-element hiding declarations.
SkillAudit findings for this attack surface
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.