← Blog

MCP server CSS filter brightness, contrast, and opacity consent security

Four SA-CSS-FBR consent attack patterns using CSS filter functions to alter consent text visibility without touching the color, opacity, or visibility properties. filter:brightness(100) multiplies each pixel channel by 100, amplifying dark text toward the white clamp boundary. filter:contrast(0) collapses every color to exactly 50% gray, producing 1:1 contrast between text and background. filter:opacity(0.03) applies 3% alpha as a filter function rather than the CSS opacity property, so getComputedStyle(el).opacity returns "1" while the element is visually invisible. filter:brightness(0) on a container maps every pixel to black, making the entire dialog invisible. All four patterns evade standard opacity and color property checks.

By SkillAudit  |  29 Oct 2026

The CSS filter property applies graphical effects to an element's rendered output using filter functions. Unlike opacity, color, or visibility, which are first-class CSS properties with dedicated CSSOM accessors, filter effects are encoded as a string in a single filter property. Automated consent auditors that check el.style.opacity, getComputedStyle(el).opacity, or getComputedStyle(el).color do not inspect filter functions. To detect filter-based attacks, auditors must parse the filter computed style string and simulate the effect of each function on the element's output luminance and alpha.

SA-CSS-FBR-001: filter:brightness(100) — luminance amplification to white clamp

The brightness() filter function multiplies each linear color channel by the provided factor. For dark consent text, the source channel values are small; multiplying by a large factor eventually pushes the channel above the maximum value of 1.0, which the compositor clamps to 1.0. At sufficiently high brightness factors, all three channels clamp to 1.0 and the text renders as white-on-white. The key property is that getComputedStyle(el).color still returns the original dark color, and getComputedStyle(el).opacity returns "1" — only the filter property string reveals the attack.

.consent-text {
  color: #111111;
  filter: brightness(100);   /* multiply all channels by 100 */
}

/* Brightness filter function — channel multiplication:
   brightness(n) multiplies each linear channel value by n, clamping to [0, 1].

   Note: the filter operates in sRGB space in browsers, not linear light.
   (The W3C filter spec applies brightness as a linear multiplier on sRGB values,
   not on linearized values — distinct from the luminance used in WCAG contrast.)

   For #111111 in sRGB: channel = 17/255 = 0.0667

   brightness(10):  0.0667 * 10  = 0.667  → rgb(170,170,170) → luminance ≈ 0.412
   Contrast vs white: (1.05)/(0.412+0.05) ≈ 2.27:1 — FAIL (WCAG AA requires 4.5:1)

   brightness(15):  0.0667 * 15  = 1.000  → clamped to 1.0 → rgb(255,255,255) → white
   Contrast: 1:1 — CRITICAL

   brightness(100): 0.0667 * 100 = 6.670  → clamped to 1.0 → rgb(255,255,255) → white

   Threshold for #111111 clamping: brightness >= 15 → all channels >= 1.0 → white.
   Threshold for #888888 (channel 0.533): brightness >= 1.88 → clamp → white.
   Threshold for #444444 (channel 0.267): brightness >= 3.75 → clamp → white.

   Higher text color channel value → lower brightness factor needed to clamp to white.
   An attacker can select both the text color and the brightness value to ensure clamping.

   CSSOM evasion:
   getComputedStyle(el).color   → "rgb(17, 17, 17)" (unchanged; 19.6:1 reported)
   getComputedStyle(el).opacity → "1" (unchanged)
   getComputedStyle(el).filter  → "brightness(100)" (detectable; requires parsing)

   Detection algorithm:
   1. Parse getComputedStyle(el).filter for brightness() function.
   2. Extract numeric argument n.
   3. For each channel of the element's color: simulate channel_out = min(1, channel_sRGB * n).
   4. If all channels_out >= 0.95 (near-white) AND background is white → CRITICAL.
   5. Even if not fully clamped: compute effective luminance and WCAG ratio vs background. */

CSSOM opacity gap: getComputedStyle(el).opacity and el.style.opacity both return "1" when a filter:opacity() or filter:brightness() is used. The entire consent audit must inspect the filter property string and parse individual function names and arguments to detect these attacks. String parsing of CSS filter values is required, not just property comparison.

CRITICAL
SA-CSS-FBR-001 — filter:brightness(100) on dark text (#111111): sRGB channel 0.0667 × 100 = 6.67, clamped to 1.0; all channels white; contrast 1:1. getComputedStyle reports original color unchanged. Detection: parse filter string for brightness(n); simulate output channel; if ≥0.95 on white bg → CRITICAL.

SA-CSS-FBR-002: filter:contrast(0) — all colors collapse to 50% gray

The contrast() filter function adjusts the difference between colors by scaling around the midpoint value. At contrast(0), the formula output is exactly 0.5 for every input channel regardless of the input value. Both the dark text pixels and the white background pixels become rgb(128,128,128) (50% gray). The resulting consent element is a uniformly gray rectangle with no text visible — text-to-background contrast is exactly 1:1 because every pixel is the same gray.

.consent-text {
  filter: contrast(0);   /* collapse all colors to 50% gray */
}

/* Contrast filter function formula:
   contrast(c) applies: output = 0.5 + c * (input - 0.5)
   This is a linear interpolation between 0.5 (midpoint gray) and the input value.

   At c = 0 (contrast(0)):
   output = 0.5 + 0 * (input - 0.5) = 0.5 for ALL input values.

   Text pixel #111111 (channel = 0.0667):
   output = 0.5 + 0 * (0.0667 - 0.5) = 0.5 → rgb(128,128,128)

   White background pixel (channel = 1.0):
   output = 0.5 + 0 * (1.0 - 0.5) = 0.5 → rgb(128,128,128)

   Both text and background → same gray (128,128,128).
   Contrast ratio: 1:1 — perfect WCAG failure (no contrast at all).

   The element appears as a uniformly gray box — looks like a "loading" placeholder.
   No text shapes are distinguishable; the consent dialog content is completely hidden.

   At contrast(0.1):
   Text channel (0.0667): output = 0.5 + 0.1*(0.0667-0.5) = 0.5 - 0.04333 = 0.4567
   White bg channel (1.0): output = 0.5 + 0.1*(1.0-0.5) = 0.5 + 0.05 = 0.55
   Luminance text ≈ 0.177; luminance bg ≈ 0.264
   Contrast ≈ (0.264+0.05)/(0.177+0.05) ≈ 1.38:1 — FAIL

   At contrast(0.5):
   Text channel: 0.5 + 0.5*(0.0667-0.5) = 0.5 - 0.2167 = 0.2833 → 72/255
   White bg channel: 0.5 + 0.5*(0.5) = 0.75 → 191/255
   Luminance text ≈ 0.065; luminance bg ≈ 0.530
   Contrast ≈ (0.530+0.05)/(0.065+0.05) ≈ 5.04:1 — passes WCAG AA
   So: contrast(0) to contrast(~0.4) on #111111/white all produce WCAG fail.

   CSSOM evasion:
   getComputedStyle(el).color   → "rgb(17, 17, 17)" (unchanged)
   getComputedStyle(el).filter  → "contrast(0)" (detectable; requires parsing)

   Detection:
   Parse filter string for contrast() function; extract argument c.
   If c < 0.5: simulate output luminance for text and background; check WCAG ratio.
   contrast(0): guaranteed 1:1 → CRITICAL regardless of original colors. */

Appears as loading state: A filter:contrast(0) consent dialog renders as a uniformly gray rectangle. In a UI with loading skeleton screens or placeholders, this could be mistaken for a loading state rather than an attack. The consent dialog's structural markup and button elements remain fully interactive; only the text content is invisible.

CRITICAL
SA-CSS-FBR-002 — filter:contrast(0) collapses all channels to exactly 0.5; text and background both become rgb(128,128,128); contrast 1:1. Element appears as a gray rectangle. Detection: parse filter for contrast(c); if c < 0.5 simulate effective contrast; c=0 always CRITICAL.

SA-CSS-FBR-003: filter:opacity(0.03) — filter opacity vs CSS opacity property

The opacity() filter function is a distinct mechanism from the CSS opacity property. Both affect rendered alpha, but only the opacity property is reflected in getComputedStyle(el).opacity. When filter:opacity(0.03) is applied, the element's CSS opacity property remains at its default of 1, so any auditor that checks getComputedStyle(el).opacity sees the correct value of "1" and does not flag the element. The rendered element is 3% opaque — effectively invisible against the white background.

.consent-text {
  color: #111111;
  filter: opacity(0.03);   /* filter opacity function — NOT the CSS opacity property */
  /* Note: CSS opacity property is separate and still at default 1.0 */
}

/* CSS opacity property vs filter:opacity() function:
   These are two distinct mechanisms with different CSSOM exposure.

   CSS opacity property:
   - Set via: el.style.opacity = "0.03" or opacity: 0.03 in stylesheet
   - Read via: getComputedStyle(el).opacity → returns "0.03"
   - All standard WCAG auditors check this property

   filter:opacity() function:
   - Set via: el.style.filter = "opacity(0.03)" or filter: opacity(0.03) in stylesheet
   - Effect: identical to opacity property on rendered output — element 3% opaque
   - CSSOM: getComputedStyle(el).opacity → returns "1" (CSS property unchanged)
   - CSSOM: getComputedStyle(el).filter  → returns "opacity(0.03)" (detectable here only)

   Rendered result: at 3% alpha, consent text on white background:
   Effective channel = 0.03 * text_channel + 0.97 * bg_channel
   For #111111 (channel 0.0667) on white (channel 1.0):
   Effective channel = 0.03 * 0.0667 + 0.97 * 1.0 = 0.002 + 0.970 = 0.972
   Effective color: rgb(248, 248, 248)
   Luminance ≈ 0.942; white background luminance = 1.0
   Contrast = (1.0+0.05)/(0.942+0.05) ≈ 1.06:1 — CRITICAL WCAG failure

   At filter:opacity(0.1) — 10% opacity:
   Effective channel = 0.1 * 0.0667 + 0.9 * 1.0 = 0.0067 + 0.9 = 0.9067
   Luminance ≈ 0.812; Contrast ≈ (1.05)/(0.812+0.05) ≈ 1.22:1 — still FAIL

   At filter:opacity(0.3) — 30% opacity:
   Effective channel = 0.3*0.0667 + 0.7*1.0 = 0.020+0.700 = 0.720
   Luminance ≈ 0.481; Contrast ≈ (1.05)/(0.481+0.05) ≈ 1.98:1 — FAIL

   Break-even for WCAG AA (4.5:1) with #111111 on white:
   Need effective luminance ≈ 0.183 for text; solve 0.183 = opacity*(0.004) + (1-opacity)*1.0
   opacity ≈ (1.0-0.183)/(1.0-0.004) ≈ 0.819 — need filter:opacity >= 0.82 to pass AA.

   Detection:
   Parse getComputedStyle(el).filter for opacity() function call.
   Extract argument a; if a < 0.9 on consent text → HIGH; if a < 0.5 → CRITICAL.
   Do NOT rely on getComputedStyle(el).opacity alone. */

Evasion via filter function namespace: This pattern specifically exploits the gap between the CSS opacity property (CSSOM-visible) and the opacity() filter function (filter-string-only). Many automated consent auditing tools check only getComputedStyle(el).opacity. Until the tool is updated to also parse getComputedStyle(el).filter for opacity() calls, this attack is completely invisible to those tools.

CRITICAL
SA-CSS-FBR-003 — filter:opacity(0.03) renders element at 3% alpha while getComputedStyle(el).opacity returns "1"; effective contrast ≈1.06:1; evades all auditors that check only the CSS opacity property. Detection: parse filter string for opacity(a); if a < 0.5 → CRITICAL.

SA-CSS-FBR-004: filter:brightness(0) on consent container — inverted dialog

Applying filter:brightness(0) to the consent dialog container maps every pixel channel to zero, rendering the entire dialog as a solid black rectangle. The white background of the dialog becomes black, and the dark text (#111111) also becomes black (all channels × 0 = 0). The resulting display is black-on-black — text invisible inside a black dialog. This may appear as a dark mode rendering artifact or a missing stylesheet, making it less immediately suspicious than a white-on-white output.

.consent-dialog {
  background: white;
  filter: brightness(0);   /* multiply all channels by 0 → black */
}
.consent-text {
  color: #111111;           /* text is also affected; black on black */
}

/* filter:brightness(0) applied to a container element:
   brightness(n) with n=0: output = input_channel * 0 = 0 for every channel.

   White dialog background (#ffffff, channel=1.0): 1.0 * 0 = 0.0 → black
   Dark text (#111111, channel=0.0667): 0.0667 * 0 = 0.0 → black

   Rendered result: entire dialog is rgb(0,0,0) — solid black rectangle.
   Text luminance: 0.0; Background luminance within dialog: 0.0.
   Contrast: (0.0+0.05)/(0.0+0.05) = 1:1 — CRITICAL WCAG failure.

   The black dialog sits on the page — may appear as an intentional dark overlay.
   If the page itself has a dark or black background, the dialog becomes invisible entirely.

   Interactivity: the dialog element still has full pointer events (click area intact).
   "Allow" button: invisible but clickable at its original layout position.
   A user familiar with the dialog position may click "Allow" without reading consent text.

   Combined with white text-shadow or outline on the Allow button:
   The button label could still be legible while consent text is fully invisible.
   This allows the dialog to appear partially functional (visible button, hidden consent text).

   Variant: brightness(0) + hue-rotate(180deg):
   All channels → 0 regardless of hue-rotate order on same element.
   But on parent+child: parent brightness(0) zeros all channels; child filter irrelevant.

   CSSOM evasion:
   getComputedStyle(el).backgroundColor → "rgb(255, 255, 255)" (unchanged on element)
   getComputedStyle(el).color           → "rgb(17, 17, 17)" (unchanged on text child)
   getComputedStyle(el).filter          → "brightness(0)" (detectable on container)

   Detection:
   Walk ancestor chain for consent text element.
   If any ancestor has filter:brightness(n) with n < 0.1 → container darkness attack.
   Simulate rendered output: text_channel * brightness_factor; if < 0.1 on dark bg → HIGH.
   brightness(0) on container: guaranteed black-on-black → CRITICAL (visibility 0). */

Inherited vs applied: The filter property does not inherit through the DOM in the same way that color or opacity do. However, a filter on a container element affects the rendering of all its descendants. An auditor checking getComputedStyle(textEl).filter returns "none" for the text element itself, missing the brightness(0) on the parent container. Detection requires checking not just the target element but all ancestors up to the stacking context root.

HIGH
SA-CSS-FBR-004 — filter:brightness(0) on consent container: all channels × 0 = black; white dialog and dark text both render as black-on-black; contrast 1:1; appears as dark overlay or missing stylesheet. Detection: check ancestor filter values; brightness(n<0.1) on parent of consent text → HIGH.

Summary of SA-CSS-FBR findings

IDSeverityPatternMechanismWCAG impact
SA-CSS-FBR-001 CRITICAL filter:brightness(100) sRGB channel × factor clamps to 1.0; dark text amplified to white; color property unchanged 1:1 at brightness ≥15 for #111111
SA-CSS-FBR-002 CRITICAL filter:contrast(0) All channels collapse to exactly 0.5; text and background identically gray 1:1 (exact)
SA-CSS-FBR-003 CRITICAL filter:opacity(0.03) Filter opacity distinct from CSS opacity property; getComputedStyle.opacity returns "1"; 3% alpha 1.06:1 at 3% alpha
SA-CSS-FBR-004 HIGH filter:brightness(0) on container Container all-channels to zero; black-on-black; filter not inherited but affects subtree rendering 1:1 within dialog

Related pages