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
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.