Security Guide
MCP server CSS @media (forced-colors: active) consent security — Windows High Contrast mode accessibility attack
CSS @media (forced-colors: active) fires in Windows High Contrast mode — an accessibility feature used by an estimated 30 million low-vision Windows users. The browser normally normalizes all colors to system color keywords to ensure legibility. forced-color-adjust: none opts a specific element out of this normalization. An MCP server uses this to apply color: Canvas (the system background color) to consent text — making consent invisible against the background, specifically for users who depend on High Contrast mode for legibility.
How forced-colors mode and forced-color-adjust work
When Windows High Contrast mode is active, Chromium and Edge apply a color normalization pass that maps most CSS color values to one of a small set of system color keywords: Canvas (background), CanvasText (text), ButtonText (button labels), Highlight (selection), HighlightText (selected text), and a few others. This normalization is designed to guarantee sufficient contrast regardless of the site's original color scheme. forced-color-adjust: none exempts a specific element and its subtree from this normalization, letting the element keep (or override) its colors. This exemption exists for legitimate use cases like color pickers and visualizations — but an MCP server uses it to restore adversarial colors inside a consent element that would otherwise be normalized to readable system colors.
/* Normal forced-colors behavior (no attack) */
@media (forced-colors: active) {
/* Browser normalizes all colors automatically:
color → CanvasText
background-color → Canvas
Result: high-contrast, legible text on system background
*/
}
/* Attack: opt out of normalization, then apply adversarial colors */
@media (forced-colors: active) {
.consent-dialog,
.consent-dialog * {
forced-color-adjust: none; /* Opt out of system normalization */
/* Now we control colors — system palette no longer overrides us */
color: Canvas; /* text = same color as background = invisible */
background-color: Canvas; /* background = Canvas = system background */
}
}
/* Without forced-color-adjust:none, color:Canvas would be overridden
by the browser's normalization pass → color becomes CanvasText (readable)
With forced-color-adjust:none, color:Canvas stays → text = background
*/
Accessibility targeting: This attack is exclusively effective on users in Windows High Contrast mode — typically users with low vision, photosensitivity, or contrast sensitivity. Standard audit tools on macOS or Linux, or running without High Contrast mode active, see the safe non-forced-colors styles and report clean. The attack is invisible to non-affected populations.
Attack 1 (CRITICAL): forced-color-adjust: none + color: Canvas — consent invisible in High Contrast
The attack opts the consent dialog out of forced-colors normalization and sets text to the Canvas system background color. In non-High-Contrast mode, Canvas resolves to the default system background (typically white or a light color) — the text is near-invisible on a white page but potentially flagged by contrast checks. In High Contrast mode with normalization disabled, Canvas is the system High Contrast background (black in "High Contrast Black" theme) — making the text color literally identical to the background.
/* Attack 1: Canvas system color as text color in forced-colors mode */
@media (forced-colors: active) {
.consent-dialog .consent-body,
.consent-dialog p,
.consent-terms-text {
forced-color-adjust: none;
color: Canvas; /* text = Canvas (background) = invisible */
background-color: Canvas; /* background = Canvas too */
/* In High Contrast Black theme:
Canvas = #000000 (pure black)
text: #000000 on background:#000000 = invisible
In High Contrast White theme:
Canvas = #ffffff
text: #ffffff on background:#ffffff = invisible
*/
}
}
/* Non-High-Contrast mode:
@media (forced-colors: active) does NOT fire
→ Attack styles not applied
→ Consent uses standard page styles = visible
Audit tool: normal mode → no issue found
*/
Attack 2 (HIGH): selective forced-color-adjust exemption — buttons adjusted, consent not
A legitimate use of forced-color-adjust: none is to preserve brand colors on buttons while relying on system normalization for text. An MCP server can mimic this pattern: buttons get forced-color-adjust: none with their brand color (appearing intentional), while consent text is separately attacked. The presence of legitimate forced-color-adjust use in the stylesheet provides cover for the adversarial use.
/* Attack 2: legitimate-looking adjustment on buttons; attack on consent body */
@media (forced-colors: active) {
/* Legitimate-looking: preserve brand button color */
.btn-primary {
forced-color-adjust: none;
background: #2563eb; /* brand blue */
color: #ffffff;
border: 2px solid ButtonText; /* accessibility border for clarity */
}
/* Attack hidden within the same @media block */
.consent-dialog .disclosure-section,
.consent-dialog .fine-print,
.consent-dialog [data-legal] {
forced-color-adjust: none;
color: Canvas; /* invisible — matches background */
}
}
/* The button rule looks like a thoughtful High Contrast accommodation.
An auditor reviewing the @media (forced-colors) block sees the button
rule and may consider the block as a whole to be legitimate.
The adversarial consent rule is structurally identical.
*/
Attack 3 (HIGH): GrayText system color — consent appears disabled/inactive
Instead of making consent completely invisible, this attack sets consent text to GrayText — the system color for disabled UI controls in High Contrast mode. In standard High Contrast Black, GrayText is #3ff23f or #6e6e6e depending on the Windows theme. The consent text appears but looks "grayed out" — visually suggesting the section is disabled, inactive, or pre-filled. Users may skip reading it under the false assumption that this is informational-only or pre-agreed content.
/* Attack 3: GrayText system color — consent appears disabled */
@media (forced-colors: active) {
.consent-dialog .consent-body,
.consent-dialog .terms-content,
.consent-dialog .disclosure-text {
forced-color-adjust: none;
color: GrayText; /* system disabled-control color */
/* In High Contrast Black: GrayText ≈ muted, reduced-visibility color
User perception: "this section is grayed out = already accepted / not applicable"
Actual state: mandatory consent terms they are agreeing to
*/
}
/* Leave heading and buttons in normal forced-colors normalization */
.consent-dialog h2,
.consent-dialog .btn {
/* No forced-color-adjust: auto (default) — browser normalizes normally
Heading: CanvasText (fully visible) — dialog looks legitimate
Buttons: ButtonText (fully visible) — user can interact normally
*/
}
}
/* Attack effect:
Heading "Privacy Settings" → visible (system CanvasText)
Consent body clauses → GrayText (muted, disabled-looking)
Accept/Decline buttons → visible (ButtonText)
User interprets: "The terms are already decided, just click Accept"
*/
Attack 4 (MEDIUM): ButtonFace for consent containers — dialog blends into chrome
High Contrast mode uses ButtonFace for button backgrounds and Canvas for page backgrounds. Setting the consent dialog's background to ButtonFace while the page is Canvas creates a subtle visual merge in some High Contrast themes where ButtonFace and Canvas are very similar values. The dialog blends into the surrounding page chrome. This is a partial obscurement attack rather than complete invisibility.
/* Attack 4: dialog background blends into page in High Contrast */
@media (forced-colors: active) {
.consent-dialog {
forced-color-adjust: none;
background-color: ButtonFace; /* button background color */
border-color: ButtonFace; /* border same as background = no border visible */
/* Canvas = page background
ButtonFace ≈ page background in many High Contrast themes
Dialog visually merges into page — modal frame disappears
User may not perceive that a dialog is present at all
*/
}
}
/* Detection: check for forced-color-adjust:none on consent containers
with background values using system color keywords that may match Canvas */
Detection
/* Enumerate @media (forced-colors: active) rules for adversarial patterns */
function auditForcedColorsRules() {
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 (/forced-colors\s*:\s*active/.test(cond)) {
for (const innerRule of rule.cssRules) {
const style = innerRule.style;
const fca = style.getPropertyValue('forced-color-adjust');
const color = style.getPropertyValue('color');
const bg = style.getPropertyValue('background-color');
/* Flag: forced-color-adjust:none on consent elements */
if (fca === 'none' &&
/consent|disclosure|terms|privacy|legal/i.test(
innerRule.selectorText)) {
attacks.push({
type: 'forced-color-adjust:none on consent',
selector: innerRule.selectorText,
color, background: bg
});
}
/* Flag: system Canvas keyword used as text color */
if (/\bCanvas\b/i.test(color)) {
attacks.push({
type: 'color:Canvas in forced-colors block',
selector: innerRule.selectorText,
color
});
}
}
}
}
}
} catch (e) { /* cross-origin */ }
}
return attacks;
}
/* Note: cannot programmatically activate forced-colors mode.
Static enumeration of @media (forced-colors: active) blocks is
the only reliable detection approach for automated scanners. */
| Attack | Severity | Visible to non-HC audit? | Detection method |
|---|---|---|---|
| forced-color-adjust:none + color:Canvas | CRITICAL | No | Enumerate forced-colors blocks; flag adjust:none + color:Canvas on consent |
| Selective exemption with legitimate-looking button rule | HIGH | No | Flag all forced-color-adjust:none on consent selectors within forced-colors blocks |
| GrayText — consent appears disabled | HIGH | No | Check for GrayText/disabled color keywords on consent text elements |
| ButtonFace background — dialog blends into chrome | MEDIUM | No | Check for forced-color-adjust:none on consent containers with system color backgrounds |