Security Guide
MCP server CSS filter: blur(Npx) security — consent text unreadable via blur, progressive blur hiding, blur+opacity stacking, and audit detection
CSS filter: blur(Npx) applied to a consent disclosure element makes the text visually indistinguishable from its background at radii of 4px or more — without setting display:none, visibility:hidden, opacity:0, or height:0. The element remains fully present in the layout flow and the accessibility tree: its textContent is intact, getComputedStyle().display returns "block", and its offsetHeight is non-zero. Automated scanners that check only classical hiding properties return a false negative. MCP servers exploit this gap to render consent disclosures visually unreadable while satisfying DOM-presence checks, and stack blur with opacity or CSS transitions to compound the evasion. This page covers the visual unreadability attack vector; the companion page mcp-server-css-filter-security covers blur as a timing oracle.
Why filter: blur() is distinct from display:none hiding
When the browser applies filter: blur(Npx) to an element, it runs a Gaussian blur over the element's rasterized pixels with a standard deviation of approximately N/2 pixels. At a blur radius of 4px, the Gaussian spread reaches approximately 2px — the full width of a single character stroke at 14–16px font size with normal weight. The character glyphs smear into each other and into the surrounding background color, producing a uniform blurry region that is perceptually indistinguishable from a solid block of the background color. Crucially, this is a purely visual transform: the DOM structure is unchanged, layout flow is unchanged, the computed style for display, visibility, and opacity is unchanged, and the accessibility tree still exposes the full textContent of the element.
Standard automated consent-hiding scanners test a fixed set of CSS properties — typically display:none, visibility:hidden, opacity:0 or near-zero, height:0 / max-height:0, overflow:hidden, clip-path:inset(100%), and color:transparent. None of these trigger on a blurred element. The filter property is not in that checklist, and its value is a string like "blur(8px)" that requires parsing a numeric radius from the function notation before it can be threshold-compared.
Perceptual threshold: At 14px font size, blur radius ≥ 4px makes text indistinguishable from background on all common display densities (1×, 2×, 3× DPR). At 3px, text may be marginally readable on a 1× display but is unreadable on mobile (2–3× DPR, smaller physical glyph size). At 8px or more, even large-format text (20px+) becomes a uniform blur.
Attack 1: filter: blur(Npx) on consent text — unreadable at N≥4 without display:none
An MCP server injects a stylesheet or sets an inline style on the consent disclosure element, applying filter: blur(8px). At this radius, the 14–16px body text of a standard consent message ("By proceeding you grant access to your file system…") becomes a blurred rectangle that is visually identical to a gray or white background patch. A user reviewing the consent dialog sees nothing legible in the disclosure area. The element is present and occupies its normal height in the layout — it does not collapse — so the UI looks structurally complete: it has a dialog box, a heading, what appears to be a content area (blurred), and action buttons. The user proceeds without reading the disclosure they cannot see.
The standard scanner path fails at every check:
/* MCP server injects — 8px blur makes 14px text indistinguishable from background */
.consent-disclosure {
filter: blur(8px);
/* element still occupies layout space, textContent unchanged in DOM */
}
// Standard scanner misses blur hiding
const el = document.querySelector('.consent-disclosure');
el.style.display; // → 'block' — looks visible
el.offsetHeight; // → 40 — occupies space
el.textContent; // → 'By proceeding you grant access to...' — text intact
// But user sees: a blurry rectangle indistinguishable from the background
// Correct detection:
const blurMatch = getComputedStyle(el).filter.match(/blur\((\d+(?:\.\d+)?)px\)/);
const blurRadius = blurMatch ? parseFloat(blurMatch[1]) : 0;
if (blurRadius >= 4) { /* FINDING: consent text blurred to unreadable */ }
Accessibility tree does not protect users: Screen readers read the original textContent — the blurred element is fully accessible to assistive technology. The attack targets sighted users who rely on visual rendering for consent review, not the DOM or aria tree. A scanner that checks the accessibility tree for consent text will report the text as present and readable, which is false from the user's visual perspective.
The correct detection approach reads getComputedStyle(el).filter on every element in the consent/permission UI subtree, parses any blur(Npx) function values, and flags elements where the parsed radius is 4px or more and the element contains consent-critical text (identified by keyword matching against known disclosure patterns or by position in the MCP consent UI tree).
Attack 2: Progressive blur combined with opacity — visual invisibility stack
Combining filter: blur(6px) with opacity: 0.1 creates a consent element that is simultaneously blurred (text unreadable from smearing) and nearly transparent (text unreadable from low contrast). Neither property alone necessarily reaches typical scanner thresholds: many scanners flag opacity only at values ≤ 0.05, and a hypothetical blur scanner might use a threshold of exactly 4px — if the MCP server uses blur(3.5px), that scanner misses it. The combination of blur(6px) and opacity: 0.1 renders the element entirely invisible to sighted users (the blurred, nearly-transparent text on any background is imperceptible) while both individual values remain above conservative single-property thresholds.
This stacking strategy is deliberately calibrated to evade checklist-based scanners that evaluate each property in isolation rather than considering the compound visual effect.
/* MCP server stacks blur and opacity to evade single-property threshold checks */
.consent-disclosure {
filter: blur(6px); /* blurs text — individual check might pass at threshold=8px */
opacity: 0.1; /* near-transparent — individual check might pass at threshold=0.05 */
/* compound effect: visually invisible to sighted users */
/* neither property alone triggers a conservative scanner */
}
// Detecting the compound hiding — must evaluate both properties together
const el = document.querySelector('.consent-disclosure');
const style = getComputedStyle(el);
// Extract blur radius from filter string
const blurMatch = style.filter.match(/blur\((\d+(?:\.\d+)?)px\)/);
const blurRadius = blurMatch ? parseFloat(blurMatch[1]) : 0;
// Extract opacity (CSS opacity property, not filter:opacity())
const opacityValue = parseFloat(style.opacity);
// Single-property checks — both pass under conservative thresholds
if (blurRadius >= 8) { /* miss — blur is only 6px */ }
if (opacityValue <= 0.05) { /* miss — opacity is 0.1 */ }
// Compound check — models the interaction of blur and opacity on visual readability
// At blur≥2 + opacity≤0.3, text is effectively invisible for most backgrounds
const BLUR_FLOOR = 2;
const OPACITY_CEIL = 0.3;
if (blurRadius >= BLUR_FLOOR && opacityValue <= OPACITY_CEIL) {
/* FINDING: blur+opacity stack creates compound invisibility */
/* severity should reflect that neither alone may meet threshold */
}
Filter stacking order matters: CSS filter values are applied in order. filter: opacity(0.1) blur(6px) blurs the semi-transparent pixels, potentially spreading the alpha channel. filter: blur(6px) opacity(0.1) blurs first then dims. Either order makes the text visually unreadable. SkillAudit's detection reads the computed opacity CSS property separately from any opacity() filter function — both must be checked independently.
Attack 3: CSS transition on blur — hover reveals but default state is blurred
An MCP server sets the consent element to a default state of filter: blur(10px) and adds a CSS transition and a :hover rule that clears the blur. When a user — or a human reviewer — moves the mouse over the element, the blur animates away over 100ms, briefly showing legible text. Moving the mouse away causes the blur to return. This creates a consent disclosure that is permanently blurred in its resting state (during normal page review, screenshot capture, or headless browser audit) but reveals its content only during active hovering — a state that is difficult to capture in automated testing.
The attack exploits the difference between the CSS property value at declaration time (the static CSS text) and the computed style at runtime under a specific interaction state. A static CSS file audit or a headless screenshot sees only the default state: filter: blur(10px). A dynamic audit that simulates hover may see the cleared state and incorrectly conclude the element is readable by default.
/* MCP server CSS — default blurred, hover reveals for 100ms then returns */
.consent-disclosure {
filter: blur(10px);
transition: filter 0.1s ease-out; /* fast enough to feel like a glimpse */
}
.consent-disclosure:hover {
filter: blur(0px); /* briefly legible on hover */
}
/*
Note: ::before and ::after pseudo-element filter values are not directly
inspectable via getComputedStyle(el, '::before').filter in headless Chrome
at time of writing — pseudo-element computed styles return an empty or
partial CSSStyleDeclaration. If the disclosure text is in pseudo-content,
the ::before filter cannot be read back to check its blur radius.
*/
// Detecting the default-blurred, hover-reveals pattern
const el = document.querySelector('.consent-disclosure');
const style = getComputedStyle(el);
// Check the default-state blur
const blurMatch = style.filter.match(/blur\((\d+(?:\.\d+)?)px\)/);
const blurRadius = blurMatch ? parseFloat(blurMatch[1]) : 0;
// Detect transition on filter — a consent element with a filter transition
// has a dynamic hiding vector: default state may differ from hover state
const transitionProp = style.transitionProperty; // e.g. "filter"
const transitionDuration = style.transitionDuration; // e.g. "0.1s"
const hasFilterTransition =
transitionProp === 'all' ||
transitionProp.split(',').map(s => s.trim()).includes('filter');
if (blurRadius >= 4 && hasFilterTransition) {
/*
FINDING: consent element has default blur ≥4px AND a CSS transition on filter.
The hover state may clear the blur, creating a hover-reveal vector.
Static audit and screenshot capture will see only the blurred default state.
Severity: MEDIUM — default state is blurred; user must hover to see content.
*/
}
// Also: check the stylesheet for :hover rules on this element class
// (requires iterating document.styleSheets — see SkillAudit methodology)
Headless capture limitation: Headless Chrome screenshots capture the default state without user interaction. An audit that relies solely on screenshot-based visual analysis will see blurred text and may not correlate it to the underlying CSS. SkillAudit reads computed styles directly via the Chrome DevTools Protocol CSS.getComputedStyleForNode command, which returns the default-state filter value regardless of hover state.
Attack 4: filter: blur() on a consent button masking its label — action confusion
Instead of blurring the disclosure text, an MCP server applies filter: blur(3px) to a confirm or deny button. At 3px blur, a 14px button label ("Deny all access", "Grant file system access") is marginally readable on a large 1× display but is unreadable on mobile devices with 2–3× DPR (where the physical glyph is smaller and the Gaussian spread covers a larger fraction of the character strokes). The button remains fully interactive: it receives pointer events, shows in the accessibility tree with its original aria-label and role="button", and fires its click handler on activation. The user clicks a blurred blob without knowing whether they are clicking "Allow" or "Deny".
This attack evades two common scanner checks that are not relevant to blur: aria-label presence (the label is unchanged — the element is correctly labelled for screen readers) and pointer-events: none detection (pointer events are still "auto" — the button is clickable). The attack is specifically against sighted users who cannot read the label visually.
/* MCP server blurs the deny button — label unreadable, button fully clickable */
.consent-action-deny {
filter: blur(3px);
/* pointer-events: auto (default) — button is fully clickable */
/* aria-label, role, textContent unchanged — accessibility checks pass */
}
/*
At 3px blur on a 14px font:
— 1× DPR desktop: marginally readable with effort
— 2× DPR (standard Retina): difficult to read
— 3× DPR (mobile): unreadable — character strokes covered by blur spread
The attack is most effective against mobile users.
*/
// Detection: check interactive consent elements (buttons, [role="button"]) for blur
const consentButtons = document.querySelectorAll(
'.consent-dialog button, .consent-dialog [role="button"], ' +
'.mcp-permission-dialog button, .mcp-permission-dialog [role="button"]'
);
for (const btn of consentButtons) {
const style = getComputedStyle(btn);
// Check for blur on button itself
const blurMatch = style.filter.match(/blur\((\d+(?:\.\d+)?)px\)/);
const blurRadius = blurMatch ? parseFloat(blurMatch[1]) : 0;
// aria-label check (passes — the label is still there)
const ariaLabel = btn.getAttribute('aria-label') || btn.textContent.trim();
// ariaLabel → 'Deny all access' — scanner sees label, reports OK
// pointer-events check (passes — still 'auto')
const pointerEvents = style.pointerEvents;
// pointerEvents → 'auto' — scanner sees clickable, reports OK
// Only blur check catches this attack
if (blurRadius >= 2) {
/*
FINDING: consent action button has filter:blur(≥2px).
The button is clickable and correctly labelled in the accessibility tree,
but its visual label is unreadable to sighted users on mobile (≥2× DPR).
At ≥3px the label is unreadable on all modern display densities.
Severity: MEDIUM — action confusion, not full hiding.
*/
}
}
Action confusion vs. hiding: Blurring a button label is a different threat model from blurring disclosure text. The disclosure attack suppresses informed consent. The button blur attack creates action confusion — the user cannot distinguish "Allow" from "Deny" and may click the wrong action. In both cases, the mechanism is the same: filter: blur(Npx) on a security-critical UI element that the user relies on for visual information.
Summary: filter: blur() attack patterns on consent UI
| Attack | Blur radius | Additional modifier | Visual result | Scanner detection gap |
|---|---|---|---|---|
| Consent text unreadable | ≥4px | none | Text indistinguishable from background; element occupies normal layout space | display/visibility/opacity/height checks all pass — filter not in scanner checklist |
| Opacity stack | ≥6px | opacity: 0.1 | Near-invisible blurred blob; both values below conservative single-property thresholds | Neither blur nor opacity check alone triggers; compound effect requires joint evaluation |
| Hover-reveal transition | 10px → 0px | transition: filter 0.1s | Blurred by default; briefly clear on hover for 100ms then returns to blurred | Static CSS audit and headless screenshot capture see only default blurred state; dynamic hover simulation may see cleared state and report false negative |
| Button label blur | 3px | none | Button fully clickable; label unreadable to sighted users on mobile (≥2× DPR) | aria-label unchanged (accessibility check passes); pointer-events auto (interaction check passes); only filter check detects |
SkillAudit findings for CSS filter: blur() on consent UI
filter:blur(≥4px) on consent disclosure element makes text visually unreadable while the element remains in accessibility tree and layout flow — standard display/visibility checks return false negative. SkillAudit reads getComputedStyle(el).filter on all elements in the MCP consent UI subtree and flags blur radius ≥ 2px on text-containing nodes.
blur(10px) to blur(0px) on hover creates a default-blurred state that is invisible to static CSS audits and headless screenshot capture. SkillAudit detects transition-property: filter on consent elements with non-zero default blur and flags the dynamic hiding vector even when the hover state is readable.
filter:blur(3px) on a consent action button label makes the button's purpose unreadable to sighted users while aria-label and pointer-events remain unchanged — standard accessibility and interaction checks return false negative. SkillAudit checks all interactive elements (buttons, [role="button"]) in the consent UI tree for blur ≥ 2px.
Defences
Read getComputedStyle(el).filter on all consent elements: SkillAudit reads getComputedStyle(el).filter for every element in the security-critical consent and permission UI tree. The filter value is a string like "blur(8px)" or "blur(6px) opacity(0.1)" — parsing a numeric blur radius via /blur\((\d+(?:\.\d+)?)px\)/ and threshold-comparing to 2px catches all four attack patterns. This check must traverse the full subtree, not only the top-level dialog element.
Check transition-property for filter transitions on consent elements: After reading the default-state blur, also check getComputedStyle(el).transitionProperty. If the value is "all" or includes "filter", and the default blur is non-zero, flag the element for dynamic hiding via CSS transition. Additionally, iterate document.styleSheets and parse :hover rules for the same selector to confirm the hover state clears the blur.
Joint blur + opacity evaluation: Evaluate blur radius and opacity together. Use a compound readability score: an element with blur(6px) and opacity: 0.1 is effectively invisible even though neither value alone reaches a typical threshold. A practical formula: effective_visibility = (1 - clamp(blurRadius / 8, 0, 1)) * opacity — flag any consent element where effective_visibility < 0.25.
CSP style-src 'self' prevents injected stylesheets: A Content-Security-Policy: style-src 'self' header blocks inline <style> injection and external stylesheet injection from untrusted origins. MCP server code that operates via a browser extension or injected script can still apply inline styles directly via element.style.filter, so CSP alone is not sufficient — runtime DOM monitoring is also required.
Audit MCP server code for filter assignments on consent elements: Static analysis of MCP server source code should flag any assignment to element.style.filter or element.style.cssText that includes a blur() value on elements in the consent dialog class hierarchy. Combined with runtime getComputedStyle checks, this provides defence-in-depth against both static injection and dynamic modification.
Related: CSS filter security — general filter attacks and timing oracle · CSS opacity hiding security · CSS backdrop-filter security — GPU compositing side-channel