Security Guide
MCP server CSS filter: opacity() function security — hidden in filter chain, stacking context difference, scanner gap vs CSS opacity property, and detection
The CSS opacity() filter function is a sub-function of the filter property that applies alpha transparency to rendered output. It produces the same visual result as the opacity CSS property — near-invisible UI elements at low values — but lives in an entirely different property path. A scanner checking element.style.opacity, getComputedStyle(element).opacity, or searching inline style attributes for opacity: will find nothing when the transparency is set via filter: opacity(0.05). The opacity sub-function is embedded inside the filter chain string and is only discoverable by parsing the computed filter value. Compound filter chains like filter: blur(2px) opacity(0.1) add a second layer of detection difficulty: a scanner looking only at the first or primary filter function will see only the blur component and miss the opacity attack entirely.
The two transparency paths — property gap
CSS provides two independent paths to set element transparency:
/* Path 1: CSS opacity property */
.risk-badge { opacity: 0.05; }
/* Detected by:
element.style.opacity → "0.05"
getComputedStyle(element).opacity → "0.05"
element.getAttribute('style') → contains "opacity: 0.05"
Most opacity scanners check this path — it's the expected location. */
/* Path 2: CSS filter opacity() sub-function */
.risk-badge { filter: opacity(0.05); }
/* Detected by:
element.style.opacity → "" (empty — property not set)
getComputedStyle(element).opacity → "1" (the opacity property is still 1)
element.getAttribute('style') → contains "filter: opacity(0.05)" but NOT "opacity:"
getComputedStyle(element).filter → "opacity(0.05)" ← ONLY this path reveals it */
/* Both produce IDENTICAL visual output at 5% opacity.
Both create new stacking contexts (browser-dependent nuances aside).
The scanner gap: a check on the opacity property path MISSES the filter path entirely. */
Scanner gap by design: A scanner author who wrote opacity detection against the CSS opacity property has covered the obvious path but left the filter chain path open. This gap is not an oversight in the scanner — it reflects the fact that opacity and filter: opacity() are two separate CSS properties with separate computed style entries. Both must be checked explicitly.
Attack 1: filter: opacity(0.05) — near-invisible element via filter chain
At opacity(0.05), an element is rendered at 5% alpha — barely visible against any background. A red HIGH RISK badge with filter: opacity(0.05) is rendered as a nearly transparent ghost: its red color occupies 5% of the pixel blending, with 95% of each pixel showing the background. On a white background, the badge appears as a very faint pinkish tint that is below conscious visual attention threshold for users who are not specifically looking for it.
/* Near-invisible badge via filter opacity — evades opacity property scanner */
.risk-badge.high-risk {
filter: opacity(0.05);
/* The badge is physically present in the DOM:
element.textContent === "HIGH RISK" ✓ (DOM intact)
element.style.opacity === "" ✓ (no opacity property set)
element.getBoundingClientRect() returns non-zero ✓ (element has layout)
getComputedStyle(el).opacity === "1" ✓ (opacity property = 1)
Scanner checking CSS opacity property: MISS
Scanner checking filter chain: HIT — filter: opacity(0.05)
Visual: 5% alpha on white background renders as near-white ghost.
User visual system: the badge is effectively invisible below ~10% alpha
under normal viewing conditions (not specifically looking for it). */
}
/* Percentage form: */
.risk-badge { filter: opacity(5%); } /* identical to 0.05 */
/* Detection — MUST check filter chain, not just opacity property */
function checkOpacityAllPaths(element) {
const findings = [];
// Path 1: CSS opacity property (traditional scanner target)
const cssOpacity = parseFloat(getComputedStyle(element).opacity);
if (cssOpacity < 0.1) {
findings.push({ path: 'opacity_property', value: cssOpacity, severity: 'HIGH' });
}
// Path 2: filter opacity() sub-function (scanner gap target)
const filter = getComputedStyle(element).filter;
const filterOpacity = parseFilterOpacity(filter);
if (filterOpacity !== null && filterOpacity < 0.1) {
findings.push({ path: 'filter_opacity_function', value: filterOpacity, severity: 'HIGH',
note: 'Evades CSS opacity property scanners — found in filter chain only' });
}
return findings;
}
function parseFilterOpacity(filterString) {
if (!filterString || filterString === 'none') return null;
const fns = extractFilterFunctions(filterString);
for (const fn of fns) {
if (fn.name === 'opacity') {
const raw = parseFloat(fn.value);
return fn.value.includes('%') ? raw / 100 : raw;
}
}
return null;
}
function extractFilterFunctions(filterString) {
const fns = [];
const re = /(\w+)\(([^)]*)\)/g;
let m;
while ((m = re.exec(filterString)) !== null) {
fns.push({ name: m[1], value: m[2] });
}
return fns;
}
Attack 2: Compound chain filter: blur(2px) opacity(0.1) — double obfuscation
A compound filter chain combines multiple filter functions in sequence. When an MCP server sets filter: blur(2px) opacity(0.1), a scanner that examines the filter chain might identify the blur component and focus on it (is 2px blur significant? maybe a soft-focus effect). If the scanner extracts only the first or primary function from the chain, or performs a simple string check for "blur", it misses the opacity component entirely.
This two-vector compound attack is particularly effective because both components are plausible individually:
blur(2px)— very slight blur that might appear as a styling choice (focus effect, modal blur)opacity(0.1)— 10% alpha, near-invisible, the actual attack vector
Together they create a consent element that is both blurred (making text hard to read even if seen) and near-transparent (making the element nearly invisible). The blur scanner focuses on blur; the opacity scanner focuses on opacity; neither catches the compound attack in a single pass.
/* Compound attack: blur + opacity in filter chain */
.permission-section {
filter: blur(2px) opacity(0.1);
/* Component analysis:
blur(2px): 2px blur radius — modest, might appear as focus effect
opacity(0.1): 10% alpha — near-invisible, the actual concealment attack
Scanner checking only blur values: finds blur(2px) → might flag as LOW/marginal
Scanner checking only opacity property: finds nothing (opacity property = 1)
Scanner tokenizing filter chain and checking ALL functions: finds BOTH — HIT on opacity(0.1)
Visual effect: the permission section is near-transparent AND text is blurred.
A user who notices the faint ghost-like rendering sees both a transparency and
a text-blur — both make the content harder to see and read. */
}
/* More complex compound: */
.risk-badge {
filter: brightness(1.02) blur(1px) opacity(0.08) saturate(0.9);
/* opacity(0.08) is buried in position 3 of a 4-function chain.
A scanner that stops after finding brightness(1.02) ≈ normal misses the rest. */
}
/* Detection: tokenize entire chain, check EVERY function */
function auditFullFilterChain(element) {
const filter = getComputedStyle(element).filter;
if (!filter || filter === 'none') return [];
const fns = extractFilterFunctions(filter);
const findings = [];
for (const fn of fns) {
const v = parseNormalized(fn.value);
switch (fn.name) {
case 'opacity':
if (v < 0.1) findings.push({ fn: 'opacity', v, severity: 'HIGH',
message: `filter:opacity(${v}) — near-invisible; evades CSS opacity property scanners` });
else if (v < 0.3) findings.push({ fn: 'opacity', v, severity: 'MEDIUM',
message: `filter:opacity(${v}) — ${Math.round(v*100)}% alpha on consent element` });
break;
case 'blur':
if (v >= 4) findings.push({ fn: 'blur', v, severity: 'HIGH',
message: `filter:blur(${v}px) — text unreadable (≥4px blur threshold)` });
else if (v >= 2) findings.push({ fn: 'blur', v, severity: 'MEDIUM',
message: `filter:blur(${v}px) — text legibility reduced` });
break;
case 'grayscale':
if (v > 0.3) findings.push({ fn: 'grayscale', v, severity: 'HIGH' });
break;
case 'saturate':
if (v < 0.3 || v > 2.0) findings.push({ fn: 'saturate', v, severity: 'HIGH' });
break;
case 'sepia':
if (v > 0.3) findings.push({ fn: 'sepia', v, severity: 'MEDIUM' });
break;
case 'invert':
if (v > 0.1) findings.push({ fn: 'invert', v, severity: v > 0.35 && v < 0.65 ? 'CRITICAL' : 'HIGH' });
break;
case 'contrast':
if (v < 0.2 || v > 5) findings.push({ fn: 'contrast', v,
severity: v > 20 || v < 0.1 ? 'HIGH' : 'MEDIUM' });
break;
case 'brightness':
if (v < 0.1 || v > 5) findings.push({ fn: 'brightness', v, severity: 'HIGH' });
break;
}
}
return findings;
}
function parseNormalized(valueStr) {
const raw = parseFloat(valueStr);
return valueStr.includes('%') ? raw / 100 : raw;
}
The comprehensive filter audit function: The auditFullFilterChain() function above is the correct approach to CSS filter security scanning — it tokenizes the entire chain and checks every function against its own threshold. This single pass covers blur, opacity, grayscale, saturate, sepia, invert, contrast, and brightness — eight attack vectors in one traversal. No single-function check is sufficient.
Attack 3: Stacking context difference — the opacity property vs. filter opacity()
The CSS opacity property always creates a new stacking context in all browsers. The filter: opacity() function also creates a new stacking context, but via the filter rendering path rather than the opacity property path. In some edge cases, the stacking context behavior differs between the two paths in terms of how they interact with z-index, fixed positioning descendants, and composite layer promotion. An MCP server that specifically needs to manipulate z-index layering of the consent dialog (to ensure a deceptive overlay appears above the consent content) may prefer filter: opacity() over the opacity property for precise control over stacking context creation timing in the rendering pipeline.
This is a subtle technical note: for pure transparency attacks, the practical difference between the two paths is the scanner gap, not the stacking context behavior. The stacking context difference is an advanced variant where the attacker also needs fine control over layer compositing order.
/* Stacking context note — when the difference matters */
/* Both create stacking contexts: */
.consent-el { opacity: 0.5; } /* stacking context via opacity property */
.consent-el { filter: opacity(0.5); } /* stacking context via filter property */
/* The filter path also creates a stacking context for ALL other filter functions:
filter: blur(1px) already creates one; adding opacity() does not add a second.
In contrast, setting opacity:0.5 creates a stacking context even with no filter set.
Consequence: an element with filter: blur(1px) opacity(0.1) has ONE stacking context
(from the filter property) and getComputedStyle().opacity === "1".
An element with opacity: 0.1 has ONE stacking context (from opacity property)
and getComputedStyle().opacity === "0.1".
The filter path gives the attacker a single stacking context that handles BOTH
the blur and the opacity effect, while the opacity-property scanner path shows
no transparency. */
Summary
| Detection method | CSS opacity property | filter: opacity() function | Coverage |
|---|---|---|---|
element.style.opacity |
HIT (if inline style) | MISS | Incomplete |
getComputedStyle(el).opacity |
HIT | MISS (returns "1") | Incomplete |
| Style attribute grep for "opacity:" | HIT (if inline) | MISS | Incomplete |
getComputedStyle(el).filter tokenized |
MISS | HIT | Incomplete |
| Both opacity property AND filter chain checked | HIT | HIT | Complete |
SkillAudit findings for CSS filter: opacity()
filter:opacity(0.05) on consent UI elements renders them at 5% alpha — effectively invisible against typical backgrounds — while evading all scanners that check the CSS opacity property. getComputedStyle(element).opacity returns "1" (unchanged) for an element with filter: opacity(0.05). Detection requires parsing the computed filter property string and checking for the opacity() sub-function inside the chain.
filter: blur(2px) opacity(0.1) bury the opacity attack alongside other filter functions. A scanner that extracts only the first filter function, or searches for a specific known-bad function pattern, will find the blur component but miss the opacity component. Correct detection requires tokenizing the full filter string and checking every function's argument independently.
filter:opacity() values below 0.3 (30% alpha) meaningfully compromise the visibility of consent UI elements. Values below 0.1 render elements effectively invisible; values between 0.1 and 0.3 reduce visibility enough to compromise casual-scan detection by users under time pressure. Both ranges should be flagged, with severity scaled to the opacity level.
filter: opacity() function have different property paths but identical visual effects at equal values. Security scanners must check both paths independently. The filter path also allows combining opacity with other visual attacks (blur, grayscale, etc.) in a single filter declaration, compounding the attack surface while maintaining a single stacking context.
Defences
Dual-path opacity detection: SkillAudit checks element transparency via two independent paths in every consent UI audit: (1) getComputedStyle(element).opacity for the CSS opacity property, and (2) parsing the computed filter string for an opacity() sub-function. Both paths use the same threshold (flag values below 0.3, HIGH severity below 0.1) and both are checked on the element and its ancestor chain.
Full filter chain tokenization: The filter chain parser tokenizes the complete filter string into all constituent function tokens before checking any individual function. This ensures that filter: brightness(1.02) blur(0.5px) opacity(0.08) saturate(0.95) — a four-function chain designed to bury the opacity attack — is correctly analyzed and the opacity(0.08) component is flagged despite its position mid-chain.
Ancestor chain traversal: filter: opacity() on an ancestor element reduces the rendered alpha of all child content while reporting getComputedStyle(child).filter === 'none' on all child elements. SkillAudit walks the ancestor chain from each consent-critical leaf element to the document root, checking each ancestor for opacity (both property and filter function paths).
Eight-function comprehensive check: SkillAudit's filter chain auditor checks all eight CSS filter functions that can affect consent UI legibility in a single pass: opacity(), blur(), grayscale(), saturate(), sepia(), invert(), contrast(), and brightness(). Each function has its own threshold and severity rules, and all are evaluated from a single tokenized parse of the computed filter string.
Related: CSS filter security overview · CSS filter blur security · CSS filter brightness security · CSS filter contrast extreme security