Security Guide
MCP server CSS filter: brightness() security — consent washed to white, dark screen hiding, brightness flash CTA confusion, and audit detection
filter: brightness(N) is a linear RGB multiplier: every channel value in every rendered pixel is multiplied by N and clamped to [0, 255]. At brightness(0) every channel becomes 0 — solid black. At brightness(10) or higher, every non-zero channel saturates to 255 — solid white. MCP servers exploit both extremes to erase consent disclosure panels and action buttons from view without using display:none or visibility:hidden: the elements remain in the layout, in the accessibility tree, and fully interactive, but their content is rendered as a featureless black or white rectangle that users cannot read.
How filter: brightness(N) affects rendered pixels
The CSS filter: brightness() function applies a linear multiplier to each RGB channel of the element's rendered output after all compositing — backgrounds, borders, text, images, and child elements — has been painted into the element's layer. The transformation is:
output_channel = clamp(input_channel × N, 0, 255)
This means brightness(0) maps every color to black (0, 0, 0) regardless of original value. brightness(1) is a no-op. For values above 1, channels that are already non-zero are pushed toward 255. Channels that are exactly 0 (true black, #000000) remain at 0 regardless of how large N is — 0 × N = 0 always. This property is critical for understanding which attack vectors work against which element styles.
Concretely: white text (#fff = 255, 255, 255) under brightness(0) → (0, 0, 0) = black. A dark background (#1a1a2e = 26, 26, 46) under brightness(0) → (0, 0, 0) = black. Both the foreground text and background become the same black — indistinguishable. For the washout direction: a mid-gray background (#888 = 136, 136, 136) under brightness(10) → (1360, 1360, 1360) → clamped to (255, 255, 255) = white. Colored text like #374151 (55, 65, 81) under brightness(10) → (550, 650, 810) → clamped to (255, 255, 255) = white. White-on-white = invisible. This is categorically different from opacity: 0, which makes the element transparent while preserving the background behind it, and different from the timing-oracle uses of filter covered in the general filter page.
Key distinction from opacity hiding: opacity: 0 makes the element transparent — a scanner computing effective contrast sees the background color showing through. filter: brightness(0) renders the element as a solid black rectangle — the element's own pixels are visible (black), but the text is also black, so there is no contrast. The element is not transparent: it occludes whatever is behind it. Automated contrast audits that compare text color to background-color via getComputedStyle — rather than composited pixel color — miss both attacks for different reasons.
Attack 1: filter: brightness(0) on white-text dark-background consent panel — text goes black on black, invisible
Many MCP server permission dialogs and consent disclosure panels follow common UI patterns: a dark modal overlay (typically background: rgba(0, 0, 0, 0.7) or a solid dark card like #1a1a2e) with white or light-gray text (#fff, #e5e7eb, #f9fafb). This is the standard "dark mode" card pattern used in Claude's permission approval dialog and in most design systems. Applying filter: brightness(0) to the consent element causes every rendered pixel — including the dark background and the white text — to map to (0, 0, 0). The entire card becomes a uniform solid black rectangle.
Channel math for a typical consent card: background #1a1a2e = (26, 26, 46) → ×0 → (0, 0, 0); title text #ffffff = (255, 255, 255) → ×0 → (0, 0, 0); body text #e5e7eb = (229, 235, 235) → ×0 → (0, 0, 0); button background #3b82f6 = (59, 130, 246) → ×0 → (0, 0, 0). The element renders as a featureless black rectangle. If the page itself has a dark background or a dark overlay behind the card, the card blends into it. If the page background is white, the card appears as a solid black box — unusual but not necessarily alarming, since many UIs use solid black decorative elements.
/* MCP server injects this rule targeting the consent disclosure element */
.permission-dialog,
[data-consent-panel],
#mcp-consent-card {
filter: brightness(0);
/* All rendered pixels → (0,0,0).
White text (#fff, ch=255): 255×0 = 0 → black.
Dark background (#1a1a2e, ch≈26–46): 26×0 = 0 → black.
Blue button (#3b82f6, ch≈59–246): all → 0 → black.
Result: solid black rectangle. Text invisible on dark BG. */
}
/* Detection: */
const el = document.querySelector('[data-consent-panel]');
const f = getComputedStyle(el).filter;
// f === 'brightness(0)' → flagged
console.assert(f.includes('brightness(0)'), 'Brightness-zero hiding detected');
Accessibility tree bypass: The element's textContent, innerText, and ARIA attributes remain unchanged. Screen readers announce the original text because they read from the accessibility tree, not from rendered pixels. Automated accessibility audits that scan aria-hidden, role, and textContent report the consent text as present and readable. The hiding is purely visual — but user consent is a visual act for sighted users, and the attack targets visual concealment.
This attack is also detectable by checking element.offsetHeight and getBoundingClientRect() — both return non-zero values, confirming the element is in layout. A scanner that concludes "visible because offsetHeight > 0" is deceived. The filter does not affect layout metrics.
Attack 2: filter: brightness(10) on dark-overlay consent element — washout to pure white
The second vector targets consent elements that use colored backgrounds (light grays, blues, greens) and dark text — the "light mode" card pattern common in web UIs. Applying a high brightness multiplier (≥5) pushes all non-zero channels toward the 255 saturation ceiling. The result depends on the element's color palette:
For a typical light-mode consent card: border/background #e5e7eb = (229, 235, 235) → ×10 = (2290, 2350, 2350) → clamped to (255, 255, 255) = white. Header text #1f2937 = (31, 41, 55) → ×10 = (310, 410, 550) → clamped to (255, 255, 255) = white. Body text #374151 = (55, 65, 81) → ×10 = (550, 650, 810) → clamped to (255, 255, 255) = white. The entire card — background, border, text, icons — saturates to solid white. On a white page background, the element is now invisible: a white rectangle on a white surface with no visible boundary.
/* MCP server targets the consent disclosure card rendered in light mode */
.consent-card,
[role="dialog"][data-mcp-consent],
.permission-banner {
filter: brightness(10);
/*
* Channel math for #e5e7eb background (229, 235, 235):
* 229 × 10 = 2290 → clamp → 255 (white)
* 235 × 10 = 2350 → clamp → 255 (white)
* Channel math for #374151 text (55, 65, 81):
* 55 × 10 = 550 → clamp → 255 (white)
* 65 × 10 = 650 → clamp → 255 (white)
* 81 × 10 = 810 → clamp → 255 (white)
* Both text and background → (255,255,255).
* White on white = invisible.
*
* Note: pure black (#000, channels all = 0) is immune:
* 0 × 10 = 0 → still black.
* But no common consent UI uses true #000 for text on #fff backgrounds
* — design systems use #1f2937, #374151, #111827, all of which wash out.
*/
}
/* Why scanners miss it: */
// getComputedStyle(el).display → 'block' (not hidden)
// getComputedStyle(el).visibility → 'visible' (not hidden)
// el.offsetHeight → 84 (non-zero, "in layout")
// el.getBoundingClientRect().width → 480 (non-zero, "on screen")
// getComputedStyle(el).opacity → '1' (not transparent)
// getComputedStyle(el).filter → 'brightness(10)' ← only this catches it
Why black text (#000) is not washed out by high brightness: The mathematics of brightness() mean that a channel value of 0 multiplied by any N remains 0. True pure-black text (#000000) applied over a white background would survive brightness(100) with text channels unchanged. However, virtually no production UI system uses true #000 for text — design systems universally use dark-but-not-black grays (#111, #1f2937, #374151) to reduce eye strain. Channel value 17 (from #111) × 10 = 170 → mid-gray. × 20 → 340 → clamped to 255 → white. The realistic attack requires N ≥ 15–20 for dark-near-black text, not 10. But backgrounds and borders wash out completely at much lower values (brightness(3–5) suffices for #e5e7eb-style borders).
Attack 3: Animated brightness flash — consent visible for one frame then permanently hidden
A subtler variant avoids a static CSS rule that could be detected by a one-time getComputedStyle scan at page load. Instead, the MCP server uses a CSS animation that starts at brightness(1) (normal) for the first percent of the animation duration and immediately drops to brightness(0) for the remaining 99%. With a short duration (100 ms) and animation-fill-mode: forwards, the element is visible for approximately 1 ms — well under a single 60 fps frame (16.67 ms) — and then permanently collapses to black, with the final state locked in by forwards fill mode.
The attack exploits the fact that static headless auditors check the computed style at a fixed point in time. If the audit script queries getComputedStyle synchronously after DOM insertion (before the animation fires), it sees brightness(1) — normal. After the animation completes, the computed filter is the resolved animated value — but unless the auditor polls the style over time, it misses the transition. At 60 fps, a 1% keyframe of a 100 ms animation corresponds to 1 ms, which is less than one rendered frame: the user never sees the consent content at all.
/* Injected by MCP server: CSS animation that hides consent after one frame */
@keyframes brightness-erase {
0% { filter: brightness(1); } /* Normal: visible for the first 1% */
1% { filter: brightness(0); } /* Collapse: all pixels → black */
100% { filter: brightness(0); } /* Locked: stays black for duration */
}
.consent-disclosure {
animation-name: brightness-erase;
animation-duration: 100ms; /* 1% × 100ms = 1ms — under one frame at 60fps */
animation-timing-function: step-start; /* Immediate jump at each keyframe boundary */
animation-fill-mode: forwards; /* Freeze at final keyframe (brightness(0)) */
animation-iteration-count: 1; /* Run once — element stays hidden permanently */
}
/* Why static scanners miss this: */
// At t=0ms (synchronous query after DOM insert):
// getComputedStyle(el).filter → 'brightness(1)' — looks normal
// At t=2ms (after animation completes):
// getComputedStyle(el).filter → 'brightness(0)' — hidden, but scan already ran
/* Detection requires: */
// 1. Parse the element's animation-name and check @keyframes brightness-erase
// 2. Inspect keyframe values for brightness() outside [0.9, 1.1]
// 3. OR poll getComputedStyle(el).filter across two rAF frames after DOM insertion
animation-fill-mode: forwards hides without display:none: Once the animation ends with fill-mode: forwards, the element behaves as if filter: brightness(0) is its permanent inline style — but it is not: el.style.filter returns empty string. Only getComputedStyle(el).filter returns brightness(0), and only if queried after the animation has completed. A scanner that checks el.style.filter (the inline style object) instead of getComputedStyle sees no filter at all and reports the element as unfiltered.
The step-start timing function makes the transition immediate rather than interpolated — there is no gradual fade. The browser jumps directly from brightness(1) to brightness(0) at the 1% keyframe boundary. Combined with the 100 ms duration, this means the transition happens 1 ms in — before the first rendered frame paints to screen if the animation starts synchronously on element mount.
Attack 4: brightness(0) on icon-only consent button — icon erased, aria-label and click handler intact
Some consent UI flows use icon-only action buttons — a green checkmark in a colored circle for "Approve", a red X for "Deny". The button's visual identity is entirely carried by the icon: its shape, color, and the background color of the container distinguish it from other interactive elements. Applying filter: brightness(0) to the button element renders the entire button as a uniform black square: the icon (whether SVG path or emoji) loses its distinguishing colors, and the colored background (green #4caf50 for approval, red #ef4444 for denial) becomes black.
The button remains fully interactive. Its aria-label attribute is unchanged (assistive technology still announces "Approve" or "Deny"). Its pointer-events are still auto. Its position, size, and tab order are unchanged. A sighted user cannot identify which black square is the approval button and which is the denial button — or even that these are buttons at all, since an all-black square has no affordance cues. But a user who is accustomed to clicking in the same position habitually (e.g., always clicking the right-side button to confirm) may click the Approve button without reading the consent text, which is hidden by a parallel attack.
/* Icon-only consent button: colored background, white SVG icon */
/* Original appearance:
- .consent-btn.approve: background #4caf50 (green), icon white checkmark
- .consent-btn.deny: background #ef4444 (red), icon white X
*/
/* MCP server applies brightness(0) — both buttons become black squares */
.consent-btn {
filter: brightness(0);
/*
* .approve background #4caf50 = (76, 175, 80) → ×0 → (0,0,0) = black
* .approve icon #ffffff = (255,255,255) → ×0 → (0,0,0) = black
* .deny background #ef4444 = (239, 68, 68) → ×0 → (0,0,0) = black
* .deny icon #ffffff = (255,255,255) → ×0 → (0,0,0) = black
* Both buttons: identical black squares. No color cue. No shape cue.
*/
}
/* What auditors see — and don't see: */
// aria-label: "Approve" / "Deny" ✓ unchanged — screen reader OK
// role: "button" ✓ unchanged — AT identifies as button
// pointer-events: "auto" ✓ clickable
// offsetWidth/H: 40px × 40px ✓ non-zero — "visible"
// tabIndex: 0 ✓ keyboard reachable
// filter: "brightness(0)" ✗ ONLY this reveals the attack
//
// Detection:
const btns = document.querySelectorAll('[role="button"], button');
btns.forEach(btn => {
const f = getComputedStyle(btn).filter;
if (/brightness\(\s*0(\s*\.?\s*0*)?\s*\)/.test(f)) {
console.warn('Consent button rendering erased by brightness(0):', btn);
}
});
Combined attack surface: Attack 4 is most dangerous when combined with Attack 1 or 2. The consent disclosure panel is hidden by a brightness filter (invisible), and the action buttons are also brightness(0) (indistinguishable black squares). A sighted user who attempts to interact with the consent UI cannot read what they are consenting to (panel hidden) and cannot visually identify which button performs which action (buttons hidden). The MCP server has effectively stripped the consent flow of all visual affordance while leaving it technically "present" in the DOM.
Summary: brightness() attacks on consent UI
| Attack | brightness value | Element type | Visual result | Why scanners miss it |
|---|---|---|---|---|
| Black panel hiding | brightness(0) |
Dark modal with white text | Solid black rectangle — white text channels ×0 = 0, indistinguishable from dark background | getComputedStyle visibility = block, offsetHeight = N px, opacity = 1 |
| White washout | brightness(10) or higher |
Light-mode consent card (dark-on-light text) | Bright white rectangle — colored text and light-gray backgrounds both saturate to #fff | display still block, offsetHeight > 0, opacity = 1, element in flow |
| Animated brightness flash | 1 → 0 in <1 frame | Consent disclosure panel (any style) | Visible for ~1 ms then solid black — user never sees content at 60 fps | Static CSS scan at parse time captures brightness(1); animated final state requires polling or @keyframes inspection |
| Icon button hiding | brightness(0) |
Colored-background icon-only action button | Solid black button square — color, shape cues, and icon all erased | aria-label unchanged, pointer-events auto, role = button, tab order intact; only filter reveals it |
SkillAudit findings for CSS filter: brightness()
filter: brightness(0) on dark-background consent panel collapses the entire rendered element to solid black — white text (channel 255) and the dark background (channel ≈26–46) both multiply to 0. The consent panel is a featureless black rectangle while the element remains in layout, the accessibility tree, and at full offsetHeight.
filter: brightness(≥5) on a light-mode consent element washes all near-white and mid-tone backgrounds and colored text channels to 255. The consent panel becomes a blank white rectangle on a white page — visually absent — while display, visibility, opacity, and layout metrics all report the element as present and visible.
brightness transition from 1 → 0 in a <1-frame window (100 ms duration, 1% keyframe = 1 ms) shows consent for a single rendering frame before permanently hiding it. animation-fill-mode: forwards locks the element at brightness(0) without ever writing to el.style.filter, defeating inline-style checks.
filter: brightness(0) on an icon-only consent button erases the icon's color and shape identity (approve vs. deny), rendering both action buttons as identical black squares. The aria-label and click handler remain active — the button is clickable but visually unidentifiable, undermining informed consent for sighted users.
Defences
Audit getComputedStyle(el).filter for out-of-range brightness values: SkillAudit reads the computed filter property for every consent-critical element (identified by role, data attributes, and position in the permission flow). Any brightness() value outside the range [0.9, 1.1] is flagged: values at or below 0.1 collapse elements toward black; values above 2 risk channel saturation for typical UI color palettes. The check runs both synchronously at DOM insertion time and after two rAF frames to catch animated transitions.
// SkillAudit consent-element brightness check
function auditBrightness(el) {
const f = getComputedStyle(el).filter;
const match = f.match(/brightness\(\s*([\d.]+)\s*\)/);
if (!match) return null;
const n = parseFloat(match[1]);
if (n < 0.9 || n > 1.1) {
return {
severity: (n === 0 || n >= 5) ? 'HIGH' : 'MEDIUM',
value: n,
element: el,
reason: n === 0
? 'brightness(0) collapses all pixels to black'
: n >= 5
? `brightness(${n}) saturates mid-tone and light channels to white`
: `brightness(${n}) substantially reduces or boosts channel values`
};
}
return null;
}
// Also inspect @keyframes for brightness transitions on consent elements:
function auditBrightnessAnimation(el) {
const animName = getComputedStyle(el).animationName;
if (!animName || animName === 'none') return null;
// Enumerate CSSKeyframesRule objects in document.styleSheets
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.type === CSSRule.KEYFRAMES_RULE && rule.name === animName) {
for (const kf of rule.cssRules) {
const match = kf.style.filter?.match(/brightness\(\s*([\d.]+)\s*\)/);
if (match) {
const n = parseFloat(match[1]);
if (n < 0.9 || n > 1.1) return { animName, keyText: kf.keyText, value: n };
}
}
}
}
} catch (_) { /* cross-origin sheet */ }
}
return null;
}
Approximate rendered contrast ratio by compositing CSS colors with brightness(): For consent-critical text elements, SkillAudit extracts the CSS color and background-color values from getComputedStyle and applies the computed brightness(N) multiplier analytically to estimate the effective rendered pixel values. The resulting foreground and background colors are used to compute a WCAG contrast ratio. If the effective contrast ratio falls below 4.5:1, the element is flagged regardless of whether the cause is a brightness filter, a low-contrast color pair, or a combination. For the black-panel attack (white text, brightness 0), the effective foreground is (0,0,0) and background is (0,0,0) — contrast ratio 1:1, flagged HIGH.
// Analytical brightness-composited contrast ratio estimation
function compositeWithBrightness(cssColor, brightnessN) {
// Parse cssColor to [r, g, b] (0–255)
const [r, g, b] = parseCssColor(cssColor);
// Apply brightness multiplier with clamping
const rc = Math.min(255, Math.round(r * brightnessN));
const gc = Math.min(255, Math.round(g * brightnessN));
const bc = Math.min(255, Math.round(b * brightnessN));
return [rc, gc, bc];
}
function relativeLuminance([r, g, b]) {
const s = [r, g, b].map(c => {
c /= 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * s[0] + 0.7152 * s[1] + 0.0722 * s[2];
}
function contrastRatio(L1, L2) {
const [lighter, darker] = L1 > L2 ? [L1, L2] : [L2, L1];
return (lighter + 0.05) / (darker + 0.05);
}
// Usage on a consent element:
// const el = document.querySelector('[data-consent-panel]');
// const n = parseFloat(getComputedStyle(el).filter.match(/brightness\(([\d.]+)\)/)[1]);
// const fg = compositeWithBrightness(getComputedStyle(el).color, n);
// const bg = compositeWithBrightness(getComputedStyle(el).backgroundColor, n);
// const ratio = contrastRatio(relativeLuminance(fg), relativeLuminance(bg));
// if (ratio < 4.5) flagConsentElement(el, ratio);
CSP style-src blocks injection: A strict Content-Security-Policy: style-src 'self' header prevents the MCP server from injecting <style> blocks or inline style attributes containing filter: brightness() rules. This is the strongest defence: if CSS injection is blocked entirely, none of the four attacks can be mounted via stylesheet. Note that element.style.filter set via JavaScript is blocked by a separate script-src directive, not style-src.
Poll consent element filter over time to catch animated attacks: Static one-shot audits miss the animated brightness flash (Attack 3) because the animation completes in under one frame. SkillAudit schedules a secondary check via requestAnimationFrame that re-reads getComputedStyle(el).filter two frames after the element is first observed in the DOM. This catches animation-fill-mode: forwards states that settle to brightness(0) after the animation duration. Additionally, SkillAudit parses the animation-name property and inspects the corresponding @keyframes rule in the CSSOM to detect brightness keyframe values before the animation runs.
Related pages:
CSS filter security (general — timing oracles, feColorMatrix, GPU side-channels) ·
CSS opacity hiding — consent rendered transparent without display:none ·
CSS filter:blur() security — consent text blurred to unreadable without removal