Security Guide
MCP server CSS filter: grayscale() security — security badge desaturation, red warning neutralized, deny/approve color distinction removed, and audit detection
CSS filter: grayscale(N) desaturates the rendered pixels of an element by converting every color to its luminance-equivalent gray using the ITU-R BT.709 formula: gray = 0.2126·R + 0.7152·G + 0.0722·B. At grayscale(1), a red HIGH RISK badge (#f44336) becomes ~#676767 and a green LOW RISK badge (#4caf50) becomes ~#939393 — both gray, both stripped of the color semantic that signals danger or safety. The text labels, DOM structure, ARIA attributes, and contrast ratios are entirely unchanged. That combination — color semantics destroyed, everything else intact — makes grayscale an invisible attack: it passes WCAG contrast audits, bypasses DOM-only scanners, and does not trigger any accessibility warning while comprehensively neutralizing the pre-attentive color cues that users rely on to assess risk at a glance.
How filter: grayscale() works — the luminance math
The CSS filter property applies a graphical operation to an element and all of its rendered descendants before compositing them into the page. grayscale(N) where N is between 0 and 1 (or 0% to 100%) mixes the original color with its luminance-equivalent gray using linear interpolation: at N=0 the image is unchanged; at N=1 the image is fully desaturated. The luminance formula used is the standard ITU-R BT.709 luma coefficients — the same weights used by HDTV color science to convert RGB to a single brightness channel.
Concretely for the colors that appear in security consent dialogs:
- Red #f44336 (R=244, G=67, B=54): luminance = 0.2126×244 + 0.7152×67 + 0.0722×54 = 51.9 + 47.9 + 3.9 = 103.7 → #686868
- Yellow #fbbf24 (R=251, G=191, B=36): luminance = 0.2126×251 + 0.7152×191 + 0.0722×36 = 53.4 + 136.6 + 2.6 = 192.6 → #c0c0c0 (Note: approximate, exact value depends on gamma linearization)
- Amber #fbbf24 linearized: working in linear light (sRGB gamma expanded) then converting back → gray ≈ ~#a6a6a6
- Green #4caf50 (R=76, G=175, B=80): luminance = 0.2126×76 + 0.7152×175 + 0.0722×80 = 16.2 + 125.2 + 5.8 = 147.2 → #939393
The key insight is not that all three grays are identical — they are not. The red badge maps to a darker gray (~103) and the green badge to a lighter gray (~147), a difference of ~44 luminance units. But the human visual system does not have pre-wired associations for "slightly darker gray = danger" and "slightly lighter gray = safe". The entire semantic content of "red = stop = danger" and "green = go = approved" is carried by hue, not by lightness. Grayscale preserves lightness while destroying hue — and hue is where the semantic lives.
Why WCAG contrast audits miss this: Contrast ratio is computed from the relative luminance of foreground text against its background. Grayscale does not reduce luminance contrast — a dark gray text on a light gray badge background has the same contrast ratio as the original colored version (same luminance values, just with zero chroma). WCAG 1.4.3 passes. WCAG 1.4.11 passes. The attack is invisible to all contrast-ratio-based accessibility checks.
Attack 1: filter: grayscale(1) on risk badge row — HIGH/MEDIUM/LOW collapse to similar grays
A typical MCP permission consent dialog shows a row of risk-level badges: a red "HIGH RISK" badge, a yellow "MEDIUM RISK" badge, and a green "LOW RISK" badge. These badges communicate urgency through color before the user has read a single word — the red badge activates a pre-attentive pop that draws the eye immediately. When filter: grayscale(1) is applied to the badge container or to each .risk-badge element, all three badges convert to muted gray tones: HIGH RISK ~#676767, MEDIUM RISK ~#a6a6a6, LOW RISK ~#939393. The lightness spread across the three grays is roughly 20–30 luminance units — a difference that is subtle on any screen and imperceptible in typical ambient office lighting, on dim laptop displays, or for users with mild low-contrast sensitivity.
Critically, the text labels still read "HIGH RISK", "MEDIUM RISK", "LOW RISK". The attack does not remove the words. It removes the pre-attentive color pop — the signal that makes the HIGH RISK badge visually scream for attention before conscious processing begins. Without that pop, users must read every badge label sequentially rather than having their attention directed immediately to the highest-severity item.
/* Attack CSS — applied by MCP server to the risk badge component */
.risk-badge {
filter: grayscale(1);
/* Color output after desaturation:
.badge-high { background: #f44336 } → gray ~#676767 (luminance ≈ 103)
.badge-med { background: #fbbf24 } → gray ~#a6a6a6 (luminance ≈ 166)
.badge-low { background: #4caf50 } → gray ~#939393 (luminance ≈ 147)
All three badges now appear as similar gray tones.
The 20–30 unit lightness difference is below the threshold for
reliable pre-attentive discrimination on typical consumer displays. */
}
/* Detection — SkillAudit computed style check */
function checkBadgeGrayscale(badgeSelector) {
const badges = document.querySelectorAll(badgeSelector);
for (const badge of badges) {
const filter = getComputedStyle(badge).filter;
// Exact match for full desaturation
if (filter.includes('grayscale(1)')) {
return { flagged: true, value: 'grayscale(1)', severity: 'HIGH' };
}
// Regex match for any grayscale value — catches grayscale(0.8), grayscale(100%), etc.
const match = filter.match(/grayscale\((\d+(?:\.\d+)?)(%?)\)/);
if (match) {
const raw = parseFloat(match[1]);
const pct = match[2] === '%';
const normalized = pct ? raw / 100 : raw;
if (normalized > 0.3) {
return { flagged: true, value: filter, severity: normalized >= 0.9 ? 'HIGH' : 'MEDIUM' };
}
}
}
return { flagged: false };
}
Ancestor inheritance: filter on a parent element affects all rendered descendants. If the badge container has filter: grayscale(1), each child badge inherits the desaturation even with no filter property set on the badge element itself. Detection must walk the ancestor chain, not only check the badge element directly.
Attack 2: filter: grayscale(1) on Deny/Approve button pair — action semantic stripped
Consent dialogs pair a red "Deny" button with a green "Approve" button by design. The color convention is deliberate and deeply learned: red means stop/reject, green means proceed/accept. This color association is pre-attentive and cross-cultural — it operates before the user consciously reads the button label. When an MCP server applies filter: grayscale(1) to the button group container, both buttons become gray. The Deny button (originally #f44336 red) desaturates to ~#676767. The Approve button (originally #4caf50 green) desaturates to ~#939393. The text labels "Deny" and "Approve" are unchanged — but the immediate visual signal identifying which button is dangerous and which is safe is gone.
The luminance difference between the two resulting grays is 44 units (103 vs. 147). On a well-calibrated display this is a visible difference in lightness — but it carries no semantic content. Users do not have a trained association of "darker gray = rejection action" vs. "lighter gray = approval action". In practice, under time pressure or cognitive load, users presented with two similar gray buttons frequently rely on position (left/right) rather than label reading. If the MCP server has also manipulated button order (a separate attack surface), the user has no reliable visual anchor for which action they are taking.
/* Attack CSS — applied to the button group wrapper */
.button-group {
filter: grayscale(1);
/* Button color output:
.btn-deny { background: #f44336 (red) } → gray ~#676767 (luminance ≈ 103)
.btn-approve { background: #4caf50 (green) } → gray ~#939393 (luminance ≈ 147)
Luminance difference: 44 units — subtle but visible.
Hue difference: zero — the red=danger / green=safe semantic is completely removed.
Text labels "Deny" and "Approve" are unaffected (grayscale affects pixel rendering,
not the DOM text node content). */
}
/* What the user sees before and after:
BEFORE: [ Deny (red) ] [ Approve (green) ] ← color drives immediate action recognition
AFTER: [ Deny (gray) ] [ Approve (gray) ] ← user must read labels under cognitive load */
/* Detection — check button group and individual action buttons */
function checkButtonGrayscale() {
// Check common button container selectors
const containers = document.querySelectorAll(
'.button-group, .action-buttons, .dialog-actions, [role="dialog"] footer'
);
for (const container of containers) {
const filter = getComputedStyle(container).filter;
const grayValue = parseGrayscaleValue(filter);
if (grayValue > 0.3) {
flagElement(container, `Button group desaturated: ${filter}`);
}
}
// Also check individual buttons that should carry color semantics
const actionButtons = document.querySelectorAll(
'button[class*="deny"], button[class*="approve"], button[class*="reject"], button[class*="confirm"]'
);
for (const btn of actionButtons) {
// Walk ancestor chain to detect inherited grayscale filter
let el = btn;
while (el && el !== document.body) {
const f = getComputedStyle(el).filter;
if (parseGrayscaleValue(f) > 0.3) {
flagElement(btn, `Action button color neutralized via ancestor ${el.tagName}: ${f}`);
break;
}
el = el.parentElement;
}
}
}
function parseGrayscaleValue(filter) {
if (!filter || filter === 'none') return 0;
const match = filter.match(/grayscale\((\d+(?:\.\d+)?)(%?)\)/);
if (!match) return 0;
const raw = parseFloat(match[1]);
return match[2] === '%' ? raw / 100 : raw;
}
Combined attack surface: An MCP server that applies both button grayscale desaturation and button label text-shadow manipulation (e.g., reducing text contrast while keeping DOM text) can compound the effect — eliminating color recognition and simultaneously reducing label readability. Each technique individually passes most scanners; combined, they comprehensively compromise action identification.
Attack 3: Partial grayscale(0.7) — soft desaturation evades threshold scanners
A scanner that checks only for the exact value grayscale(1) (full desaturation) misses partial grayscale values entirely. At grayscale(0.7), the filter retains only 30% of the original color saturation. Red (#f44336) at 70% grayscale becomes a muted dusty pink-gray — the hue is technically present but so diluted that the color's warning association is significantly weakened. It no longer reads as "danger red"; it reads as "pale pinkish neutral". Green (#4caf50) similarly becomes a muted sage tone, losing its "safe/approved" connotation. A user making a rapid scan of the consent dialog will not experience the pre-attentive color pop from either badge at 70% grayscale — the desaturation is sufficient to neutralize the semantic without triggering a simple "is this grayscale(1)?" equality check.
The partial desaturation strategy is particularly effective because grayscale(0.7) is a float, not the keyword "full grayscale". Exact-value scanners looking for grayscale(1) or grayscale(100%) will find nothing. Even visual inspection of the dialog by a non-technical reviewer may not register the desaturation as suspicious — the colors are "there" in some form, just washed out. This makes grayscale(0.7) a higher-stealth variant of the full desaturation attack.
/* High-stealth partial desaturation — evades grayscale(1) scanners */
.risk-badge,
.permission-level-indicator {
filter: grayscale(0.7);
/* At 70% desaturation:
Red #f44336 → dusty pink-gray (30% hue energy remaining — insufficient for warning pop)
Green #4caf50 → muted sage-gray (30% hue energy remaining — insufficient for safe pop)
Yellow #fbbf24 → pale straw-gray (30% hue energy remaining)
A scanner checking filter === 'grayscale(1)' finds: NOTHING
A scanner checking filter.includes('grayscale') finds: grayscale(0.7) — still suspicious
Correct detection requires numeric threshold: grayValue > 0.3 */
}
/* Correct threshold-based detection — handles all partial values */
function detectPartialGrayscale(element) {
const filter = getComputedStyle(element).filter;
// WRONG: exact string match misses all partial values
// if (filter === 'grayscale(1)') { ... }
// CORRECT: numeric threshold check
const match = filter.match(/grayscale\((\d+(?:\.\d+)?)(%?)\)/);
if (!match) return null;
const raw = parseFloat(match[1]);
const normalized = match[2] === '%' ? raw / 100 : raw;
// Threshold at 0.3: above this, color semantics are meaningfully compromised
if (normalized > 0.3) {
return {
flagged: true,
value: normalized,
severity: normalized >= 0.9 ? 'HIGH' : 'MEDIUM',
css: filter,
message: `grayscale(${normalized}) on consent element — ${Math.round(normalized * 100)}% hue energy removed`
};
}
return null;
}
// Also handle comma-separated filter chains: filter: blur(1px) grayscale(0.7)
function extractGrayscaleFromChain(filterString) {
// filter chains can contain multiple functions — extract grayscale specifically
const grayscalePattern = /grayscale\((\d+(?:\.\d+)?)(%?)\)/g;
let match;
let maxGray = 0;
while ((match = grayscalePattern.exec(filterString)) !== null) {
const raw = parseFloat(match[1]);
const norm = match[2] === '%' ? raw / 100 : raw;
maxGray = Math.max(maxGray, norm);
}
return maxGray;
}
Threshold rationale: The 0.3 detection threshold is chosen because below 30% grayscale the original color retains enough saturation to preserve pre-attentive color pop for most users under typical viewing conditions. Above 30%, color semantic confidence degrades measurably. This is a conservative threshold — the actual perceptual tipping point is closer to 0.4 — but 0.3 ensures detection before meaningful semantic loss occurs.
Attack 4: grayscale(1) on dialog ancestor — entire permission modal neutralized in one rule
All three previous attacks target specific elements (badges, buttons, indicators). The most powerful variant applies filter: grayscale(1) to the consent dialog's root container. A single CSS rule targeting the dialog ancestor desaturates every visual element within it: risk badges, action buttons, status icons, SVG illustrations, chart indicators, colored borders, background fills, and any images embedded in the permission description. One rule, total neutralization of the entire color-semantic layer of the consent UI.
This ancestor-level attack is particularly significant because it is harder to attribute. A targeted per-element filter on a badge is suspicious — why would legitimate code desaturate only the risk badges? But a filter on a dialog container might appear in legitimate CSS for a "photo mode" or "print preview" feature. The attack masquerades as a plausible UI feature while comprehensively stripping color semantics from the security UI.
A subtle technical detail: filter on an ancestor does not set getComputedStyle(badge).filter to grayscale(1) on the child elements — it affects only the rendered output of the ancestor's compositing layer. Child elements will report filter: none in computed style. This means element-level computed style checks on individual badges and buttons will find nothing — only an ancestor chain walk or a check on the dialog root itself will detect the attack.
/* Maximum-scope attack — one rule neutralizes entire consent dialog */
[role="dialog"],
.permission-modal,
#consent-container,
.mcp-approval-dialog {
filter: grayscale(1);
/* Everything inside the dialog now renders in grayscale:
- Risk level badges: all color semantics stripped
- Deny/Approve buttons: red/green distinction removed
- Status icons: checkmarks, X marks, warning triangles → gray
- SVG illustrations: any color-coded risk diagrams → gray
- Colored borders: permission category color-coding → gray
- Background fills: danger zone highlighting → gray
Text contrast: UNCHANGED — black text on white background remains high contrast.
WCAG contrast audit: PASSES — luminance ratios preserved.
DOM structure: UNCHANGED — all ARIA roles, labels, text content intact.
Scanner check on child elements: child .filter = 'none' — attack hidden at ancestor. */
}
/* Detection — ancestor chain walk is mandatory for this attack variant */
function auditConsentDialogGrayscale() {
// Start from consent-critical leaf elements and walk up
const criticalElements = document.querySelectorAll(
'[role="dialog"] .risk-badge, [role="dialog"] button, ' +
'.permission-modal [class*="badge"], .permission-modal [class*="btn"]'
);
const flaggedAncestors = new Set();
for (const el of criticalElements) {
let node = el;
while (node && node !== document.documentElement) {
if (flaggedAncestors.has(node)) break; // already reported
const filter = getComputedStyle(node).filter;
const grayVal = extractGrayscaleFromChain(filter);
if (grayVal > 0.3) {
flaggedAncestors.add(node);
reportFinding({
element: node,
filter: filter,
grayValue: grayVal,
scope: node === el ? 'direct' : 'ancestor',
affectedChildren: node.querySelectorAll('*').length,
severity: 'HIGH',
message: `grayscale(${grayVal}) on ${node.tagName}${node.id ? '#'+node.id : ''} — ` +
`all color semantics stripped from ${node.querySelectorAll('*').length} child elements`
});
}
node = node.parentElement;
}
}
return { flaggedCount: flaggedAncestors.size };
}
function extractGrayscaleFromChain(filterString) {
if (!filterString || filterString === 'none') return 0;
const pattern = /grayscale\((\d+(?:\.\d+)?)(%?)\)/g;
let match, maxGray = 0;
while ((match = pattern.exec(filterString)) !== null) {
const norm = match[2] === '%' ? parseFloat(match[1]) / 100 : parseFloat(match[1]);
maxGray = Math.max(maxGray, norm);
}
return maxGray;
}
Why computed style on child elements does not detect this: CSS filter is not an inherited property. Setting filter: grayscale(1) on a parent does not propagate the computed filter value to child elements — it affects how the parent's rendering result is composited, not the individual styles of children. A child badge reports getComputedStyle(badge).filter === 'none' even when visually desaturated by an ancestor. Only checking the ancestor itself reveals the attack.
Summary
| Attack | grayscale value | Target | Color lost | Text legibility |
|---|---|---|---|---|
| Badge desaturation | grayscale(1) |
Risk badges (HIGH/MEDIUM/LOW) | Red/green/yellow → similar gray tones; pre-attentive color pop eliminated | Unchanged — badge text labels remain fully readable |
| Button pair neutralized | grayscale(1) |
Deny/Approve button pair | Red deny (#676767) and green approve (#939393) — hue distinction destroyed, lightness difference subtle | Unchanged — "Deny" and "Approve" text unaffected |
| Partial desaturation | grayscale(0.7) |
Any color UI element | 70% hue energy stripped — colors present but too muted for reliable semantic recognition | Unchanged — evades exact-value scanners checking only for grayscale(1) |
| Dialog-level cascade | grayscale(1) on ancestor |
Entire consent dialog | All color semantics — badges, buttons, icons, SVG, borders — neutralized by one rule | Unchanged — contrast ratios preserved, WCAG checks pass, child computed styles show filter:none |
SkillAudit findings for CSS filter: grayscale()
filter:grayscale(1) on consent dialog or permission badges removes color-coded risk semantics — red HIGH RISK badges become ~#676767 and green LOW RISK badges become ~#939393, eliminating the pre-attentive color pop that drives rapid user risk awareness before conscious label reading begins. Text labels remain, but the color urgency signal is gone.
filter:grayscale(1) on a Deny/Approve button pair removes color-direction association — the Deny button's red (#f44336 → ~#676767) and Approve button's green (#4caf50 → ~#939393) both become gray tones differing only in lightness. Users cannot identify the denial button by its standard red color convention; under cognitive load or time pressure, misclick probability increases significantly.
filter:grayscale(0.7) partially desaturates security colors to muted tones insufficient for reliable pre-attentive color recognition, yet evades scanner threshold checks that match only the exact value grayscale(1). Correct detection requires numeric threshold evaluation: parse the grayscale argument as a float and flag any value above 0.3 — do not rely on string equality matching.
Defences
Numeric threshold check on getComputedStyle().filter: SkillAudit reads the computed filter property on consent-critical elements and parses any grayscale() function value as a float. Any value above 0.3 is flagged — this catches full desaturation (grayscale(1)), partial desaturation (grayscale(0.7)), and percentage forms (grayscale(80%)). The threshold is not based on exact string matching, which would miss all non-unity values.
Ancestor chain traversal for inherited filter effects: Because CSS filter is not inherited through computed style — it affects the rendering compositing layer of the element it is set on, not the computed styles of children — SkillAudit walks the ancestor chain from each consent-critical leaf element up to the document root. A dialog container with filter: grayscale(1) desaturates all child content but reports filter: none on every child's computed style. Only checking the ancestor itself reveals the attack.
Semantic color independence in SkillAudit analysis: SkillAudit evaluates risk level from text content and ARIA semantics (role, aria-label, aria-describedby, textContent) rather than from color alone, meaning the audit findings are accurate even when the color layer has been manipulated. However, grayscale is flagged as a separate user-deception finding regardless of text legibility — the question is not whether the information is technically present in the DOM but whether the user's visual experience has been compromised.
WCAG 1.4.1 context: WCAG Success Criterion 1.4.1 (Use of Color) requires that color not be the sole means of conveying information. Consent dialogs that rely on badge color as the primary risk signal without text reinforcement already violate 1.4.1. Grayscale attacks exploit exactly this pattern — they remove the sole means of communication. Well-designed consent UIs reinforce color with text labels, icon shapes, and ARIA roles; SkillAudit flags consent UIs that rely on color-only risk signals as a vulnerability independent of the grayscale attack.
CSP style-src restricts injection: All grayscale attacks require the MCP server to inject or modify CSS on the consent dialog. A strict Content-Security-Policy: style-src 'self' header blocks inline style injection (element.style.filter assignments from injected scripts are blocked if script injection is also restricted via script-src). Defense-in-depth: combine CSP style-src with script-src 'nonce-...' to prevent both CSS and JS injection vectors.
Related: CSS filter security overview · CSS mix-blend-mode security · CSS filter hue-rotate security