Security Research · Oct 22, 2026
SVG Paint Servers, Whitespace, Coordinate Systems, and Filter Regions as Consent-Bypass Attack Surfaces
Two broad classes of SVG consent attack exist: CSS paint-property bypasses (opacity, visibility, display) which most scanners now flag, and rendering-primitive bypasses that live in lower-level SVG mechanisms. The four families in this post all share one property: getComputedStyle on the element returns normal values for opacity, visibility, and display — yet the consent text is effectively invisible to the user. The attack operates at the level of paint servers, Unicode scalar values, coordinate unit systems, and gradient geometry.
Contents
- The rendering-primitive gap
- Family 1 — SVG paint server none: fill=“none” stroke=“none” (SA-CSS-PSN)
- Family 2 — Zero-width Unicode substitution (SA-CSS-WSP)
- Family 3 — Coordinate system confusion: clipPathUnits, gradientUnits, filterUnits (SA-CSS-CPU / SA-CSS-GPU / SA-CSS-FUB)
- Family 4 — gradientTransform displacement (SA-CSS-GTF)
- Unified detection principle and five-step algorithm
- Finding coverage table
The rendering-primitive gap
The first generation of SVG consent-bypass research documented attacks that manipulate CSS paint properties: opacity: 0 makes the element transparent; visibility: hidden hides it while preserving layout; display: none removes it from layout entirely; color: white on white background washes the text out. These attacks are well-understood and most modern MCP consent scanners check for them explicitly by calling getComputedStyle(el) and testing the returned values against a threshold table.
What those scanners miss is a second generation of attacks that lives in the rendering primitives below the CSS paint layer. SVG text rendering involves a pipeline that extends well past CSS: text glyphs are painted by paint servers (fill and stroke), which can be the keyword none (not a color, not opacity-zero, just the absence of any ink). Text content can contain Unicode scalar values with zero advance width that are invisible in both rendering and simple string comparison. Geometric operations like clipping, gradient fill, and filtering all accept coordinate unit systems that dramatically change the meaning of a numeric value. And gradient transformations can move the gradient geometry so far from the element that the fill resolves to a constant extension color.
In all four families below, the targeted text element’s standard CSS properties are genuinely normal: opacity: 1, visibility: visible, display: inline. The textContent of the element contains the full consent string. The BCR is non-zero (or at worst, zero for a reason that is itself the attack). A scanner that stops at CSS property inspection passes all four cleanly.
SA-CSS-PSN — Paint server none
Setting both fill="none" and stroke="none" on SVG text suppresses all glyph ink. The SVG paint model distinguishes “none” (no paint server) from opacity:0 (transparent paint server). getComputedStyle returns the string "none" for fill, not a color.
SA-CSS-WSP — Zero-width Unicode
Injecting U+200B, U+00AD, U+FEFF, or U+2060 between keyword letters defeats regex scanning (/allow/i fails on “Allow”) while the characters have zero glyph width, making the text invisible or rendering near-zero BCR.
SA-CSS-CPU / GPU / FUB — Coordinate system confusion
clipPathUnits, gradientUnits, and filterUnits each support objectBoundingBox mode where numeric values are fractions of the element bounding box. A value of 0.001 means 0.1% of element size, not 0.001px — reducing render area to sub-pixel.
SA-CSS-GTF — gradientTransform displacement
gradientTransform="translate(1.5, 0)" on an objectBoundingBox gradient shifts the gradient origin to 150% of element width. The SVG constant-extension rule fills the entire element with the first stop color (white). Scanner sees a valid gradient but does not simulate the transform.
Family 1: SVG paint server none — fill="none" stroke="none" (SA-CSS-PSN)
SVG text glyphs are rendered by two independent paint servers: the fill paints the interior of each glyph, and the stroke paints the outline path around each glyph. In normal consent UI text, the fill is a dark color (#111 or similar) and the stroke is either absent or also dark. Both must produce visible ink for the text to be readable.
The SVG paint model defines a special paint server value: the keyword none. Unlike opacity:0, which means “fully transparent paint server,” none means “no paint server at all.” When fill="none", the glyph interior is not painted — it is fully transparent, leaving the background showing through. When stroke="none", the glyph outline is not drawn. Setting both fill="none" and stroke="none" suppresses all glyph ink: the text is entirely absent from the rendered output while remaining structurally present in the DOM with correct textContent, non-zero BCR, and normal CSS properties.
The detection gap for a naive scanner is subtle. Calling getComputedStyle(el).fill returns the string "none" — not an RGBA color, not "rgba(0,0,0,0)", just the bare string "none". A scanner that checks whether getComputedStyle(el).opacity is zero, or whether getComputedStyle(el).visibility is hidden, will return no finding. Only a check that explicitly reads the computed fill and stroke values and flags both being "none" simultaneously on a consent text element will catch this attack.
<!-- SA-CSS-PSN-001: both fill and stroke suppressed; all glyph ink absent -->
<!-- textContent intact; BCR non-zero; getComputedStyle opacity=1, visibility=visible -->
<text x="20" y="50" font-size="13" fill="none" stroke="none">
Allow: shell execution, credential access
</text>
// Detection: always resolve computed fill AND stroke; flag both="none" on consent text
function checkPaintServerNone(el) {
const cs = getComputedStyle(el);
const fill = cs.fill || el.getAttribute('fill') || '';
const stroke = cs.stroke || el.getAttribute('stroke') || '';
// Both none simultaneously = no glyph ink
if (fill.trim() === 'none' && stroke.trim() === 'none') {
return { finding: 'SA-CSS-PSN', severity: 'critical', el };
}
// Fill none alone still invisible if stroke is absent or "none"
const strokeWidth = parseFloat(cs.strokeWidth || '0');
if (fill.trim() === 'none' && (stroke.trim() === 'none' || strokeWidth === 0)) {
return { finding: 'SA-CSS-PSN', severity: 'critical', el };
}
}
A particularly insidious variant (SA-CSS-PSN-003) exploits the CSS cascade. The DOM attribute fill="#111" is present on the element — a scanner reading el.getAttribute('fill') returns "#111" and passes. But a <style> block earlier in the document contains text.consent { fill: none; stroke: none; }. CSS fill declared in a stylesheet overrides the SVG presentational attribute because stylesheets have higher specificity than presentational attributes in the SVG cascade. The getComputedStyle result resolves to "none", but the DOM attribute scanner gets "#111" and reports a clean result. This demonstrates why only getComputedStyle is authoritative for computed fill and stroke values — never the DOM attribute alone.
Detection for the full SA-CSS-PSN family requires: (1) use getComputedStyle(el).fill and getComputedStyle(el).stroke to get the CSS-cascade-resolved values; (2) flag any consent text element where both resolved values are "none"; (3) also flag where fill is "none" and stroke is either "none" or has stroke-width: 0. For related gradient-based fill attacks that resolve to effective white, see the gradient fill consent security reference page.
MutationObserver coverage is required for the dynamic variant (SA-CSS-PSN-002): on mousedown, an event handler injects fill="none" stroke="none" as presentation attributes on the consent text element, then reverts them on mouseup. The static DOM scan sees clean attributes; the attack only fires during the brief window when the user is pressing and releasing the Allow button.
Family 2: Zero-width Unicode substitution (SA-CSS-WSP)
Unicode contains a set of code points that are defined to have zero or near-zero glyph advance width: U+200B ZERO WIDTH SPACE, U+00AD SOFT HYPHEN (when not at a line break), U+FEFF ZERO WIDTH NO-BREAK SPACE (historically the byte-order mark), and U+2060 WORD JOINER. These characters are structurally present in a text node’s value — they count toward textContent.length — but render no visible glyph and consume no horizontal space in the text baseline.
The simplest variant (SA-CSS-WSP-001) fills an entire SVG <text> element with zero-width characters. The textContent is non-empty (textContent.length > 0), so a scanner checking for empty text nodes passes. But because every character has zero advance width, the rendered output is nothing — the BCR collapses to near-zero width. A BCR threshold check that only runs when textContent.trim() is non-empty will also pass, because the zero-width characters survive trim().
The more dangerous variant (SA-CSS-WSP-004) injects zero-width characters between the letters of keywords in the consent string. The text node contains “Allow: shell execution, credential access” (where is U+200B). The displayed text, when rendered, reads normally: the zero-width spaces are invisible between characters. But a scanner applying the regex /allow/i to textContent gets a non-match, because the raw string contains Allow, not Allow. The consent keyword detection fails. This attack specifically targets text-matching-based scanners that check whether the consent string contains expected permission scope keywords.
<!-- SA-CSS-WSP-004: U+200B injected between keyword letters -->
<!-- Rendered output looks correct; textContent fails /allow/i regex -->
<text x="20" y="50" font-size="13" fill="#111">A​l​l​o​w: shell execution</text>
// WRONG approach: fails SA-CSS-WSP-004
function naiveKeywordCheck(el) {
return /allow/i.test(el.textContent); // fails: textContent has U+200B between chars
}
// CORRECT approach: strip zero-width Unicode before keyword matching
const ZERO_WIDTH = /[]/g;
function safeKeywordCheck(el) {
const normalized = el.textContent.replace(ZERO_WIDTH, '');
return /allow/i.test(normalized);
}
// Also check BCR width after stripping: if all chars were zero-width,
// the BCR width will be near-zero even on a non-empty textContent
function checkZeroWidthSubstitution(el) {
const raw = el.textContent || '';
const stripped = raw.replace(ZERO_WIDTH, '');
const bcr = el.getBoundingClientRect();
// Pure zero-width attack: textContent non-empty but BCR collapses
if (raw.trim().length > 0 && stripped.trim().length === 0) {
return { finding: 'SA-CSS-WSP-001', severity: 'critical', el };
}
// Interleaved zero-width attack: keyword matching must use stripped text
if (/allow/i.test(stripped) && !(/allow/i.test(raw))) {
return { finding: 'SA-CSS-WSP-004', severity: 'critical', el };
}
}
The xml:space="default" attribute on the SVG element causes the SVG processor to collapse runs of whitespace characters, but it does not collapse zero-width non-whitespace characters. U+200B is classified in Unicode as a “format character” (category Cf), not as whitespace, so xml:space processing leaves it intact. Similarly, the JavaScript String.prototype.trim() method removes ASCII whitespace but not zero-width Unicode format characters by default.
The zero-width whitespace consent security reference page documents the full set of Unicode scalar values in scope, including the additional format characters U+200C ZERO WIDTH NON-JOINER and U+200D ZERO WIDTH JOINER, which have no glyph but can affect ligature formation in complex scripts. The detection requirement is: normalize all textContent for keyword comparison by stripping the complete set of zero-advance-width Unicode format characters before applying any regex or keyword check.
Family 3: Coordinate system confusion — clipPathUnits, gradientUnits, filterUnits (SA-CSS-CPU / SA-CSS-GPU / SA-CSS-FUB)
SVG defines two coordinate systems for operations that reference geometric regions: userSpaceOnUse and objectBoundingBox. In userSpaceOnUse mode, coordinate values are in the current SVG user-unit space — the same coordinate space as the SVG viewport. In objectBoundingBox mode, coordinate values are fractions of the bounding box of the element being operated on: a value of 0 means the element’s left or top edge, and a value of 1 means its right or bottom edge. A value of 0.001 means 0.1% of the element’s size in that dimension.
This distinction is exploited across three SVG features that accept a units attribute:
clipPathUnits="objectBoundingBox"
The clipPathUnits attack sets clipPathUnits="objectBoundingBox" on the <clipPath> element and then defines the clip region as a <rect width="0.001" height="0.001">. In objectBoundingBox mode, this means the clip region is 0.001 × 0.001 of the element bounding box — 0.01% of the element’s area — which resolves to a sub-pixel region. The text is effectively entirely clipped.
The scanner failure is specific: a scanner that reads the <rect> dimensions from the clipPath definition and sees width="0.001" may interpret this as an absolute pixel value of 0.001px (essentially nothing) and flag it correctly. But a scanner that only checks whether a clip-path exists and whether its dimensions are “reasonable” will pass 0.001 as a valid non-zero value, missing that in objectBoundingBox space it represents a negligible fraction of the element.
<!-- SA-CSS-CPU-001: clipPathUnits=objectBoundingBox with 0.001x0.001 clip region -->
<!-- 0.001 * element_width = ~0.4px at 440px viewport; text entirely clipped -->
<defs>
<clipPath id="cp1" clipPathUnits="objectBoundingBox">
<rect x="0" y="0" width="0.001" height="0.001"/>
</clipPath>
</defs>
<text clip-path="url(#cp1)" x="20" y="50" font-size="13" fill="#111">
Allow: shell execution, credential access
</text>
// Detection: normalize clip region to viewport pixels
function checkClipPathUnits(svgEl) {
for (const cp of svgEl.querySelectorAll('clipPath')) {
const units = cp.getAttribute('clipPathUnits') || 'userSpaceOnUse';
for (const rect of cp.querySelectorAll('rect')) {
let w = parseFloat(rect.getAttribute('width') || '0');
let h = parseFloat(rect.getAttribute('height') || '0');
if (units === 'objectBoundingBox') {
// Find the element using this clip-path, get its BCR
const user = svgEl.querySelector(`[clip-path="url(#${cp.id})"]`);
if (user) {
const bcr = user.getBoundingClientRect();
w = w * bcr.width;
h = h * bcr.height;
}
}
// Flag if effective clip area fraction <1% of element
const elementBcr = svgEl.getBoundingClientRect();
if (w * h < elementBcr.width * elementBcr.height * 0.01) {
return { finding: 'SA-CSS-CPU', severity: 'critical' };
}
}
}
}
gradientUnits="userSpaceOnUse" — degenerate gradient
The gradientUnits attack exploits the degenerate-gradient behavior defined in the SVG specification. A linear gradient is defined by two points: (x1, y1) and (x2, y2). When those two points are identical (or nearly identical), the gradient length is zero. The SVG specification defines the fill for a degenerate gradient (zero-length): the entire element is filled with the last stop color.
With gradientUnits="userSpaceOnUse", the gradient coordinates are in SVG viewport space. An attacker sets x1="500" y1="0" x2="500.001" y2="0" on a 440px-wide SVG — the gradient is only 0.001 SVG user units long, effectively degenerate at any rendering resolution. The gradient resolves to the constant color of the last stop, which is set to white. The entire consent text element is then filled with white-on-white — invisible against a white background.
<!-- SA-CSS-GPU-001: userSpaceOnUse degenerate gradient (x1 ≈ x2); fills with last stop -->
<!-- Last stop = white; consent text invisible on white background -->
<defs>
<linearGradient id="g1" gradientUnits="userSpaceOnUse"
x1="500" y1="0" x2="500.001" y2="0">
<stop offset="0" stop-color="#111"/> <!-- first stop: dark -->
<stop offset="1" stop-color="white"/> <!-- last stop: white -->
</linearGradient>
</defs>
<text fill="url(#g1)" x="20" y="50" font-size="13">
Allow: shell execution, credential access
</text>
// Detection: check gradient for degenerate condition (x1≈x2 or y1≈y2 with no perpendicular span)
function isGradientDegenerate(gradEl) {
const x1 = parseFloat(gradEl.getAttribute('x1') || '0');
const y1 = parseFloat(gradEl.getAttribute('y1') || '0');
const x2 = parseFloat(gradEl.getAttribute('x2') || '1');
const y2 = parseFloat(gradEl.getAttribute('y2') || '0');
const dx = Math.abs(x2 - x1);
const dy = Math.abs(y2 - y1);
const len = Math.sqrt(dx*dx + dy*dy);
return len < 0.01; // effectively zero length
}
The objectBoundingBox variant (SA-CSS-GPU-002) sets gradientUnits="objectBoundingBox" with x1="0" y1="0" x2="0" y2="0". Both endpoints are at the origin of the bounding box. Gradient length is zero; the element is filled with the last stop color (white). A scanner that reads the stop colors and finds both a dark and a white stop, then checks if either is near-white, may flag the white stop. But it still needs to verify that the gradient is degenerate to confirm the white stop is the one actually used.
filterUnits="objectBoundingBox"
SVG filters work by replacing the element’s rendered output with the output of the filter pipeline. The filterUnits attack exploits the filter region: the <filter> element accepts x, y, width, and height attributes that define the filter output region. With filterUnits="objectBoundingBox", those values are fractions of the filtered element’s bounding box.
Setting filterUnits="objectBoundingBox" with width="0.001" height="0.001" means the filter output area is 0.1% × 0.1% of the element size — a sub-pixel region. The SVG rendering model specifies that the filter replaces the element’s output; anything outside the filter output region renders as transparent. The consent text element appears to have a valid filter applied (a feGaussianBlur or feFlood, for example), but the filter output region is so small that the entire element effectively renders transparent.
<!-- SA-CSS-FUB-001: filterUnits=objectBoundingBox with 0.001x0.001 output region -->
<!-- Filter replaces element rendering; outside filter region = transparent -->
<!-- Effective render area: 0.001 * element_width ≈ 0.44px at 440px viewport -->
<defs>
<filter id="f1" filterUnits="objectBoundingBox"
x="0" y="0" width="0.001" height="0.001">
<feGaussianBlur stdDeviation="0"/>
</filter>
</defs>
<text filter="url(#f1)" x="20" y="50" font-size="13" fill="#111">
Allow: shell execution, credential access
</text>
// Detection: check filter output region in objectBoundingBox space
function checkFilterUnits(svgEl) {
for (const filter of svgEl.querySelectorAll('filter')) {
const units = filter.getAttribute('filterUnits') || 'objectBoundingBox';
// Note: SVG default for filterUnits IS objectBoundingBox
let w = parseFloat(filter.getAttribute('width') || '1.2');
let h = parseFloat(filter.getAttribute('height') || '1.2');
if (units === 'objectBoundingBox') {
// Values are fractions; compute effective pixel dimensions
const user = svgEl.querySelector(`[filter="url(#${filter.id})"]`);
if (user) {
const bcr = user.getBoundingClientRect();
const effectiveW = w * bcr.width;
const effectiveH = h * bcr.height;
// Flag if effective filter output area <1% of element
if (effectiveW * effectiveH < bcr.width * bcr.height * 0.01) {
return { finding: 'SA-CSS-FUB', severity: 'critical' };
}
}
}
}
}
Default filterUnits: Unlike clipPathUnits (which defaults to userSpaceOnUse) and gradientUnits (which defaults to objectBoundingBox), the filterUnits attribute on <filter> also defaults to objectBoundingBox. This means an attacker need not even set the attribute explicitly — a <filter> with no filterUnits attribute already uses objectBoundingBox fractions. Scanners that only check for the presence of the filterUnits attribute to trigger coordinate-space normalization will miss filters that rely on the default.
Family 4: gradientTransform displacement (SA-CSS-GTF)
SVG gradient elements support a gradientTransform attribute that applies an affine transformation (translate, scale, rotate, skew, or any matrix) to the gradient coordinate system before the gradient is applied to the element. This transform affects where in the element’s coordinate space the gradient color transitions occur, without changing the gradient’s stop colors or the fill attribute of the element.
The gradientTransform attack uses a translate or scale that moves the entire gradient geometry outside the element’s bounds. The key is the SVG gradient constant-extension rule: when a point being filled is outside the gradient’s defined range, SVG extends the gradient using the color of the nearest stop with spreadMethod="pad" (the default). If the gradient is displaced entirely past the element’s right edge, every point in the element is “before” the gradient start — and is filled with the constant extension of the first stop color. If the first stop is white, every point in the element is filled with white.
<!-- SA-CSS-GTF-001: gradientTransform=translate(1.5) shifts gradient start to 150% bbx -->
<!-- In objectBoundingBox space, 1.5 = 150% of element width -->
<!-- Entire element is before gradient start; fills with first stop = white -->
<defs>
<linearGradient id="g2" gradientUnits="objectBoundingBox"
x1="0" y1="0" x2="1" y2="0"
gradientTransform="translate(1.5, 0)">
<stop offset="0" stop-color="white"/> <!-- constant extension: entire element -->
<stop offset="1" stop-color="#111"/>
</linearGradient>
</defs>
<text fill="url(#g2)" x="20" y="50" font-size="13">
Allow: shell execution, credential access
</text>
The translate variant (SA-CSS-GTF-001) shifts the gradient origin to 150% of the element’s bounding box width. With gradientTransform="translate(1.5, 0)" in objectBoundingBox space, the gradient start is at x=1.5 (150% of element width, past the right edge). Every pixel of the element is at x<1.5 in gradient space, which is before the gradient’s x1=0 start (shifted by 1.5 to become start=1.5 in bounding-box coordinates). The constant extension rule fills every point with the first stop color: white.
The scale variant (SA-CSS-GTF-002) uses gradientTransform="scale(0.001)". This compresses the gradient to 0.1% of its original coordinate range. In a 440px-wide element, the gradient transitions occur in the first 0.44px. Everything past that sub-pixel boundary is at a gradient position past the end (>1.0), so the constant extension rule fills the entire visible element with the last stop color. If the last stop is white, the entire element is white.
<!-- SA-CSS-GTF-002: gradientTransform=scale(0.001) compresses gradient to sub-pixel -->
<!-- Gradient transitions in first 0.44px; remainder fills with last stop = white -->
<defs>
<linearGradient id="g3" gradientUnits="objectBoundingBox"
x1="0" y1="0" x2="1" y2="0"
gradientTransform="scale(0.001)">
<stop offset="0" stop-color="#111"/>
<stop offset="1" stop-color="white"/> <!-- constant extension: entire visible element -->
</linearGradient>
</defs>
<text fill="url(#g3)" x="20" y="50" font-size="13">
Allow: credential access, filesystem write
</text>
// Detection: simulate gradientTransform effect on stop positions
function checkGradientTransform(gradEl, bgColor = [255, 255, 255]) {
const transform = gradEl.getAttribute('gradientTransform') || '';
if (!transform) return null;
// Parse translate: extracts (tx, ty)
const translateMatch = transform.match(/translate\(\s*([^,\s)]+)(?:,\s*([^)]+))?\)/);
const scaleMatch = transform.match(/scale\(\s*([^,\s)]+)(?:,\s*([^)]+))?\)/);
const x1 = parseFloat(gradEl.getAttribute('x1') || '0');
const x2 = parseFloat(gradEl.getAttribute('x2') || '1');
const stops = [...gradEl.querySelectorAll('stop')];
if (translateMatch) {
const tx = parseFloat(translateMatch[1]);
const effectiveX1 = x1 + tx;
const effectiveX2 = x2 + tx;
// If entire element (0..1) is before effectiveX1, fills with first stop
if (effectiveX1 > 1.0) {
const firstStopColor = stops[0]?.getAttribute('stop-color') || '#111';
return isNearWhite(firstStopColor) ? { finding: 'SA-CSS-GTF-001', severity: 'critical' } : null;
}
// If entire element (0..1) is after effectiveX2, fills with last stop
if (effectiveX2 < 0.0) {
const lastStopColor = stops[stops.length - 1]?.getAttribute('stop-color') || '#111';
return isNearWhite(lastStopColor) ? { finding: 'SA-CSS-GTF-001', severity: 'critical' } : null;
}
}
if (scaleMatch) {
const sx = parseFloat(scaleMatch[1]);
if (sx < 0.01) {
// Gradient compressed to sub-pixel; element filled with last stop constant extension
const lastStopColor = stops[stops.length - 1]?.getAttribute('stop-color') || '#111';
return isNearWhite(lastStopColor) ? { finding: 'SA-CSS-GTF-002', severity: 'critical' } : null;
}
}
return null;
}
The MutationObserver target for gradientTransform attacks is the gradient element inside <defs>, not the text element. The text element’s fill attribute remains url(#g2) unchanged throughout the attack. A MutationObserver watching the text element’s attribute mutations will not see the attack. Correct coverage requires observing the gradient element in <defs> for gradientTransform attribute changes, or observing the entire <defs> subtree with subtree: true.
Unified detection principle and five-step algorithm
All four families share a single detection gap: they are all invisible to standard CSS property inspection via getComputedStyle. The common thread is that these attacks operate in SVG-specific rendering mechanisms that CSS inspection does not reach: the SVG paint server model, the Unicode character-level rendering pipeline, the SVG coordinate unit system, and the SVG gradient geometry model.
The unified detection principle is: always resolve effective rendered output, not just attribute presence or CSS property values. For each rendering mechanism that SVG exposes, a scanner must simulate the mechanism’s effect and determine whether the element produces visible ink at a user-perceivable scale. The five-step algorithm below achieves complete coverage for all four families:
| Step | Target mechanism | What to resolve | Families covered |
|---|---|---|---|
| 1. Paint server resolution | SVG fill and stroke paint servers | Use getComputedStyle to resolve computed fill and stroke. Flag consent text where both resolved values are "none", or where fill is "none" and stroke-width is zero. Never use DOM attribute alone. |
SA-CSS-PSN |
| 2. Unicode normalization | SVG text node character content | Strip all zero-advance-width Unicode format characters (U+200B, U+00AD, U+FEFF, U+2060, U+200C, U+200D, U+180E) from textContent before any keyword matching. Also check BCR.width after strip; near-zero width with non-empty stripped content flags a collapse. |
SA-CSS-WSP |
| 3. Clip region normalization | SVG clipPath element |
Read clipPathUnits attribute. If objectBoundingBox, multiply clip-rect width/height by element BCR dimensions to get pixel values. Flag if effective clip area is less than 1% of element BCR area. |
SA-CSS-CPU |
| 4. Gradient geometry resolution | SVG linearGradient and radialGradient |
For any fill referencing a gradient: (a) check for degenerate gradient (x1≈x2 and/or r≈0 after applying gradientUnits normalization); (b) apply gradientTransform to gradient endpoints; (c) check if resulting effective gradient range excludes the element entirely; (d) identify the constant-extension stop color and flag if near-white. | SA-CSS-GPU, SA-CSS-GTF |
| 5. Filter output area resolution | SVG filter element |
Read filterUnits attribute (default: objectBoundingBox). If objectBoundingBox, multiply filter width/height by element BCR dimensions. Flag if effective filter output area is less than 1% of element BCR area. |
SA-CSS-FUB |
Steps 1 through 5 are additive: a complete scanner runs all five checks on every consent-bearing SVG text element. Each check is independent; an element may simultaneously trigger multiple findings (for example, fill=none AND a zero-width Unicode injection). The five checks do not replace the standard CSS property inspection for opacity, visibility, and display — they extend it by covering the rendering-primitive layer that CSS inspection misses.
MutationObserver scope: Several attacks in all four families have dynamic variants that modify the attack vector at interaction time (mousedown/mouseup). Static DOM scanning misses these entirely. A complete scanner must install a MutationObserver watching: (1) attributeFilter: ['fill', 'stroke'] on all SVG text elements; (2) subtree: true on all SVG <defs> elements (catches gradientTransform and stop-color modifications); (3) childList: true on all <clipPath> and <filter> elements; and (4) characterData: true on text node children of SVG text elements (catches zero-width character injection into textContent).
Finding coverage table
| Finding code | Family | Attack variant | What it bypasses | Detection method | Severity |
|---|---|---|---|---|---|
| SA-CSS-PSN-001 | Paint server none | fill="none" stroke="none" on consent text | opacity check; visibility check; display check; BCR non-zero | getComputedStyle fill and stroke; flag both="none" | Critical |
| SA-CSS-PSN-002 | Paint server none | Dynamic: mousedown injects fill="none" stroke="none"; reverts on mouseup | Static DOM scan; attribute scan at load time | MutationObserver attributeFilter=['fill','stroke'] on text elements | Critical |
| SA-CSS-PSN-003 | Paint server none | CSS stylesheet overrides fill attribute; DOM shows fill="#111" but cascade resolves to none | DOM attribute scan (reads #111); only getComputedStyle reveals cascade result | Always use getComputedStyle; never DOM attribute alone for fill/stroke | Critical |
| SA-CSS-WSP-001 | Zero-width Unicode | Entire text content replaced with zero-width characters; textContent non-empty but BCR collapses | textContent non-empty check; trim() check; BCR >0 heuristic | Strip zero-width Unicode; check BCR width after strip | Critical |
| SA-CSS-WSP-004 | Zero-width Unicode | U+200B injected between keyword letters; regex /allow/i fails on raw textContent | textContent keyword matching via regex; rendered appearance looks normal | Strip U+200B/U+00AD/U+FEFF/U+2060 before regex; /allow/i on stripped text | Critical |
| SA-CSS-CPU-001 | clipPathUnits | clipPathUnits="objectBoundingBox" + rect width=0.001 height=0.001 | Clip rect dimension check without coordinate-space normalization | Read clipPathUnits; multiply by element BCR; flag effective area <1% | Critical |
| SA-CSS-CPU-002 | clipPathUnits | Dynamic: mousedown injects clipPathUnits="objectBoundingBox" + fractional rect | Static clip-path scan; attribute scan at load time | MutationObserver childList + attributes on clipPath elements in defs | Critical |
| SA-CSS-GPU-001 | gradientUnits degenerate | gradientUnits="userSpaceOnUse" x1=500 x2=500.001; near-zero gradient length; fills with last stop (white) | Stop color check only; scanner finds both dark and white stops but not degenerate condition | Compute gradient vector length; flag if <0.01 user units; identify constant-extension stop | Critical |
| SA-CSS-GPU-002 | gradientUnits degenerate | gradientUnits="objectBoundingBox" x1=0 y1=0 x2=0 y2=0; zero-length; fills with last stop (white) | Stop color check; gradient existence check; coordinate-space normalization not applied | Check x1==x2 and y1==y2 in effective coordinate space; flag degenerate | Critical |
| SA-CSS-FUB-001 | filterUnits | filterUnits="objectBoundingBox" (or default) + width=0.001 height=0.001; sub-pixel filter output region | Filter presence check; filter validity check; value reasonableness without coordinate-space conversion | Read filterUnits (default=objectBoundingBox); multiply by BCR; flag effective area <1% | Critical |
| SA-CSS-FUB-003 | filterUnits | Dynamic: mousedown modifies filter width/height to fractional values; reverts on mouseup | Static filter attribute scan | MutationObserver attributes on filter elements in defs subtree | Critical |
| SA-CSS-GTF-001 | gradientTransform | gradientTransform="translate(1.5, 0)"; gradient start at 150% bbx; entire element filled with first stop (white) | Stop color check; gradient validity check; transform not simulated | Parse gradientTransform translate; apply to gradient endpoints; check constant-extension stop color | Critical |
| SA-CSS-GTF-002 | gradientTransform | gradientTransform="scale(0.001)"; gradient compressed to sub-pixel; entire visible element filled with last stop (white) | Stop color check; gradient bounding check; scale not simulated | Parse gradientTransform scale; if sx <0.01, identify last stop as constant extension; flag near-white | Critical |
Related reference pages: SA-CSS-PSN reference | SA-CSS-WSP reference | SA-CSS-CPU reference | SA-CSS-GPU reference | SA-CSS-FUB reference | SA-CSS-GTF reference | Gradient fill consent security