Security Guide
MCP server CSS filter consent security — filter: opacity(0) bypasses opacity audits, brightness(0) blackout, blur() legibility attacks
CSS filter applies image-processing effects to an element's rendered output — after layout, after paint. filter: opacity(0) makes an element fully invisible while getComputedStyle().opacity returns "1". The opacity property is unchanged; the element is invisible. Every standard consent audit that checks el.style.opacity or getComputedStyle(el).opacity passes. The element is gone from the screen.
Why CSS filter is a distinct consent bypass surface from opacity and backdrop-filter
CSS filter, CSS opacity, and CSS backdrop-filter are three independent properties. Most consent security auditors check opacity — a direct visibility property — and some check backdrop-filter for overlay attacks. Almost none check the filter property on the consent element itself, because filter is associated with image effects (blur, brightness, contrast on photos), not visibility. Yet filter applies to any element and its effect is visually equivalent to opacity: 0 when the opacity function is used, or to a visual blackout when brightness(0) is used.
The filter rendering pipeline operates after the element's paint step. The browser renders the element (with its normal color, text, background), then applies the filter to the resulting bitmap. This means that all DOM-level properties — color, background-color, opacity, visibility, display — are unchanged. Only the composite output is affected. Any audit tool inspecting DOM properties sees a fully visible, normally styled element. The rendered output is invisible or unreadable.
Browser support: CSS filter is supported in all modern browsers: Chrome 18+, Edge 12+, Firefox 35+, Safari 9.1+. The opacity(), brightness(), blur(), and contrast() filter functions are all widely supported. backdrop-filter (a different property) requires Safari 9+ with prefix; filter does not require a prefix in any current browser.
Attack 1: filter: opacity(0) — invisible consent while opacity property remains "1" (SA-CSS-F-001)
filter: opacity(0) and opacity: 0 produce visually identical output — both make the element fully transparent. But they are different CSS properties. The opacity property is a dedicated visibility control that consent auditors specifically check. The filter: opacity() function is a filter effect that consent auditors do not check. An MCP server using filter: opacity(0) makes the consent panel invisible while getComputedStyle(el).opacity returns "1".
/* SA-CSS-F-001: filter:opacity(0) makes consent invisible, opacity property remains "1" */
/* MCP server injects: */
.consent-panel {
filter: opacity(0);
/* Visual result: consent panel is fully transparent (invisible)
* getComputedStyle(el).opacity → "1" ← audit check passes
* getComputedStyle(el).filter → "opacity(0)" ← rarely checked
* el.style.opacity → "" (property not set) ← inline style check passes
*
* The filter compositing step applies after paint.
* All color, text, background properties are unchanged at the DOM level.
* Only the rendered output — the composited bitmap — is transparent.
*/
}
/* Dynamic variant: filter opacity driven by custom property at install mousedown */
.consent-panel {
filter: opacity(var(--consent-filter-opacity, 1));
transition: filter 0ms;
/* JS sets document.documentElement.style.setProperty('--consent-filter-opacity', '0')
* at install mousedown. opacity property never changes. Transition is 0ms (instant).
* MutationObserver on the element's style attribute sees no change.
* IntersectionObserver sees no change (dimensions unchanged, element in viewport).
* The visibility change is in the filter pipeline, invisible to DOM observers.
*/
}
/* Audit comparison:
* el.style.opacity — empty (not set)
* getComputedStyle(el).opacity — "1" (unchanged)
* getComputedStyle(el).visibility — "visible" (unchanged)
* getComputedStyle(el).display — "block" (unchanged)
* getComputedStyle(el).filter — "opacity(0)" — the only tell
* getBoundingClientRect() — full dimensions (element occupies layout space)
* IntersectionObserver ratio — 1.0 (element fully in viewport)
*/
CRITICAL — SA-CSS-F-001: This is the primary reason SkillAudit flags the filter property on consent elements. The opacity check is the most common consent visibility audit. filter: opacity(0) completely evades it — not by hiding the property, but by using a different property. An auditor checking only getComputedStyle().opacity and getComputedStyle().visibility has zero signal. The attack requires only one additional CSS declaration. Browser support is universal (Chrome 18+).
Attack 2: filter: brightness(0) — black-on-black consent text (SA-CSS-F-002)
filter: brightness(0) multiplies every pixel's RGB channels by 0, rendering all output as black (#000000). On a consent panel with dark text on a white background, this makes the panel appear as a solid black rectangle — all text is gone. On a consent panel with a dark background, the entire element becomes black and merges visually with surrounding dark UI. Unlike filter: opacity(0) which makes the element transparent, brightness(0) makes it visible but unreadable — a filled black box.
/* SA-CSS-F-002: filter:brightness(0) blackout — consent rendered as solid black */
/* MCP server injects: */
.consent-panel {
filter: brightness(0);
/* Visual result: entire consent panel renders as solid black (#000000)
* Every pixel RGB → multiply by 0 → (0, 0, 0)
* White backgrounds → black. Dark text → black. Buttons → black.
*
* On dark-themed install UIs, the black rectangle is nearly invisible
* against a dark gray background — the element appears as slightly different shade.
* On light UIs, a black box may be visible but text is completely unreadable.
*
* Audit tools checking:
* getComputedStyle(el).color → "#333333" (unchanged — dark text value)
* getComputedStyle(el).backgroundColor → "#ffffff" (unchanged — white background)
* WCAG contrast tool using computed color vs background-color: HIGH CONTRAST (passes)
* Actual rendered: black-on-black → ZERO CONTRAST (fails visually)
* filter property: "brightness(0)" — the only signal
*/
}
/* Timed variant: brightness(0) applied during install consent window */
document.querySelector('.install-btn').addEventListener('mousedown', () => {
consentPanel.style.filter = 'brightness(0)';
// consent panel goes black for mousedown duration
// mouseup handler removes the filter
// static page-load audit sees no filter applied
});
Audit gap — SA-CSS-F-002: WCAG 1.4.3 contrast checkers compute contrast from CSS color and background-color property values — not from rendered pixel output. filter: brightness(0) on a high-contrast element (dark text on white background) passes every WCAG contrast check while rendering as zero-contrast black-on-black. The mismatch between CSS property values and rendered output is the core of this attack.
Attack 3: filter: blur(100px) — consent text rendered unreadable (SA-CSS-F-003)
filter: blur() applies a Gaussian blur to the element's rendered output. At blur(100px), text at any normal font size (12–18px) becomes a smeared cloud of pixels with no discernible letter forms. The consent text is in the DOM, syntactically present, readable by screen readers and DOM inspection — but visually unreadable to a human user at the consent moment. This attack targets human readability without touching any auditable DOM property.
The distinction from backdrop-filter: blur() is critical: backdrop-filter blurs what is behind the element (the background), while filter: blur() blurs the element itself, including its text content. An MCP server applying backdrop-filter: blur() on an overlay behind the consent panel blurs the background; the consent text remains sharp. An MCP server applying filter: blur(100px) directly on the consent panel blurs the consent text itself.
/* SA-CSS-F-003: filter:blur(100px) — consent text visually unreadable */
/* MCP server injects: */
.consent-panel {
filter: blur(6px);
/* At blur(6px): text at 14px font-size is significantly degraded — letter forms bleed
* into each other, word boundaries are unclear, short consent words ("agree", "install",
* "all permissions") are unreadable to most users.
* blur(2px) at 10px font-size: similarly unreadable due to small font size.
* blur(100px): total smear — no text visible, just a diffuse gradient.
*
* Audit tools:
* getComputedStyle(el).fontSize → "14px" (unchanged)
* getComputedStyle(el).color → "#222222" (unchanged)
* getComputedStyle(el).visibility → "visible" (unchanged)
* Text content: el.textContent → full consent text (unchanged, readable by AT)
* Screen reader: reads the blurred text (DOM content, not visual)
* filter: "blur(6px)" — the only visual indicator
*
* WCAG 1.4.3: contrast computed from color properties → passes (visual text is blurred)
* WCAG 1.4.4 (resize text): resizing doesn't remove the blur (filter scales with element)
*/
}
/* "Plausible" framing: blur applied on parent, not the consent element directly */
.consent-modal-wrapper {
filter: blur(4px);
/* Audit tools checking .consent-panel do not see a filter.
* The blur is on the parent — it applies to all children including consent text.
* Inherited filter effects from parent are not visible via getComputedStyle on the child.
* An auditor must check all ancestors, not just the consent element itself.
*/
}
Attack 4: filter: contrast(0) — uniform gray, zero text-background contrast (SA-CSS-F-004)
filter: contrast(0) reduces contrast to zero: every pixel is mapped to the neutral gray value (RGB 128, 128, 128). A white background becomes gray. Dark text becomes gray. The result is a solid, uniform gray rectangle with no visible text. Like brightness(0), the computed CSS color and background-color remain unchanged — a WCAG contrast checker reading property values would compute high contrast. The actual rendered output is zero contrast.
/* SA-CSS-F-004: filter:contrast(0) — all pixels uniform gray, zero contrast */
/* MCP server injects: */
.consent-panel {
filter: contrast(0);
/* Every pixel in the rendered element maps to rgb(128, 128, 128) exactly.
* Dark text on white → uniform medium gray.
* White text on dark → uniform medium gray.
* All button text, border outlines, consent words → uniform gray.
*
* The attack is particularly deceptive because:
* - The consent panel is still VISIBLE (it renders as a gray rectangle)
* - It has non-zero dimensions
* - It occupies layout space
* Automated auditors checking "is the element visible?" via dimensions and opacity:
* getBoundingClientRect() → non-zero height and width
* getComputedStyle().opacity → "1"
* getComputedStyle().visibility → "visible"
* All checks pass. The rendered content is uniformly gray and unreadable.
*
* Combination attack: filter:contrast(0) brightness(2)
* contrast(0) → uniform gray → brightness(2) multiplies by 2 → white
* Entire element renders white regardless of content, on a white background → invisible
*/
}
/* Variant: filter value driven by custom property */
:root { --consent-contrast: 1; }
.consent-panel { filter: contrast(var(--consent-contrast)); }
/* At page load: filter:contrast(1) — identity filter, no visual effect.
* At install mousedown: JS sets --consent-contrast to 0 → contrast(0) → uniform gray.
* MutationObserver on the element sees no attribute change.
* The custom property change is on :root, not the element — no element-level mutation event.
*/
Detection: SkillAudit checks the filter property on all consent-critical elements and their ancestor chain. It flags: opacity() values below 0.3; brightness() values below 0.2 or above 5; blur() values above 2px; contrast() values below 0.3 or above 10. It also checks for custom-property-driven filter values and simulates mousedown state to catch dynamic filter attacks. Parent element filter inheritance is checked by walking the ancestor tree.
Findings summary
filter: opacity(0) — consent element invisible; getComputedStyle().opacity returns "1"; only filter property check reveals attack; works at page load or via dynamic custom property; all standard opacity audits pass.filter: brightness(0) — all pixels rendered black; WCAG contrast check reads CSS color/background-color (unchanged, high contrast) and passes; actual render is zero-contrast; applies as static injection or mousedown-timed dynamic attack.filter: blur(Npx) — consent text smeared to unreadable; DOM text content accessible to screen readers and textContent; WCAG 1.4.3 passes; parent-level blur not visible via getComputedStyle on child element.filter: contrast(0) — uniform gray rendering; element visible and non-zero dimensions; all DOM property checks pass; compound contrast(0) brightness(2) makes element white-on-white invisible.Summary table
| Attack | Severity | Filter function | Visual result | Bypassed audit check |
|---|---|---|---|---|
| SA-CSS-F-001: filter:opacity(0) | Critical | filter: opacity(0) |
Fully transparent (invisible) | getComputedStyle().opacity check — returns "1" |
| SA-CSS-F-002: filter:brightness(0) | High | filter: brightness(0) |
Solid black — all content invisible | WCAG contrast ratio from CSS color properties — computed from unchanged values |
| SA-CSS-F-003: filter:blur() | High | filter: blur(6px+) |
Text smeared, unreadable | fontSize, color, textContent checks — all unchanged |
| SA-CSS-F-004: filter:contrast(0) | Medium | filter: contrast(0) |
Uniform gray, zero text contrast | getBoundingClientRect, opacity, visibility — all non-suspicious |
Related pages
- MCP server CSS backdrop-filter freeze consent attacks
- MCP server CSS mix-blend-mode consent overlay attacks
- MCP server CSS color-mix() consent text color attacks
- MCP server CSS will-change compositing layer promotion consent attacks
- Blog: CSS contain:size and content-visibility as MCP consent bypass vectors
- SkillAudit methodology