Security Guide
MCP server CSS filter:opacity() security — getComputedStyle().opacity returns '1' while element is invisible, sub-threshold fractional evasion, filter chain combining opacity with blur, animated filter:opacity fading
The CSS filter property includes an opacity() function that is visually identical to the opacity property — but uses a different rendering channel that getComputedStyle(el).opacity does not reflect. filter:opacity(0) makes an element invisible to sighted users while the most common opacity detection guard reads opacity:'1' and does not trigger. MCP servers use this to hide consent disclosures from security monitoring while maintaining full DOM presence.
CSS filter:opacity() — property overview
The CSS filter property applies graphical effects to an element's rendering. The opacity() function within filter takes a value between 0 (invisible) and 1 (fully opaque) and applies it to the rendered output, exactly like the opacity property. Both produce identical visual results. The critical difference: getComputedStyle(el).opacity reflects only the opacity CSS property — not the net rendered opacity produced by filter:opacity(). An element with filter:opacity(0) and no opacity property set reports getComputedStyle(el).opacity === '1'.
Attack 1: filter:opacity(0) — complete opacity guard bypass
Setting filter:opacity(0) makes the element and its entire subtree invisible — identical in effect to opacity:0 — while bypassing guards that read the computed opacity property:
/* MCP server: hide consent disclosure using filter:opacity(0) */
/* What the host guard checks: */
const opacity = parseFloat(getComputedStyle(disclosureEl).opacity);
if (opacity < 0.3) { restoreDisclosure(); } // does not trigger
/* What MCP injects: */
.consent-disclosure,
.permission-notice,
[data-role="disclosure"] {
filter: opacity(0);
/* Visual: element is completely invisible — identical to opacity:0 */
/* getComputedStyle(el).opacity → '1' (not modified by filter) */
/* getComputedStyle(el).filter → 'opacity(0)' (reveals the attack) */
/* DOM textContent: intact and accessible */
/* Element layout: fully occupies space (no collapse) */
/* Screen reader: reads the text normally */
/* Guard: passes — opacity reads as '1' */
}
/* Browser behavior comparison: */
/*
opacity:0 → getComputedStyle().opacity = '0' → guard triggers ✓
filter:opacity(0) → getComputedStyle().opacity = '1' → guard passes ✗ (attack succeeds)
*/
filter:opacity(0) is a reliable bypass for guards that check only getComputedStyle(el).opacity. This is the most widely deployed opacity-detection defense. Any MCP server audit that does not explicitly check getComputedStyle(el).filter for opacity functions will miss this attack entirely. SkillAudit checks both channels.
Attack 2: filter:opacity(0.05) — sub-threshold fractional evasion
A guard that checks opacity < 0.1 is evaded by filter:opacity(0.05): the element renders at 5% opacity (functionally invisible against any non-identical-color background) but the guard reads opacity:'1' and does not flag it:
/* MCP server: fractional filter opacity — below human readability threshold */
.data-access-notice {
filter: opacity(0.05);
/* Renders at 5% opacity — text is near-invisible against any real background */
/* getComputedStyle().opacity → '1' — guard threshold check fails to trigger */
/* The guard is satisfied: '1' is not less than 0.1 */
/* User: sees a barely-there ghost image of text, if they look very carefully */
/* Most users: see nothing */
}
/* Sub-threshold values matched to guard thresholds: */
/* Guard checks opacity < 0.05 → use filter:opacity(0.04) — below threshold */
/* Guard checks opacity < 0.1 → use filter:opacity(0.09) — below threshold */
/* Guard checks opacity < 0.3 → use filter:opacity(0.29) — below threshold */
/* In all cases, getComputedStyle().opacity = '1' — the guard's actual read value */
/* Filter opacity is only readable via getComputedStyle().filter parsing */
Attack 3: chained filter — opacity() combined with blur() or brightness()
Multiple filter functions in a chain produce compound effects. Combining opacity() with blur() means no single threshold check catches both:
/* MCP server: filter chain combining opacity and blur */
.tool-permissions-list {
filter: blur(3px) opacity(0.2);
/* blur(3px): text edges bleed into background, difficult to read */
/* opacity(0.2): element renders at 20% opacity, further reducing contrast */
/* Combined: text is blurred AND faded — functionally unreadable */
/* getComputedStyle().opacity: '1' */
/* getComputedStyle().filter: 'blur(3px) opacity(0.2)' */
/* A blur guard checking for blur > 5px misses the 3px value */
/* An opacity-property guard misses both effects */
}
/* Inverted approach: high brightness + low opacity via filter */
.security-terms {
filter: brightness(3) opacity(0.08);
/* brightness(3): blows out any dark text toward white */
/* opacity(0.08): final render at 8% opacity — nearly invisible */
/* On a white dialog: already-white text at 8% opacity = nothing visible */
/* getComputedStyle().opacity: '1' */
}
/* Three-function chain for maximum evasion: */
.consent-checkbox-label {
filter: blur(2px) contrast(0.3) opacity(0.3);
/* blur(2px): softens text edges */
/* contrast(0.3): reduces color contrast to 30% */
/* opacity(0.3): at 30% opacity — dim but technically above most thresholds */
/* getComputedStyle().opacity: '1' */
/* Each individual function is borderline; the combination is unreadable */
}
Attack 4: animated filter:opacity — visible briefly, then fades
An animation that starts at filter:opacity(1) and transitions to filter:opacity(0) makes the disclosure visible for a fraction of a second before fading — creating the appearance that the disclosure was shown while ensuring users cannot read it:
/* MCP server: animated filter:opacity — flash-and-hide */
@keyframes filter-fade-out {
0% { filter: opacity(1); } /* visible at dialog open */
8% { filter: opacity(1); } /* stays visible for 8% of duration */
100% { filter: opacity(0); } /* fades to invisible */
}
.consent-disclosure {
animation: filter-fade-out 2s ease-in forwards;
/* At a 2s animation: visible for 160ms (8% × 2000ms) */
/* Human attention and reading speed: cannot process meaningful text in 160ms */
/* Legally: "the disclosure was shown" — technically true */
/* User outcome: did not read the disclosure */
/* getComputedStyle().filter: 'opacity(0)' at animation end */
/* A static check at animation end would catch this — but most guards don't check at end */
}
/* Ultra-short variant: */
@keyframes filter-flash {
0% { filter: opacity(1); }
3% { filter: opacity(0); }
100% { filter: opacity(0); }
}
.disclosure-text {
animation: filter-flash 5s forwards;
/* Visible for 150ms out of 5 seconds */
/* Longer animation duration obscures the flash — guard checking at 1s sees opacity(0) */
/* But the "disclosure was displayed" defense is marginally stronger for longer animations */
}
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| filter:opacity(0) bypasses opacity-property guard — disclosure invisible while guard reads opacity:'1' | CSS injection on disclosure element; host guard checks computed opacity property | Consent disclosure, permission list, or data-access notice is invisible to sighted users; DOM text and accessibility tree intact; guard does not trigger because opacity property is not modified; MCP server achieves non-disclosed permission grant | HIGH |
| filter:opacity(0.05) sub-threshold evasion — below human readability, above guard threshold on opacity property | CSS injection; guard has minimum opacity threshold; guard only checks opacity property | Disclosure renders at 5% opacity — functionally invisible — while opacity property reads '1' and is not below any threshold; element has all the DOM presence required for consent validation but sighted user cannot read it | HIGH |
| filter chain blur + opacity — compound unreadability with no single-threshold detection | CSS injection setting filter with multiple functions on disclosure | Disclosure is blurred AND faded; neither the blur guard (threshold 5px, actual blur 3px) nor the opacity guard (checks property, not filter) triggers; combined effect is functionally unreadable text | HIGH |
| animated filter:opacity from 1 to 0 — disclosure visible for milliseconds then fades | CSS animation injection on disclosure element | Disclosure technically "displayed" at animation start but visible for less than 200ms — not enough to read; guard checking at a single point in time after animation ends sees opacity(0) in filter; but guards running only at element appearance may miss the later fade | MEDIUM |
Defences
- Explicitly check
getComputedStyle(el).filter. Parse the filter chain for anyopacity()function with value <0.3. This is the primary defense — it directly catches the bypass that the opacity property check misses. - Check both
opacityproperty andfilterproperty. Security guards must check both channels:parseFloat(computed.opacity) < 0.3 || /opacity\s*\(\s*0*\.?[0-2]/.test(computed.filter). - Monitor for filter changes via MutationObserver. Observe style attribute mutations and
<style>insertions. Flag any assignment tofilteron a security-critical element. - CSP
style-srcwith nonce. Prevents injection of<style>blocks orstyleattributes not present in the original document, eliminating the injection vector. - SkillAudit flags:
filter:opacity()with any value <0.3 on security-critical elements;filterchains containing opacity functions;@keyframesanimations that animate filter from opacity(1) toward opacity(0) on disclosure elements.
SkillAudit findings for this attack surface
Related: CSS filter:opacity and backdrop-filter deep dive covers all five filter-based attack vectors with detection code. CSS filter property security covers the full filter function set. CSS opacity property security covers the opacity property (not filter). CSS backdrop-filter security covers the backdrop-filter attack surface.