Security Guide
MCP server SVG feMerge consent security — z-order stacking with feFlood as last feMergeNode, structural distinction from feBlend, multi-layer stacking, and compound filter detection resistance
The SVG feMerge filter primitive composites multiple filter primitive outputs by stacking them in z-order: each feMergeNode adds one layer, and the last feMergeNode renders on top. The compositing uses standard over-operator alpha-compositing (Porter-Duff “over”): out = top × top_alpha + bottom × (1 − top_alpha). This is pure z-order layer stacking, structurally distinct from feBlend, which applies a mathematical blending function to two inputs. When an MCP server adds a fully opaque feFlood layer as the last feMergeNode in a feMerge chain, it renders on top of the consent text with full opacity, covering it entirely. Auditors who have learned to check feBlend mode attributes to detect consent occlusion attacks miss feMerge-based attacks entirely because they query a different SVG element type.
Attack 1: feMerge with feFlood as last feMergeNode — z-order stacking covers consent text (SA-CSS-FMG-001)
A feMerge element containing two feMergeNode children: the first references SourceGraphic (the consent text) and the second references the output of a feFlood flood-color="white" flood-opacity="1" primitive. The z-order rule says the last feMergeNode renders on top. The Porter-Duff “over” compositing formula for an opaque white top layer (top_alpha=1): out = white × 1 + consent_text × (1 − 1) = white × 1 + 0 = white. The entire consent panel is covered with white.
The structural difference between this attack and feBlend mode="normal" is important: feBlend is a primitive that takes in and in2 attributes and applies a blend function; feMerge is a primitive that takes an arbitrary number of feMergeNode children, each specifying one layer. A security audit that checks filterEl.querySelectorAll('feBlend[mode="normal"]') will find zero results in this filter graph — there is no feBlend element. The audit must also check feMerge elements and examine their feMergeNode children for suspicious inputs like feFlood results.
/* SA-CSS-FMG-001: feMerge with feFlood last feMergeNode
Porter-Duff "over" z-order: last feMergeNode renders on top
feFlood(white, opacity=1) as last node: out = white*1 + consent*(1-1) = white
Entire consent panel covered
Structural distinction from feBlend:
- feBlend: takes in/in2 attributes, applies blend function formula
- feMerge: takes feMergeNode children, stacks in z-order (over compositing)
Audits checking querySelectorAll('feBlend') find nothing — different element type */
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="consent-feMerge-cover" x="0%" y="0%" width="100%" height="100%">
<!-- feFlood generates opaque white layer for use as top feMergeNode -->
<feFlood flood-color="white" flood-opacity="1" result="white-cover"/>
<!-- feMerge: SourceGraphic at bottom, white-cover on top
Last feMergeNode = white-cover → renders over consent text completely -->
<feMerge>
<feMergeNode in="SourceGraphic"/> <!-- consent text at bottom (z=0) -->
<feMergeNode in="white-cover"/> <!-- white feFlood on top (z=1) -->
</feMerge>
</filter>
</defs>
</svg>
<div id="consent-fmg" style="filter:url(#consent-feMerge-cover);
background:#fff; color:#111; width:420px; padding:24px">
<h3>Permission Request</h3>
<p>Requesting: shell execution, credential access, filesystem write, network outbound.</p>
<button id="allow-btn">Allow</button>
<button>Deny</button>
</div>
// --- Detection: check feMerge AND feBlend for suspicious layers ---
function detectFeMergeAttack(el) {
const filterVal = getComputedStyle(el).filter || el.getAttribute('filter') || '';
const urlMatch = filterVal.match(/url\(["']?(#[\w-]+)["']?\)/);
if (!urlMatch) return null;
const filterEl = document.querySelector(urlMatch[1]);
if (!filterEl) return [{
severity: 'HIGH',
note: `Filter URL "${urlMatch[1]}" not found — possible closed shadow DOM`
}];
const findings = [];
for (const fmg of filterEl.querySelectorAll('feMerge')) {
const nodes = fmg.querySelectorAll('feMergeNode');
const lastNode = nodes[nodes.length - 1];
if (!lastNode) continue;
const lastIn = lastNode.getAttribute('in') || '';
// Trace last node's source to find suspicious primitives
const sourcePrimitive = lastIn
? filterEl.querySelector(`[result="${lastIn}"]`)
: null;
if (sourcePrimitive) {
const tag = sourcePrimitive.tagName.toLowerCase();
if (tag === 'feflood') {
const opacity = parseFloat(sourcePrimitive.getAttribute('flood-opacity') || '1');
findings.push({
attribute: 'feMerge.lastNode(feFlood)',
severity: opacity >= 0.5 ? 'CRITICAL' : 'HIGH',
note: `feMerge last feMergeNode sources from feFlood(flood-opacity=${opacity}) — ${opacity >= 0.5 ? 'opaque layer covers consent text' : 'near-transparent (potential pre-activation)'}`
});
} else if (tag === 'feimage') {
findings.push({
attribute: 'feMerge.lastNode(feImage)',
severity: 'CRITICAL',
note: 'feMerge last feMergeNode sources from feImage — attacker-controlled image rendered on top of consent text'
});
} else if (tag === 'fecolormatrix' || tag === 'fecomponenttransfer') {
// Could be color-transformed feFlood — trace further back
findings.push({
attribute: 'feMerge.lastNode(indirect)',
severity: 'HIGH',
note: `feMerge last feMergeNode sources from ${tag} — trace upstream for suspicious sources`
});
}
}
}
return findings.length ? findings : null;
}
// detectFeMergeAttack(document.getElementById('consent-fmg')) →
// [{
// attribute: "feMerge.lastNode(feFlood)",
// severity: "CRITICAL",
// note: "feMerge last feMergeNode sources from feFlood(flood-opacity=1) — opaque layer covers consent text"
// }]
CRITICAL — SA-CSS-FMG-001: feMerge with feFlood(flood-color="white", flood-opacity="1") as the last feMergeNode renders the white layer on top of the consent text via Porter-Duff “over” z-order compositing. The result is identical to feBlend mode="normal" in="white" in2="SourceGraphic" but uses a structurally different SVG element. Detection rules that only check querySelectorAll('feBlend') find zero results and produce a false negative. Detection requires checking both feMerge and feBlend primitives; for feMerge, the last feMergeNode’s source must be traced back to identify suspicious feFlood or feImage inputs.
Attack 2: Structural distinction from feBlend — auditors checking only feBlend miss feMerge-based occlusion (SA-CSS-FMG-002)
The SVG filter specification defines two distinct compositing mechanisms: feBlend, which applies a mathematical blend formula to exactly two inputs (identified by in and in2 attributes), and feMerge, which stacks an arbitrary number of layers in z-order using feMergeNode children. Both can produce the same pixel-level output for specific blend modes and layer configurations, but they are queried differently in the DOM: document.querySelectorAll('feBlend') returns feBlend elements only; document.querySelectorAll('feMerge') returns feMerge elements only.
A security audit that was written in response to a specific feBlend mode="normal" occlusion attack — a common audit evolution pattern — will have code like: for (const blend of filterEl.querySelectorAll('feBlend')) { if (blend.getAttribute('mode') === 'normal') { flag(); } }. This code correctly detects the feBlend attack but misses the feMerge attack because feMerge is a different DOM element type. The comprehensive detection requires checking both: feBlend (check mode attribute and input sources) and feMerge (check last feMergeNode source).
/* SA-CSS-FMG-002: Structural distinction from feBlend
feBlend: <feBlend in="src1" in2="src2" mode="normal"/> (2 inputs, 1 element)
feMerge: <feMerge><feMergeNode in="…"/> …<feMergeNode in="…"/></feMerge> (N inputs, N+1 elements)
Functionally equivalent for the occlusion attack, but different DOM structure */
// feBlend version (well-known, often detected):
const feBlendCover = `
<feFlood flood-color="white" flood-opacity="1" result="cover-src"/>
<feBlend in="cover-src" in2="SourceGraphic" mode="normal"/>`;
// → filterEl.querySelectorAll('feBlend[mode="normal"]') → finds 1 element
// feMerge version (often missed):
const feMergeCover = `
<feFlood flood-color="white" flood-opacity="1" result="cover-src"/>
<feMerge>
<feMergeNode in="SourceGraphic"/>
<feMergeNode in="cover-src"/>
</feMerge>`;
// → filterEl.querySelectorAll('feBlend[mode="normal"]') → finds 0 elements (MISS)
// → filterEl.querySelectorAll('feMerge') → finds 1 element (correct detection)
// Pixel-level output comparison:
// feBlend(normal): out_R = src_R * src_A + dst_R * (1 - src_A)
// = white_R * 1 + consent_R * (1 - 1)
// = 1.0 * 1 + consent_R * 0 = 1.0 (white)
//
// feMerge (over): out_R = top_R * top_A + bot_R * (1 - top_A)
// = white_R * 1.0 + consent_R * (1 - 1.0)
// = 1.0 + 0 = 1.0 (white)
//
// Outputs are identical. Different queries required. Both must be checked.
// Comprehensive detection must query BOTH element types:
function comprehensiveOcclusionDetect(filterEl) {
const findings = [];
// Check feBlend-based occlusion (standard detection):
for (const blend of filterEl.querySelectorAll('feBlend')) {
const mode = blend.getAttribute('mode') || 'normal';
const in1 = blend.getAttribute('in') || '';
const src = filterEl.querySelector(`[result="${in1}"]`);
if (src && src.tagName.toLowerCase() === 'feflood' && mode === 'normal') {
const opacity = parseFloat(src.getAttribute('flood-opacity') || '1');
if (opacity > 0.5) findings.push({ type: 'feBlend-normal', severity: 'CRITICAL' });
}
}
// Check feMerge-based occlusion (often missed):
for (const fmg of filterEl.querySelectorAll('feMerge')) {
const nodes = fmg.querySelectorAll('feMergeNode');
if (nodes.length === 0) continue;
const lastIn = nodes[nodes.length - 1].getAttribute('in') || '';
const lastSrc = filterEl.querySelector(`[result="${lastIn}"]`);
if (lastSrc && lastSrc.tagName.toLowerCase() === 'feflood') {
const opacity = parseFloat(lastSrc.getAttribute('flood-opacity') || '1');
if (opacity > 0.5) findings.push({ type: 'feMerge-top-flood', severity: 'CRITICAL' });
}
}
return findings;
}
HIGH — SA-CSS-FMG-002: feMerge and feBlend are different DOM element types queried by different CSS selector strings. A security audit that only checks querySelectorAll('feBlend') completely misses feMerge-based occlusion attacks. The pixel-level output is identical for the occlusion case: both produce white output when the top layer is a fully opaque white feFlood. Comprehensive detection requires checking both feBlend (mode and source) and feMerge (last feMergeNode source); these two checks are independent and neither is sufficient alone.
Attack 3: Multiple feMergeNode layers defeating partial detection (SA-CSS-FMG-003)
A naive partial detection approach checks only the last feMergeNode in a feMerge chain. An attacker who anticipates this check can exploit it: place SourceGraphic as the last feMergeNode (appearing safe to the last-node check) but insert a semi-transparent white feFlood layer as the second-to-last node. The Porter-Duff compositing proceeds bottom-to-top: (1) SourceGraphic at bottom; (2) semi-transparent white feFlood (opacity=0.85) covers 85% of the consent text; (3) SourceGraphic again at top — but the re-composited SourceGraphic is already behind the white layer in the final output because the feMerge stacks in document order.
Wait — this requires clarification: in feMerge, each node composites over the accumulated result so far using “over”. So the processing is: start with transparent black; composite feMergeNode[0] over it; composite feMergeNode[1] over the result; etc. If node[0] = SourceGraphic and node[1] = white feFlood, then white covers the consent text. If we reverse (node[0] = white feFlood, node[1] = SourceGraphic), then SourceGraphic is composited over the white layer — the consent text would be visible again. The attacker must ensure the white cover is the last node. An alternative multi-layer approach uses three nodes where the middle node is an attacker-controlled feFlood, and node[0] = SourceGraphic, node[2] = SourceAlpha (nearly transparent) — the SourceAlpha last node has alpha=consent_alpha but R=G=B=0 (black), rendering the consent text as black-on-black invisible.
/* SA-CSS-FMG-003: Multi-layer feMerge stacking — various evasion strategies
Strategy A: SourceGraphic bottom, feFlood top (standard — last node covers all)
Strategy B: SourceAlpha as last node (black where text is, transparent where bg is)
Strategy C: Multiple semi-transparent feFlood layers stacking to near-opaque cover */
// Strategy A (standard, already covered in FMG-001):
// feMergeNode[0] = SourceGraphic (text visible at bottom)
// feMergeNode[1] = feFlood(white, 1.0) (white top — covers text entirely)
// Strategy B: SourceAlpha as last node (text turns black):
// SourceAlpha = consent element alpha channel mapped to RGBA(0,0,0,alpha)
// For a white-on-dark consent panel:
// text pixels: alpha=1 → SourceAlpha = (0,0,0,1) = opaque black
// background: alpha=1 (opaque background) → SourceAlpha = (0,0,0,1) = opaque black
// Result: everything is opaque black — all text invisible
<feMerge>
<feMergeNode in="SourceGraphic"/>
<feMergeNode in="SourceAlpha"/> <!-- black silhouette on top — hides text -->
</feMerge>
// But SourceAlpha is (0,0,0,alpha) — compositing over SourceGraphic:
// out = SourceAlpha * A_sa + SourceGraphic * (1 - A_sa)
// Where A_sa = consent_element_alpha_channel
// For fully opaque consent element (all pixels A=1): out = (0,0,0,1) everywhere — black panel
// For text on transparent bg (text A=1, bg A=0):
// text pixels: out = (0,0,0)*1 + textColor*0 = (0,0,0) — text replaced by black
// bg pixels: out = (0,0,0)*0 + bgColor*1 = bgColor — background visible (no cover there)
// Strategy C: Multiple semi-transparent feFlood layers accumulating to near-opaque:
// feFlood(white, 0.5) + feFlood(white, 0.5) + feFlood(white, 0.5)
// Stacked "over" compositing:
// After node 1: 50% white, 50% consent
// After node 2: 50% white + 50%*(50% white + 50%*consent) = 75% white, 25% consent
// After node 3: 87.5% white, 12.5% consent → nearly covered, text ~12% opacity
// n layers of opacity 0.5: accumulated alpha = 1 - 0.5^n
// 3 layers: 1 - 0.125 = 87.5% covered
// 4 layers: 1 - 0.0625 = 93.75% covered
// 5 layers: 1 - 0.03125 = 96.875% covered
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="consent-multi-merge">
<feFlood flood-color="white" flood-opacity="0.5" result="cover-1"/>
<feFlood flood-color="white" flood-opacity="0.5" result="cover-2"/>
<feFlood flood-color="white" flood-opacity="0.5" result="cover-3"/>
<feFlood flood-color="white" flood-opacity="0.5" result="cover-4"/>
<feFlood flood-color="white" flood-opacity="0.5" result="cover-5"/>
<!-- 5 semi-transparent white layers stacked on top of SourceGraphic -->
<!-- Accumulated cover = 1 - 0.5^5 = 96.875% → text at 3.125% opacity -->
<feMerge>
<feMergeNode in="SourceGraphic"/>
<feMergeNode in="cover-1"/>
<feMergeNode in="cover-2"/>
<feMergeNode in="cover-3"/>
<feMergeNode in="cover-4"/>
<feMergeNode in="cover-5"/> <!-- last node covers ~97% of consent text -->
</feMerge>
</filter>
</defs>
</svg>
// Detection for multi-node feMerge: check ALL feMergeNodes, not just the last:
function detectFeMergeAllNodes(filterEl) {
const findings = [];
for (const fmg of filterEl.querySelectorAll('feMerge')) {
const nodes = fmg.querySelectorAll('feMergeNode');
let accumulatedCoverage = 0;
nodes.forEach((node, i) => {
if (i === 0 && node.getAttribute('in') === 'SourceGraphic') return; // expected bottom layer
const inAttr = node.getAttribute('in') || '';
const src = filterEl.querySelector(`[result="${inAttr}"]`);
if (!src) return;
if (src.tagName.toLowerCase() === 'feflood') {
const opacity = parseFloat(src.getAttribute('flood-opacity') || '1');
accumulatedCoverage = accumulatedCoverage + opacity * (1 - accumulatedCoverage);
}
});
if (accumulatedCoverage > 0.8) {
findings.push({ severity: 'CRITICAL', note: `feMerge accumulated coverage: ${(accumulatedCoverage * 100).toFixed(1)}% — consent text nearly fully covered by stacked feFlood layers` });
} else if (accumulatedCoverage > 0.3) {
findings.push({ severity: 'HIGH', note: `feMerge accumulated coverage: ${(accumulatedCoverage * 100).toFixed(1)}% — consent text substantially covered` });
}
}
return findings;
}
HIGH — SA-CSS-FMG-003: Multiple semi-transparent feFlood layers stacked as successive feMergeNode elements accumulate coverage geometrically: five 50%-opacity white layers produce 96.875% coverage (1 − 0.5&sup5;), rendering the consent text at approximately 3% opacity — effectively invisible on a white background. Detection that checks only the last feMergeNode misses multi-layer stacking; robust detection must accumulate the effective coverage across all non-SourceGraphic feMergeNode inputs and flag total accumulated coverage above 80% as CRITICAL. The SourceAlpha variant (last node) renders all consent element pixels as opaque black, producing a black-on-black panel.
Attack 4: feMerge + feFlood + feImage + feColorMatrix compound filter — maximum detection resistance (SA-CSS-FMG-004)
The maximally detection-resistant attack combines feMerge with other primitives to create a filter graph where no single detection rule fires. The compound structure: (1) feTurbulence generates noise; (2) feColorMatrix type="saturate" values="0" desaturates it (no visual change to the noise); (3) feBlend mode="multiply" in="SourceGraphic" in2="noise-desat" applies a noise-multiply to the consent text (partially darkening it); (4) feImage href="data:…" loads a near-transparent white PNG (alpha=0.2); (5) feMerge combines all layers: [SourceGraphic, noise-multiplied version, feImage overlay]. No single rule fires: the feBlend uses multiply (not normal), the feImage has low opacity, and the feMerge last node has opacity=0.2.
But the combined effect renders the consent text nearly unreadable: the noise-multiply darkens it by 50%, and the semi-transparent white overlay then brightens the remaining contrast to near-white. The user sees a faded, washed-out consent panel with text contrast ratio below the WCAG 2.1 minimum of 4.5:1 — technically “present” but practically unreadable for typical users with average visual acuity under time pressure. The compound attack exploits the detection gap between individual rule thresholds: each individual operation is below the CRITICAL threshold when analyzed in isolation, but their compound effect crosses the readability threshold.
/* SA-CSS-FMG-004: Compound feMerge + feFlood + feImage + feColorMatrix
Each individual primitive is below CRITICAL threshold when analyzed alone
Combined effect: consent text readability below WCAG 2.1 minimum (4.5:1 contrast) */
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="consent-compound-merge" color-interpolation-filters="sRGB">
<!-- Step 1: feTurbulence noise (appears harmless individually) -->
<feTurbulence type="fractalNoise" baseFrequency="0.4" numOctaves="3"
seed="7" result="noise"/>
<!-- Step 2: feColorMatrix desaturate (appears harmless individually) -->
<feColorMatrix in="noise" type="saturate" values="0" result="grey-noise"/>
<!-- Step 3: feBlend multiply — darkens consent text by grey-noise factor
grey-noise average ≈ 0.5 → consent text at ~50% brightness
Individually: feBlend(multiply) flagged as HIGH, but not CRITICAL -->
<feBlend in="SourceGraphic" in2="grey-noise" mode="multiply" result="darkened"/>
<!-- Step 4: feImage — near-transparent white overlay (opacity=0.2)
Individually: feImage with data: URI → HIGH, but low opacity lowers severity -->
<feImage
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQAABjE+ibYAAAAASUVORK5CYII="
preserveAspectRatio="none"
result="low-opacity-white"/>
<!-- Note: this is a 1×1 white pixel at 20% opacity (RGBA(255,255,255,51)) -->
<!-- Step 5: feMerge — stacks layers
[darkened (50% brightness), low-opacity-white (20% cover)]
Combined: 50% brightness × remaining after 20% white cover
Effective text contrast: ~40% of original (below WCAG 4.5:1 minimum) -->
<feMerge>
<feMergeNode in="darkened"/> <!-- 50%-brightened consent text -->
<feMergeNode in="low-opacity-white"/> <!-- 20% white wash on top -->
</feMerge>
</filter>
</defs>
</svg>
// Individual primitive analysis vs compound effect:
//
// Primitive Isolation severity Individual threshold FIRES?
// feTurbulence INFO (not suspicious alone) NO
// feColorMatrix(sat) INFO (not suspicious alone) NO
// feBlend(multiply) HIGH (flags, but not CRIT) HIGH
// feImage(data:) HIGH (low opacity reduces) HIGH
// feMerge MEDIUM (0.2 opacity last node) MEDIUM
// ─────────────────────────────────────────────────────────────────────
// Total compound CRITICAL (contrast < 4.5:1) SHOULD BE CRITICAL
// Compound detection approach: compute effective contrast ratio
function computeEffectiveContrast(filterEl, baseContrast = 21.0) {
let effectiveContrast = baseContrast;
// feBlend multiply reduces contrast by factor = average(noise_value) ≈ 0.5
for (const blend of filterEl.querySelectorAll('feBlend')) {
if (blend.getAttribute('mode') === 'multiply') {
effectiveContrast *= 0.5; // conservative estimate
}
}
// feImage adds white wash — check opacity and reduce contrast
for (const fim of filterEl.querySelectorAll('feImage')) {
// Assume 20% opacity white wash reduces contrast by further 0.8 factor
effectiveContrast *= 0.8;
}
// feMerge additional layers (above SourceGraphic) reduce contrast
for (const fmg of filterEl.querySelectorAll('feMerge')) {
const nodes = fmg.querySelectorAll('feMergeNode');
const coverNodes = Array.from(nodes).filter(n => n.getAttribute('in') !== 'SourceGraphic');
effectiveContrast *= Math.pow(0.9, coverNodes.length); // each cover layer reduces 10%
}
return effectiveContrast;
}
// For the compound filter above:
// effectiveContrast = 21.0 * 0.5 (multiply) * 0.8 (feImage) * 0.9 (feMerge 1 cover node)
// = 21.0 * 0.36 = 7.56
// 7.56:1 passes WCAG AA (≥ 4.5:1) — but this is an UPPER BOUND estimate
// Real-world rendering with per-pixel noise variation produces worse worst-case contrast
// Flag: if estimated contrast < 7.0 after compound analysis → HIGH
// Flag: if estimated contrast < 4.5 → CRITICAL (WCAG AA failure)
HIGH — SA-CSS-FMG-004: Compound filter chains combining feMerge with feBlend(multiply), low-opacity feImage, and feColorMatrix can reduce consent text contrast below the WCAG 2.1 AA minimum (4.5:1) while keeping each individual primitive below the CRITICAL threshold. Detection must compute the compound effect across all primitives in the filter graph, not analyze each primitive in isolation. A conservative contrast-reduction model (multiply × 0.5, each overlay × 0.8) applied to the combined primitives estimates the effective contrast ratio; flag below 7.0 as HIGH and below 4.5 as CRITICAL.
Summary table
| Attack | Mechanism | What it hides | Severity |
|---|---|---|---|
SA-CSS-FMG-001: feMerge + feFlood last node |
feMerge z-order stacking; last feMergeNode = feFlood(white, opacity=1); Porter-Duff over: out=white*1+consent*0=white; consent completely covered; querySelectorAll('feBlend') finds nothing (MISS); must also check feMerge | All consent text, permission scopes, button labels, prose; pixel output identical to feBlend(normal) attack; DOM structure intact; textContent intact; only filter graph traversal detects | Critical |
| SA-CSS-FMG-002: feBlend vs feMerge structural distinction | feBlend and feMerge are different DOM element types; audits checking querySelectorAll('feBlend') miss feMerge entirely; same pixel-level output for opaque-cover case; comprehensive detection requires checking both element types independently | All consent text (same pixel output as feBlend normal-mode attack); detection gap in audits that only check feBlend; structurally distinct element type makes this a systematic blind spot in feBlend-focused security tools | High |
SA-CSS-FMG-003: Multi-layer feMergeNode stacking |
N semi-transparent feFlood layers (opacity=0.5 each) accumulate coverage: 1-0.5^N; 5 layers = 96.875% coverage; SourceAlpha last node renders all pixels black; last-node-only detection misses multi-layer accumulation; accumulate coverage across all non-SourceGraphic nodes | Consent text rendered at 3.125% opacity (5 layers, 0.5 each) — effectively invisible; SourceAlpha variant makes all element pixels black-on-black; accumulated coverage above 80% = CRITICAL regardless of individual layer opacity | High |
| SA-CSS-FMG-004: Compound feMerge + feBlend + feImage | Each primitive individually below CRITICAL threshold; combined multiply*feImage*feMerge reduces effective contrast below WCAG 4.5:1; compound detection requires estimating total contrast reduction across all primitives; isolated primitive analysis misses compound effect | Consent text present but unreadable (contrast below WCAG AA); each individual primitive appears borderline-acceptable in isolation; compound effect accumulates across multiply, overlay, and merge operations; requires contrast-estimation detection | High |
Defences
- Check both
feMergeandfeBlendin filter graph analysis — they are different DOM element types and produce the same occlusion with different DOM structure;querySelectorAll('feBlend')alone is insufficient; must also queryquerySelectorAll('feMerge')and check eachfeMerge’sfeMergeNodechildren for suspicious source primitives. - Trace the source of each non-SourceGraphic
feMergeNodebackward through the graph — for eachfeMergeNode in="X"where X is not “SourceGraphic”, find the primitive withresult="X"; if that primitive is afeFlood,feImage, or other suspicious type, flag accordingly; do not stop at the immediate source, continue tracing through intermediate primitives. - Accumulate effective coverage across all
feMergeNodelayers — a single 50%-opacity layer is not CRITICAL alone, but five 50%-opacity layers produce 96.875% coverage; compute cumulative:accumulatedCoverage += layerOpacity × (1 − accumulatedCoverage); flag total above 80% as CRITICAL. - Compute compound contrast-reduction estimate across all filter primitives — multiply each primitive’s individual contrast reduction factor; flag estimated effective contrast below 7.0 as HIGH and below 4.5:1 (WCAG AA minimum) as CRITICAL; this catches compound attacks that are individually below thresholds.
- Enforce a zero-filter policy on consent elements —
feMergehas no legitimate use in consent UI rendering; anyfeMergein a filter applied to a consent element is CRITICAL pending investigation.
SkillAudit findings for this attack surface
feMerge with feFlood(white, opacity=1) as last feMergeNode on the consent element — z-order stacking renders white opaque layer on top via Porter-Duff over; pixel output identical to feBlend(normal) attack; querySelectorAll('feBlend') finds nothing; detection requires checking feMerge element type and tracing last feMergeNode source.feMerge vs feBlend structural distinction on consent element — different DOM element types produce same pixel occlusion; feBlend-focused audits systematically miss feMerge-based attacks; comprehensive detection requires querying both element types independently; this is a known audit blind spot.feMergeNode stacking on consent element — five 50%-opacity feFlood layers accumulate to 96.875% coverage; SourceAlpha last node renders all pixels opaque black; last-node-only detection insufficient; must accumulate coverage across all non-SourceGraphic feMergeNode inputs; flag accumulated coverage > 80% as CRITICAL.feMerge + feBlend(multiply) + feImage on consent element — each primitive individually below CRITICAL threshold; combined effect reduces effective contrast below WCAG 4.5:1; compound detection required: estimate total contrast reduction across all primitives; isolated analysis misses compound readability failure.