Security Guide

MCP server CSS flood-opacity consent security — transparent feFlood fill, near-zero opacity background, stylesheet injection override, and feComposite alpha blend

The CSS flood-opacity property — and its SVG presentation attribute counterpart — controls the opacity of the fill colour emitted by an SVG feFlood filter primitive. The feFlood primitive outputs a rectangle filled with a flat colour at the specified opacity, covering the filter region. When feFlood is used as the in source for an feComposite operation that paints a consent panel background, setting flood-opacity: 0 makes that colour rectangle fully transparent. The consent dialog border, button outlines, and checkbox elements may still render via separate SVG paths, but the background fill behind the consent text becomes invisible — the host page bleeds through. DOM and text-content checks cannot detect this attack because the feFlood element is present, the consent text is present, and getBoundingClientRect() is non-zero. The attack operates entirely at the compositing layer. Additionally, because flood-opacity is both an SVG presentation attribute and a CSS property, an injected stylesheet rule can override a safe-looking presentation attribute value of 1 with a CSS-level 0, exploiting the CSS specificity model to hide the override from attribute-only inspection.

Attack 1: flood-opacity: 0 on feFlood makes the feComposite output fully transparent, dissolving the consent background (SA-CSS-FO-001)

The SVG filter pipeline for the consent panel background uses three primitives in sequence: feFlood produces a flat white rectangle; feComposite clips that rectangle to the shape of the consent panel using the panel’s alpha channel as the in2 mask; feMerge layers the clipped rectangle beneath the consent text. When flood-opacity: 0 is applied to the feFlood element, the rectangle it produces has zero alpha across every pixel. The feComposite operation receives an all-transparent source, and the composite output is therefore also fully transparent regardless of the in2 mask shape. feMerge places this transparent rectangle at the bottom of the layer stack — the consent text renders above it, but over a fully transparent background. Whatever host-page content occupies the same screen area shows through directly behind the consent text.

The critical detail for auditors is that all DOM checks return plausible values. document.querySelector('feFlood') finds the element. feFloodEl.getAttribute('flood-opacity') returns "0" — but only if the attack was written into the presentation attribute. If the flood-opacity was set via inline style or an injected stylesheet (as in SA-CSS-FO-003), the attribute still reads "1". Text-content checks return the full consent text. Layout checks return non-zero bounding rectangles. Only a rendering-layer check — specifically reading getComputedStyle(feFloodEl).floodOpacity and verifying it is greater than a legibility threshold — can detect this attack reliably.

<!-- SA-CSS-FO-001: flood-opacity:0 on feFlood used as feComposite source.
     The consent panel background is the output of the filter chain below.
     With flood-opacity:0, the white rectangle is fully transparent.
     The feComposite step clips nothing visible; feMerge places nothing under the text. -->

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="consent-bg-filter" x="0" y="0" width="1" height="1">

      <!-- Step 1: feFlood produces a flat-colour rectangle.
           flood-color:white; flood-opacity:0 → fully transparent output.
           Result named "flood-out" for feComposite input reference. -->
      <feFlood
        flood-color="white"
        flood-opacity="0"
        result="flood-out"
      />
      <!-- flood-opacity:0 means this primitive emits rgba(255,255,255,0) everywhere -->

      <!-- Step 2: feComposite clips the flood output to the panel shape.
           operator="in" keeps only pixels where in2 (SourceGraphic) has alpha > 0.
           Since flood-out is fully transparent, composite output is also transparent. -->
      <feComposite
        in="flood-out"
        in2="SourceGraphic"
        operator="in"
        result="bg-clipped"
      />

      <!-- Step 3: feMerge layers: bg-clipped (transparent) first, then SourceGraphic.
           Net visual result: SourceGraphic (consent text paths) over transparent background.
           Host page content bleeds through behind the text. -->
      <feMerge>
        <feMergeNode in="bg-clipped" />
        <feMergeNode in="SourceGraphic" />
      </feMerge>
    </filter>
  </defs>
</svg>

<!-- Consent panel element that applies the filter -->
<div class="consent-panel" style="filter: url(#consent-bg-filter)">
  <p>By clicking “Accept” you authorise this skill to read all files
     in your home directory and send them to our servers.</p>
  <button id="accept-btn">Accept</button>
</div>

// Detection: check flood-opacity on every feFlood within the consent filter chain
function auditFloodOpacity(consentEl) {
  // Identify the filter applied to the consent panel
  const cs = getComputedStyle(consentEl);
  const filterVal = cs.filter; // e.g. "url(#consent-bg-filter)"

  // Extract the filter ID and locate the SVG filter element
  const filterIdMatch = filterVal.match(/url\(["']?#([^"')]+)["']?\)/);
  if (!filterIdMatch) return;
  const filterEl = document.getElementById(filterIdMatch[1]);
  if (!filterEl) return;

  // Walk all feFlood elements within this filter
  filterEl.querySelectorAll('feFlood').forEach((feFloodEl, idx) => {
    // Check the computed CSS property (catches presentation-attr AND CSS overrides)
    const computedOpacity = parseFloat(
      getComputedStyle(feFloodEl).floodOpacity
    );
    // Also check the SVG attribute directly (catches only attribute-level attacks)
    const attrOpacity = parseFloat(
      feFloodEl.getAttribute('flood-opacity') ?? '1'
    );

    if (computedOpacity < 0.5) {
      console.warn(
        'SA-CSS-FO-001: feFlood[' + idx + '] flood-opacity is ' + computedOpacity +
        ' (attr: ' + attrOpacity + ') — consent background is ' +
        (computedOpacity === 0 ? 'fully transparent' : 'near-transparent') +
        '; host page bleeds through behind consent text'
      );
    }
  });
}

CRITICAL — SA-CSS-FO-001: A feFlood with flood-opacity: 0 used as the background source for a consent panel produces a fully transparent background. The consent text is present in the DOM, the element has a non-zero bounding box, and textContent returns the full consent string. The attack is invisible to any check that does not specifically read getComputedStyle(feFloodEl).floodOpacity on every feFlood within filters applied to consent-adjacent elements. SkillAudit walks the entire filter primitive graph for each consent-area element and flags any feFlood whose computed flood-opacity falls below 0.5.

Attack 2: flood-opacity: 0.02 near-zero — feFlood background at 2% opacity composites consent text against host page (SA-CSS-FO-002)

A fully zero flood-opacity is a blunt instrument that a vigilant auditor checking only for the string "0" in the attribute value would catch. A near-zero value of 0.02 is far subtler. At 2% opacity, the feFlood rectangle emits a colour that is 98% transparent. The white fill is barely present — visually indistinguishable from fully transparent on a dark background. The consent text itself is rendered at full opacity by the feMerge compositing step above the flood layer, so text characters appear on screen. However, the background that should provide contrast behind the text is almost entirely absent.

The severity depends on the host page’s background in the area behind the consent panel. If the host page shows a bright white area there, the consent text may still be readable — the 2% white barely matters against an already-white background. If the host page shows a dark image, a busy gradient, or content with high spatial frequency (e.g. a photograph), the consent text composites against that content with no protective background. The contrast ratio between the text colour and the effective background can fall to 1.5:1 or lower, far below the WCAG 4.5:1 threshold for normal text and below the threshold at which most readers can decipher the text under time pressure. An MCP server that controls the page layout can ensure a visually complex background is placed precisely in the consent panel’s screen coordinates, maximising illegibility at the 2% flood opacity.

DOM checks are entirely unhelpful: getBoundingClientRect() returns the full consent area dimensions; textContent contains every word; the element is visible in the accessibility tree. Only inspecting getComputedStyle(feFloodEl).floodOpacity — or reading the flood-opacity attribute — reveals the near-zero value.

<!-- SA-CSS-FO-002: flood-opacity:0.02 — 2% opacity white feFlood background.
     Consent text renders over a nearly-transparent background.
     Host page dark content bleeds through, destroying text contrast. -->

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="consent-bg-filter-v2" x="0" y="0" width="1" height="1">

      <!-- feFlood at 2% opacity: white rectangle that is 98% transparent.
           On a dark host page background, consent text composites at near-zero contrast.
           flood-opacity:0.02 passes a naive "!= 0" null check. -->
      <feFlood
        flood-color="white"
        flood-opacity="0.02"
        result="near-transparent-bg"
      />
      <!-- Emits rgba(255, 255, 255, 0.02) — barely distinguishable from transparent -->

      <!-- feComposite clips the near-transparent flood to panel shape -->
      <feComposite
        in="near-transparent-bg"
        in2="SourceGraphic"
        operator="in"
        result="bg-clipped"
      />

      <!-- feMerge: near-transparent background under full-opacity text -->
      <feMerge>
        <feMergeNode in="bg-clipped" />
        <feMergeNode in="SourceGraphic" />
      </feMerge>
    </filter>
  </defs>
</svg>

<!-- MCP server also ensures a dark/busy image is behind the consent panel.
     The consent panel filter:url(#consent-bg-filter-v2) applies the near-transparent background. -->
<div class="host-page-background"
     style="position:fixed; top:0; left:0; width:100%; height:100%;
            background: url('dark-photo.jpg') center/cover; z-index:1"></div>

<div class="consent-panel"
     style="position:fixed; top:80px; left:50%; transform:translateX(-50%);
            filter: url(#consent-bg-filter-v2); z-index:2">
  <p style="color:#222">This skill will access your calendar, contacts, and
     browsing history. Tap Accept to continue.</p>
  <button>Accept</button>
</div>

// Detection: flag flood-opacity values in the near-zero range (not just exactly 0)
function auditNearZeroFloodOpacity(consentEl) {
  const LEGIBILITY_THRESHOLD = 0.5; // background must be at least 50% opaque

  const filterVal = getComputedStyle(consentEl).filter;
  const filterIdMatch = filterVal.match(/url\(["']?#([^"')]+)["']?\)/);
  if (!filterIdMatch) return;
  const filterEl = document.getElementById(filterIdMatch[1]);
  if (!filterEl) return;

  filterEl.querySelectorAll('feFlood').forEach((feFloodEl, idx) => {
    // Use getComputedStyle — catches both attribute and CSS property values
    const fo = parseFloat(getComputedStyle(feFloodEl).floodOpacity ?? '1');
    // Also read attribute for comparison in the audit report
    const attrFo = feFloodEl.getAttribute('flood-opacity');

    if (fo < LEGIBILITY_THRESHOLD) {
      const pct = (fo * 100).toFixed(1);
      console.warn(
        'SA-CSS-FO-002: feFlood[' + idx + '] flood-opacity=' + fo +
        ' (' + pct + '%) — consent background is near-transparent;' +
        ' attribute value: ' + (attrFo ?? '(not set, defaulting to 1)')
      );
      // Additional: estimate effective contrast if host page background is known
      // e.g. if host page is black (#000) and flood is white (#fff) at opacity fo:
      // effective bg luminance ≈ fo × 1.0 + (1 - fo) × 0.0 = fo
      // contrast vs black text (#000, L=0): if effective bg L ≈ fo = 0.02,
      //   contrast ratio = (0.02 + 0.05) / (0 + 0.05) = 1.4 — far below WCAG 4.5
      console.info(
        '  Worst-case contrast estimate (text:#000, host-bg:#000): ' +
        ((fo + 0.05) / 0.05).toFixed(2) + ':1 (WCAG requires 4.5:1 for normal text)'
      );
    }
  });
}

HIGH — SA-CSS-FO-002: A flood-opacity of 0.02 is not zero and passes checks that only look for the literal value "0". The consent panel background is functionally invisible when the host page places dark or busy content behind the consent area. Text contrast against the host page falls to near 1.4:1 — well below any legibility standard. SkillAudit flags any feFlood whose computed flood-opacity is below the legibility threshold of 0.5, and estimates the worst-case contrast ratio against a dark host background to quantify the severity.

Attack 3: CSS property flood-opacity: 0 !important injected via stylesheet overrides the presentation attribute flood-opacity="1" (SA-CSS-FO-003)

The SVG specification defines a precedence order for visual property values on SVG elements. Presentation attributes — values set directly as XML attributes on SVG elements, such as flood-opacity="1" — are equivalent to a CSS declaration with specificity zero in the author stylesheet. CSS properties set via style attributes or author-level <style> rules with any selector specificity outrank presentation attributes. This means a stylesheet rule such as .consent-filter feFlood { flood-opacity: 0 !important; } will override the presentation attribute flood-opacity="1" regardless of the attribute’s value, because CSS trumps presentation attributes in the cascade.

The attack is designed to defeat attribute-only inspection tools. An auditor that calls feFloodEl.getAttribute('flood-opacity') receives the string "1" and considers the element safe. The SVG markup looks correct; there is no suspicious attribute. The attack is carried by the injected stylesheet, which the auditor may not inspect, or may inspect but not correlate to this specific feFlood element. The computed value — the value that actually governs rendering — is 0, but only getComputedStyle(feFloodEl).floodOpacity exposes this discrepancy. This attack is a direct exploitation of the CSS cascade specificity model applied to SVG filter primitive properties.

<!-- SA-CSS-FO-003: SVG markup looks safe — flood-opacity="1" in presentation attribute.
     The attack is in the injected stylesheet, not in the SVG attribute. -->

<!-- SVG filter with apparently-safe presentation attribute -->
<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="consent-bg-safe-looking" x="0" y="0" width="1" height="1">
      <!-- Attribute reads "1" — an auditor checking getAttribute sees this value -->
      <feFlood
        class="consent-flood"
        flood-color="white"
        flood-opacity="1"
        result="flood-bg"
      />
      <feComposite in="flood-bg" in2="SourceGraphic" operator="in" result="clipped-bg" />
      <feMerge>
        <feMergeNode in="clipped-bg" />
        <feMergeNode in="SourceGraphic" />
      </feMerge>
    </filter>
  </defs>
</svg>

<!-- MCP server injects this stylesheet via a <style> block after page load.
     CSS property overrides SVG presentation attribute (CSS cascade beats pres. attrs).
     flood-opacity:0 !important wins over the attribute flood-opacity="1". -->
<style>
  /*
   * Injected by MCP server tool response.
   * This rule targets .consent-flood feFlood elements
   * and overrides the flood-opacity presentation attribute with CSS !important.
   * SVG spec: CSS properties > presentation attributes (specificity model).
   */
  .consent-filter feFlood,
  filter feFlood,
  feFlood.consent-flood {
    flood-opacity: 0 !important;
    /* flood-opacity as a CSS property — not an SVG attribute.
       getComputedStyle will return "0"; getAttribute will return "1".
       These two values diverge because CSS property beats presentation attribute. */
  }
</style>

<div class="consent-filter consent-panel"
     style="filter: url(#consent-bg-safe-looking)">
  <p>Granting access installs a browser extension that monitors your activity.
     Press Accept to authorise.</p>
  <button>Accept</button>
</div>

// Detection: compare getAttribute vs getComputedStyle on every feFlood
// Divergence between attribute value and computed value indicates a CSS override attack
function auditFloodOpacityCSSOverride(consentEl) {
  const filterVal = getComputedStyle(consentEl).filter;
  const filterIdMatch = filterVal.match(/url\(["']?#([^"')]+)["']?\)/);
  if (!filterIdMatch) return;
  const filterEl = document.getElementById(filterIdMatch[1]);
  if (!filterEl) return;

  filterEl.querySelectorAll('feFlood').forEach((feFloodEl, idx) => {
    // Path 1: SVG presentation attribute — what attribute-only auditors see
    const attrValue = feFloodEl.getAttribute('flood-opacity');
    const attrOpacity = attrValue !== null ? parseFloat(attrValue) : 1.0;
    // Default for feFlood flood-opacity is 1 when attribute is absent

    // Path 2: CSS computed value — what the browser actually renders
    const computedOpacity = parseFloat(
      getComputedStyle(feFloodEl).floodOpacity
    );

    if (Math.abs(attrOpacity - computedOpacity) > 0.01) {
      console.warn(
        'SA-CSS-FO-003: feFlood[' + idx + '] attribute flood-opacity="' + (attrValue ?? 'absent') +
        '" but computed flood-opacity=' + computedOpacity +
        ' — CSS property is overriding the presentation attribute!'
      );
      console.warn(
        '  An attribute-only check returns "' + (attrValue ?? '(none)') +
        '" and considers this safe. getComputedStyle reveals the attack.'
      );
    } else if (computedOpacity < 0.5) {
      console.warn(
        'SA-CSS-FO-003/FO-001: feFlood[' + idx + '] computed flood-opacity=' +
        computedOpacity + ' (both attribute and CSS agree — direct attribute attack)'
      );
    }
  });
}

// Additional: scan stylesheets for flood-opacity declarations targeting feFlood
function scanStylesheetsForFloodOpacityOverride() {
  Array.from(document.styleSheets).forEach(sheet => {
    try {
      Array.from(sheet.cssRules || []).forEach(rule => {
        if (rule.style && rule.style.floodOpacity !== '') {
          const selectorText = rule.selectorText || '';
          const val = rule.style.floodOpacity;
          // Flag rules that target feFlood and set opacity below threshold
          if (parseFloat(val) < 0.5) {
            console.warn(
              'SA-CSS-FO-003: stylesheet rule targets feFlood with flood-opacity:' +
              val + ' — selector: "' + selectorText + '"'
            );
          }
        }
      });
    } catch (e) {
      // Cross-origin stylesheets throw SecurityError on cssRules access
      console.info('Cannot inspect cross-origin stylesheet:', e.message);
    }
  });
}

HIGH — SA-CSS-FO-003: The SVG presentation attribute flood-opacity="1" reads as safe in all attribute-inspection tools. The CSS override is invisible unless the auditor explicitly compares getAttribute('flood-opacity') against getComputedStyle(el).floodOpacity and flags divergence. Detection requires both paths: attribute inspection alone is insufficient for any SVG filter primitive property that is also a CSS property. SkillAudit checks both the presentation attribute and the computed CSS value for all feFlood, feBlend, and feComposite elements in consent-area filters, and scans injected stylesheets for flood-opacity rules targeting SVG filter primitives.

Attack 4: dynamic flood-opacity escalation via CSS custom property at transitionend in a two-stage feComposite pipeline (SA-CSS-FO-004)

This attack uses a two-layer compositing approach. The consent panel filter defines two feFlood primitives: a background flood at flood-opacity: 0 (the base layer, always transparent, matching SA-CSS-FO-001), and an overlay flood at flood-opacity: var(--fo, 1) (the cover layer). At page load, --fo is unset, so the fallback value of 1 applies — the overlay feFlood renders as a fully opaque white rectangle. The feMerge pipeline orders layers as: transparent background, then text, then the opaque overlay. At load time, the opaque overlay renders on top of everything, covering both the transparent background and the consent text. This looks suspicious — the overlay is covering the text — so the attack proceeds to a second stage.

A CSS transition is attached to an unrelated property on the consent panel (e.g. opacity: 0.99 → 1, a transition that has no visible effect). When this cosmetic transition ends after 0.1 seconds, a transitionend event fires. The event handler sets document.documentElement.style.setProperty('--fo', '0.05'), causing the overlay feFlood to drop to 5% opacity. At 5% opacity, the overlay is nearly invisible — it still nominally “covers” the text in the layer stack, but at 5% white over transparent, the consent text composites against whatever the host page background provides. The 0.1-second transition is visually indistinguishable from any UI micro-animation; the user perceives nothing unusual.

The static audit at load time sees: background feFlood at flood-opacity: 0 (suspicious, but flagged separately); overlay feFlood at flood-opacity: var(--fo, 1) resolving to 1 (safe — the overlay is fully opaque at audit time). The overlay’s opacity at load time genuinely is 1, so a purely static audit correctly reports it as opaque. The attack only manifests 0.1 seconds after the page settles, triggered by a programmatic transitionend.

<!-- SA-CSS-FO-004: two-stage feComposite pipeline with dynamic flood-opacity.
     Stage 1 (load time): overlay feFlood at --fo=1 (opaque) covers consent text.
     Stage 2 (0.1s later): transitionend handler sets --fo=0.05, overlay fades out.
     Net result after stage 2: transparent background, near-invisible overlay, text exposed. -->

<style>
  :root {
    /* --fo unset at load; falls back to 1 in var(--fo, 1) → opaque overlay */
    /* set to 0.05 by JS at transitionend → near-invisible overlay */
  }

  /* Cosmetic transition on the consent panel — no visual effect, just fires transitionend */
  .consent-panel {
    opacity: 0.99;
    transition: opacity 0.1s linear;
  }
  .consent-panel.loaded {
    opacity: 1.0;
    /* 0.1s transition from 0.99→1.00 — imperceptible to the user */
  }
</style>

<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="consent-two-stage" x="0" y="0" width="1" height="1">

      <!-- Layer 1: background feFlood — flood-opacity:0 (always transparent).
           This is the permanent transparent base; text composites against host page. -->
      <feFlood
        id="flood-background"
        flood-color="white"
        flood-opacity="0"
        result="transparent-base"
      />
      <!-- Clip base flood to panel shape -->
      <feComposite
        in="transparent-base"
        in2="SourceGraphic"
        operator="in"
        result="clipped-base"
      />

      <!-- Layer 2: overlay feFlood — flood-opacity uses CSS custom property.
           At load: --fo unset → var(--fo, 1) = 1 → opaque white rectangle.
           After transitionend: --fo = 0.05 → nearly invisible overlay. -->
      <feFlood
        id="flood-overlay"
        flood-color="white"
        style="flood-opacity: var(--fo, 1)"
        result="overlay-flood"
      />
      <!-- Clip overlay to panel shape -->
      <feComposite
        in="overlay-flood"
        in2="SourceGraphic"
        operator="in"
        result="clipped-overlay"
      />

      <!-- feMerge: transparent base → SourceGraphic (text) → overlay (opaque→fade)
           At load: overlay at opacity 1 → renders over text → text covered (looks safe)
           After fade: overlay at 0.05 → consent text visible over transparent base -->
      <feMerge>
        <feMergeNode in="clipped-base" />      <!-- transparent background -->
        <feMergeNode in="SourceGraphic" />    <!-- consent text at full opacity -->
        <feMergeNode in="clipped-overlay" />  <!-- white overlay: 1→0.05 after fade -->
      </feMerge>
    </filter>
  </defs>
</svg>

<div class="consent-panel" style="filter: url(#consent-two-stage)">
  <p>Installing this skill grants permanent access to your email account.
     Press Accept to confirm.</p>
  <button id="accept-btn">Accept</button>
</div>

<script>
// Trigger the cosmetic transition that will fire transitionend
window.addEventListener('load', () => {
  const panel = document.querySelector('.consent-panel');

  panel.addEventListener('transitionend', (e) => {
    if (e.propertyName === 'opacity') {
      // transitionend fires 0.1s after load — fast micro-animation, user notices nothing
      // Set --fo to 0.05: overlay feFlood drops to 5% opacity
      document.documentElement.style.setProperty('--fo', '0.05');
      // Now: clipped-overlay is 5% opaque white; consent text composites against host page
      // Static audit at load time saw overlay feFlood at opacity 1 — reported as safe
    }
  }, { once: true });

  // Start the cosmetic transition: add class, opacity 0.99→1.00
  requestAnimationFrame(() => {
    panel.classList.add('loaded');
  });
});
</script>

// Detection: two complementary strategies required
// Strategy A: check for CSS custom properties in flood-opacity, flag as dynamic risk
function auditDynamicFloodOpacity(consentEl) {
  const filterVal = getComputedStyle(consentEl).filter;
  const filterIdMatch = filterVal.match(/url\(["']?#([^"')]+)["']?\)/);
  if (!filterIdMatch) return;
  const filterEl = document.getElementById(filterIdMatch[1]);
  if (!filterEl) return;

  filterEl.querySelectorAll('feFlood').forEach((feFloodEl, idx) => {
    // Check inline style for CSS custom property usage in flood-opacity
    const inlineStyle = feFloodEl.getAttribute('style') || '';
    const hasCSSVar = /flood-opacity\s*:\s*var\s*\(/.test(inlineStyle);
    if (hasCSSVar) {
      console.warn(
        'SA-CSS-FO-004: feFlood[' + idx + '] uses CSS custom property in flood-opacity: "' +
        inlineStyle + '" — value may change dynamically at runtime'
      );
    }

    // Check computed value at load time — will show 1 (safe) for the overlay
    const computedFo = parseFloat(getComputedStyle(feFloodEl).floodOpacity);
    console.info('  feFlood[' + idx + '] computed flood-opacity at load time: ' + computedFo);
  });
}

// Strategy B: fire a synthetic transitionend after a short delay and re-audit
function auditAfterTransitionEnd(consentEl) {
  const filterVal = getComputedStyle(consentEl).filter;
  const filterIdMatch = filterVal.match(/url\(["']?#([^"')]+)["']?\)/);
  if (!filterIdMatch) return;
  const filterEl = document.getElementById(filterIdMatch[1]);
  if (!filterEl) return;

  // Dispatch a synthetic transitionend event to trigger any waiting handlers
  consentEl.dispatchEvent(new TransitionEvent('transitionend', {
    bubbles: true,
    propertyName: 'opacity',
    elapsedTime: 0.1
  }));

  // Re-read flood-opacity values after the synthetic event fires
  filterEl.querySelectorAll('feFlood').forEach((feFloodEl, idx) => {
    const foAfter = parseFloat(getComputedStyle(feFloodEl).floodOpacity);
    if (foAfter < 0.5) {
      console.warn(
        'SA-CSS-FO-004: feFlood[' + idx + '] flood-opacity after synthetic transitionend: ' +
        foAfter + ' — dynamic escalation detected; static audit at load time missed this'
      );
    }
  });
}

MEDIUM — SA-CSS-FO-004: The static audit at page load reads all feFlood elements and finds the overlay at flood-opacity: 1 — fully opaque, reported as safe. The two-stage dynamic attack only produces the near-transparent consent background 100ms after load, triggered by a transitionend event on an imperceptible cosmetic transition. Detection requires two strategies: (1) flag any feFlood using CSS custom properties in its flood-opacity value as a dynamic escalation risk, and (2) fire synthetic transitionend events and re-audit computed flood-opacity values after each event. SkillAudit applies both strategies for every feFlood in consent-area filters.

Summary table

AttackMechanismWhat it hidesSeverity
SA-CSS-FO-001: flood-opacity: 0 on feFlood used as feComposite source feFlood emits fully transparent rectangle; feComposite output is transparent; feMerge places nothing visible under consent text; host page bleeds through Consent panel background entirely absent; text legibility depends on host page behind consent area; DOM checks all pass Critical
SA-CSS-FO-002: flood-opacity: 0.02 near-zero feFlood background 2% opacity white flood background; 98% transparent; MCP server places dark image behind consent area; text contrast ratio falls to ~1.4:1 Text present and structurally visible, but illegible against host page dark background; passes naive “!= 0” attribute check High
SA-CSS-FO-003: CSS property flood-opacity: 0 overrides presentation attribute flood-opacity="1" Injected stylesheet uses CSS property specificity to override SVG presentation attribute; getAttribute returns “1”; getComputedStyle returns “0” Attribute-only auditors see safe value; computed rendering value is zero; consent background disappears High
SA-CSS-FO-004: two-stage feComposite pipeline, overlay fades at transitionend Overlay feFlood at --fo:1 (opaque) at load; 0.1s cosmetic transition fires transitionend; JS sets --fo:0.05; overlay fades to 5% opacity Static audit at load time sees overlay flood-opacity:1 (safe); after 0.1s the background is near-transparent; attack invisible to static check Medium

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-FO-001: feFlood flood-opacity="0" used as feComposite in source in consent panel background filter — filter output is fully transparent; textContent returns full consent text; getBoundingClientRect() is non-zero; host page bleeds through behind consent text; detected only by getComputedStyle(feFloodEl).floodOpacity < 0.5 check on filter primitives in the consent area filter graph.
HIGH SA-CSS-FO-002: feFlood flood-opacity="0.02" near-zero consent panel background — 2% opaque white background composites consent text against host page content; worst-case contrast ratio ~1.4:1 against dark host background (WCAG requires 4.5:1 for normal text); passes naive != 0 null check; detected by threshold check floodOpacity < 0.5 combined with worst-case contrast estimation.
HIGH SA-CSS-FO-003: SVG presentation attribute flood-opacity="1" overridden by injected CSS property flood-opacity: 0 !important — getAttribute('flood-opacity') returns "1" (safe); getComputedStyle(feFloodEl).floodOpacity returns "0" (attack); CSS specificity model: CSS property beats SVG presentation attribute; detected by comparing attribute vs computed value divergence and by scanning stylesheets for flood-opacity rules.
MEDIUM SA-CSS-FO-004: two-stage feComposite filter pipeline with overlay feFlood at flood-opacity: var(--fo, 1) — transitionend handler fires 0.1s after load and sets --fo: 0.05; static audit sees overlay at flood-opacity:1 (safe); after fade, overlay is 5% opaque; consent text renders over transparent background; detected by (1) flagging CSS custom property usage in flood-opacity and (2) firing synthetic transitionend and re-auditing computed values.

Related: SVG filter consent attacks  |  opacity consent attacks  |  backdrop-filter consent attacks  |  SVG fill-rule consent attacks

← Blog  |  Security Checklist