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 */
}
AttackPrerequisiteWhat it enablesSeverity
filter:opacity(0) bypasses opacity-property guard — disclosure invisible while guard reads opacity:'1'CSS injection on disclosure element; host guard checks computed opacity propertyConsent 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 grantHIGH
filter:opacity(0.05) sub-threshold evasion — below human readability, above guard threshold on opacity propertyCSS injection; guard has minimum opacity threshold; guard only checks opacity propertyDisclosure 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 itHIGH
filter chain blur + opacity — compound unreadability with no single-threshold detectionCSS injection setting filter with multiple functions on disclosureDisclosure 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 textHIGH
animated filter:opacity from 1 to 0 — disclosure visible for milliseconds then fadesCSS animation injection on disclosure elementDisclosure 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 fadeMEDIUM

Defences

SkillAudit findings for this attack surface

HIGHfilter:opacity(0) bypasses opacity-property guard on consent disclosure: MCP server injects filter:opacity(0) on the consent disclosure element; getComputedStyle().opacity returns '1'; the host's opacity guard does not trigger; sighted users cannot see the disclosure; DOM text and accessibility output are intact; permission grant proceeds with user unaware of the disclosure content
HIGHfilter:opacity(0.05) sub-threshold fractional evasion — disclosure at 5% opacity while opacity property reads '1': MCP server sets filter:opacity(0.05) on disclosure; element renders at 5% opacity (functionally invisible); guard threshold check on opacity property finds '1' — not below threshold; disclosure is not flagged; user cannot read the disclosure but the guard considers it visible
HIGHfilter chain blur(3px) opacity(0.2) — compound unreadability evading individual thresholds: MCP server injects filter:blur(3px) opacity(0.2); blur guard threshold is 5px (3px not flagged); opacity-property guard reads '1' (not flagged); combined blur and opacity makes disclosure functionally unreadable; no single guard triggers; user cannot read the consent terms
MEDIUManimated filter:opacity — disclosure visible 160ms then fades to invisible via @keyframes: MCP server animates filter from opacity(1) to opacity(0) over 2 seconds; disclosure is technically visible for the first 160ms; user cannot read a consent disclosure in 160ms; static guard checking computed style at animation midpoint or end sees opacity(0) in filter; animated attacks require continuous monitoring to detect reliably

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.

← Blog  |  Security Checklist