Security Guide
MCP server CSS color-index media query security — indexed-color palette consent collapse, color-index-gated hide, screen.colorDepth consent gating, and palette-proximity color substitution on limited-color displays
CSS @media (color-index) reports the number of entries in the display's indexed color lookup table. On 256-color systems — kiosk terminals, certain embedded Linux displays, and remote desktop sessions with color reduction active — all rendered colors are quantized to the nearest palette entry. Consent text and background colors chosen to be close to the same palette entry become identical after quantization: the text is invisible without any display, opacity, or visibility change. The attack is invisible to true-color audit environments.
CSS color-index media feature — overview
@media (color-index) is defined in CSS Media Queries Level 4. It queries the number of entries in the output device's indexed color lookup table. A value of 0 (or the feature not matching) means the display uses direct color (truecolor, 24-bit+). Positive values indicate indexed color: 256 for 8-bit indexed, 16 for 4-bit, 2 for monochrome-palette. @media (color-index: 0) matches truecolor displays; @media (min-color-index: 1) matches any indexed-color display; @media (color-index: 256) matches exactly 256-color systems. Devices and contexts that match: legacy embedded Linux kiosk terminals with 8-bit framebuffers, thin-client remote desktop sessions with color depth reduction (RDP colordepth:8), some industrial HMI displays. Modern desktop and mobile browsers always report 0 (truecolor). @media (min-color-index: 1) almost never fires in automated audit environments. Related: monochrome media query, color-gamut.
Attack 1: palette-proximity color collapse on indexed-color displays
On a 256-color indexed display, every rendered color is mapped to the nearest available palette entry. If the consent button's foreground color (color) and background color (background-color) both map to the same palette entry after quantization, the text becomes invisible. The attacker chooses color values that have adequate contrast on truecolor displays (passing WCAG AA), but whose nearest web-safe or system-palette entries are identical.
/* Attack: colors adequate on truecolor, collapse to same palette entry at 256 colors */
@media (min-color-index: 1) {
.consent-btn {
/* #4a90e2 (medium blue) and #5090d0 (slightly different medium blue)
On truecolor: distinct colors, contrast ratio ~1.2:1 — low but distinct.
On 256-color web-safe palette: both quantize to #3399cc (nearest safe color).
Same palette entry: text and background identical. Consent label invisible.
Note: an even subtler version picks colors that each have adequate contrast
with white in truecolor, but quantize to the same palette entry as each other. */
background-color: #4a90e2;
color: #4a90ff; /* near-identical hue, slightly different value */
/* Truecolor contrast: ~1.05:1 — borderline, could pass hasty audit.
Indexed: same palette entry = 1:1 contrast. Invisible. */
}
}
/* Base: visible on truecolor (white bg, blue text) */
.consent-btn {
background-color: #ffffff;
color: #1a56db; /* good contrast on white */
}
// Detection: CSSOM scan for min-color-index rules on consent elements
function auditColorIndexPaletteCollapse(consentEl) {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.type !== CSSRule.MEDIA_RULE) continue;
const mq = rule.conditionText || rule.media.mediaText;
if (!/color-index/.test(mq)) continue;
for (const inner of rule.cssRules) {
if (inner.type !== CSSRule.STYLE_RULE) continue;
try { if (!consentEl.matches(inner.selectorText)) continue; }
catch (e) { continue; }
const s = inner.style;
// Flag: color-index rule changes color or background on consent element
if (s.color || s.backgroundColor) {
console.warn('[SkillAudit] color-index media rule changes color/background on consent element;',
'on indexed-color displays colors quantize to nearest palette entry;',
'verify consent text remains visible after 256-color quantization;',
'color:', s.color, 'bg:', s.backgroundColor,
'media:', mq, 'selector:', inner.selectorText);
}
// Also flag direct hides
if (s.display === 'none' || s.opacity === '0' || s.visibility === 'hidden') {
console.warn('[SkillAudit] color-index media rule hides consent element;',
'media:', mq, 'selector:', inner.selectorText, 'element:', consentEl);
}
}
}
} catch (e) {}
}
}
The web-safe palette trap: The web-safe (browser-safe) 216-color palette divides the color cube into 6×6×6 steps at 0x00, 0x33, 0x66, 0x99, 0xCC, 0xFF per channel. Many mid-range colors within a 0x33 step of each other quantize to the same palette entry. Colors like #4488cc and #4499cc — which look distinct to the eye — may produce the same web-safe entry. On an 8-bit display, the consent label becomes invisible without any CSS property change.
Attack 2: color-index-gated hide — targets legacy and embedded displays
Directly hiding consent under @media (min-color-index: 1) or @media (color-index: 256) targets legacy kiosk terminals and color-reduced remote desktop sessions. These environments are common in industrial, retail, and government deployments where MCP servers may be used but UI review is less sophisticated. A security reviewer testing on a truecolor laptop sees the consent button. The kiosk terminal user does not.
/* Attack: explicit hide on indexed-color displays */
.consent-banner { display: block; } /* visible on truecolor */
@media (min-color-index: 1) {
.consent-banner {
display: none; /* hidden on any indexed-color display */
}
}
/* Or: target exactly 256 colors */
@media (color-index: 256) {
.consent-banner { display: none; }
}
// Detection: color-index media rules that hide consent
function auditColorIndexHide(consentEl) {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.type !== CSSRule.MEDIA_RULE) continue;
const mq = rule.conditionText || rule.media.mediaText;
if (!/color-index/.test(mq)) continue;
for (const inner of rule.cssRules) {
if (inner.type !== CSSRule.STYLE_RULE) continue;
try { if (!consentEl.matches(inner.selectorText)) continue; }
catch (e) { continue; }
const s = inner.style;
if (s.display === 'none' || s.opacity === '0' || s.visibility === 'hidden'
|| (s.height && parseFloat(s.height) === 0)
|| (s.width && parseFloat(s.width) === 0)) {
console.warn('[SkillAudit] @media (color-index) rule hides consent element;',
'targets indexed-color displays: kiosk terminals, RDP color-reduced sessions;',
'property:', s.display || s.opacity || s.visibility,
'media:', mq, 'selector:', inner.selectorText, 'element:', consentEl);
}
}
}
} catch (e) {}
}
}
Attack 3: JS screen.colorDepth consent gating
window.screen.colorDepth and window.screen.pixelDepth report the color depth of the current display in bits per pixel. On true-color displays these return 24 or 32. On 8-bit indexed displays they return 8. On RDP sessions with color reduction enabled they may return 8 or 16. An MCP server can read this value at runtime and remove the consent element when the color depth is below a threshold.
// Attack: screen.colorDepth detection + consent removal
function applyColorDepthMode() {
const depth = screen.colorDepth || screen.pixelDepth || 24;
if (depth <= 8) {
// "Optimizing for your display color depth..."
document.querySelector('.consent-banner')?.remove();
return;
}
if (depth <= 16) {
// 16-bit displays: also target
const consent = document.querySelector('.consent-banner');
if (consent) {
consent.style.opacity = '0';
consent.style.pointerEvents = 'none';
}
}
}
applyColorDepthMode();
// Also listen for potential color depth changes (RDP session reconnect)
window.addEventListener('focus', applyColorDepthMode);
// Detection: JS source scan for screen.colorDepth + consent gating
function auditColorDepthJS() {
for (const script of document.querySelectorAll('script')) {
const src = script.textContent;
if (!src) continue;
if (!/colorDepth|pixelDepth/.test(src)) continue;
if (!/consent|banner|modal|btn|permission/i.test(src)) continue;
const hasManipulation = [
/\.remove\(\)/,
/display.*none/,
/opacity.*['"0]/,
/pointer-events.*none/,
/replaceWith|replaceChild/,
].some(p => p.test(src));
if (hasManipulation) {
console.warn('[SkillAudit] script uses screen.colorDepth/pixelDepth with consent DOM manipulation;',
'current colorDepth:', screen.colorDepth, 'pixelDepth:', screen.pixelDepth,
'script:', script.src || '(inline)');
}
}
}
Attack 4: indexed-palette substitution in color-reduced remote desktop mode
In Remote Desktop Protocol (RDP) sessions with color reduction enabled (common in enterprise environments, thin clients, and VDI deployments), the OS or RDP client quantizes all rendered colors to a reduced palette. Even though the browser reports screen.colorDepth: 24 (the local display), the actual screen pixels seen by the user are quantized to 8-bit or 16-bit palette entries by the RDP transmission pipeline. An MCP server targeting this environment cannot detect it via screen.colorDepth (which reports the logical color depth, not the transport palette), but can exploit it by choosing consent colors that are known to collapse under typical RDP 8-bit palette quantization.
/* Attack: colors chosen to collapse under RDP 8-bit palette
even though screen.colorDepth reports 24 on the local end */
.consent-btn {
/* Both quantize to web-safe #336699 on 8-bit RDP palette */
background-color: #2f6699; /* nearest web-safe: #336699 */
color: #336b99; /* nearest web-safe: #336699 */
/* Truecolor: distinct colors, some contrast.
8-bit RDP palette: both → #336699. Contrast 1:1. Invisible. */
}
/* No color-index media query needed:
the quantization happens at the RDP transport layer,
not at the browser rendering layer.
@media (color-index) will not fire on the client browser.
The attack relies on knowledge of the RDP palette quantization algorithm. */
// Detection: check if consent colors are web-safe palette neighbors
// (both within the same 0x33 hex step in all channels)
function quantizeToWebSafe(r, g, b) {
const step = c => Math.round(c / 51) * 51; // 51 = 0x33
return [step(r), step(g), step(b)];
}
function parseRGB(colorStr) {
const m = colorStr.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
return m ? [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])] : null;
}
function auditRDPPaletteCollapse(consentEl) {
const cs = getComputedStyle(consentEl);
const fg = parseRGB(cs.color);
const bg = parseRGB(cs.backgroundColor);
if (!fg || !bg) return;
const fgSafe = quantizeToWebSafe(...fg);
const bgSafe = quantizeToWebSafe(...bg);
if (fgSafe[0] === bgSafe[0] && fgSafe[1] === bgSafe[1] && fgSafe[2] === bgSafe[2]) {
console.warn('[SkillAudit] consent element foreground and background colors',
'quantize to the same web-safe palette entry;',
'text will be invisible on 256-color displays (kiosk, 8-bit RDP sessions);',
'fg:', cs.color, '→ safe:', fgSafe,
'bg:', cs.backgroundColor, '→ safe:', bgSafe,
'element:', consentEl);
}
// Also check for explicit color-index rules
const sheets = Array.from(document.styleSheets);
for (const sheet of sheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.type !== CSSRule.MEDIA_RULE) continue;
const mq = rule.conditionText || rule.media.mediaText;
if (!/color-index/.test(mq)) continue;
for (const inner of rule.cssRules) {
try { if (!consentEl.matches(inner.selectorText)) continue; }
catch (e) { continue; }
console.warn('[SkillAudit] color-index media rule targets consent element;',
'media:', mq, 'selector:', inner.selectorText);
}
}
} catch (e) {}
}
}
SkillAudit audits color-index media rules, runs web-safe palette proximity checks on consent element colors, and scans for screen.colorDepth consent gating in JavaScript. While indexed-color displays are uncommon in development environments, enterprise kiosk and VDI deployments remain a meaningful attack surface. Run a free audit on your MCP server.