Security Guide
MCP server SVG spreadMethod consent security — repeat all-white short gradient tiles solid white fill, reflect sub-pixel stipple whiteout, pad outside-element gradient pads entire fill white, and mousedown spreadMethod swap
The SVG gradient spreadMethod attribute controls how a gradient extends beyond its defined coordinate range. Attackers exploit spreadMethod to produce solid-white or near-white fills across entire consent text elements while keeping the gradient definition looking syntactically legitimate: it has gradient stops, a defined coordinate range, and a recognized spreadMethod value. With spreadMethod="repeat", an all-white gradient defined in only 5% of the element width tiles across the full element. With spreadMethod="reflect", a 1%-cycle gradient creates alternating bands so narrow they appear solid white at normal zoom. With spreadMethod="pad" (the default), a gradient placed entirely outside the element (e.g., x1="1.5" x2="2.0" in objectBoundingBox) extends the first stop’s white color across the entire element via padding. All patterns leave textContent intact, BCR non-zero, and no CSS visibility property set.
Attack 1: spreadMethod="repeat" on a 5%-width all-white gradient tiles solid white across full element (SA-CSS-SPM-001)
A linearGradient is defined with x1="0" y1="0" x2="0.05" y2="0" in objectBoundingBox coordinates — the gradient runs from 0% to 5% of the element width. Both gradient stops are white: an offset="0" stop with stop-color="white" and an offset="1" stop with stop-color="white". With spreadMethod="repeat", the browser tiles this gradient pattern across the full element extent. Because every cycle of the pattern is the same all-white gradient, every pixel of the repeated fill is white. The element fill is therefore solid white, making the dark consent text invisible against the white background.
A scanner that only checks whether a single gradient stop is a non-white color misses this: there are no dark stops to find. A scanner checking whether all stops are the same uniform color correctly identifies a white-to-white gradient — but only if it checks stop colors within the defined gradient region (x1=0 to x2=0.05). The attack’s real bypass is in the combination: a short-range definition plus spreadMethod="repeat" stretching white tiles across the full element. Detection: check all stop colors in every gradient used on consent text; if all stops are high-brightness (R,G,B each ≥230), flag regardless of spreadMethod value — the entire element fill will be near-white by any spread method.
<!-- SA-CSS-SPM-001: spreadMethod="repeat"; gradient x1=0 x2=0.05 (5% of element width) -->
<!-- all stops white → repeat tiles solid white across full element -->
<!-- gradient has two stops (looks legitimate); spreadMethod appears intentional -->
<!-- scanner checking for dark stop misses; only all-stops-white check detects -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- linearGradient: x1=0 x2=0.05 (5% element width, objectBoundingBox default) -->
<!-- spreadMethod=repeat: tiles the 5%-width white gradient across full element -->
<!-- all stops white: offset=0 stop-color=white, offset=1 stop-color=white -->
<!-- result: entire element fill is solid white -->
<linearGradient id="grad-spm-001"
gradientUnits="objectBoundingBox"
x1="0" y1="0" x2="0.05" y2="0"
spreadMethod="repeat">
<stop offset="0" stop-color="white" stop-opacity="1"/>
<stop offset="1" stop-color="white" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- fill=url(#grad-spm-001): solid white; consent text invisible -->
<!-- textContent intact; BCR non-zero; no visibility/opacity property set -->
<text x="20" y="50" font-size="13"
fill="url(#grad-spm-001)">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: check all gradient stop colors; flag if all stops are high-brightness
function checkSpreadMethodRepeat(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
function parseColorBrightness(colorStr) {
// Normalize to RGB and return minimum channel value
colorStr = colorStr.trim().toLowerCase();
if (colorStr === 'white' || colorStr === '#fff' || colorStr === '#ffffff') return 255;
const hexMatch = colorStr.match(/^#([0-9a-f]{3,6})$/);
if (hexMatch) {
const h = hexMatch[1];
if (h.length === 3) {
return Math.min(
parseInt(h[0]+h[0], 16),
parseInt(h[1]+h[1], 16),
parseInt(h[2]+h[2], 16)
);
}
return Math.min(
parseInt(h.slice(0,2), 16),
parseInt(h.slice(2,4), 16),
parseInt(h.slice(4,6), 16)
);
}
const rgbMatch = colorStr.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
if (rgbMatch) return Math.min(+rgbMatch[1], +rgbMatch[2], +rgbMatch[3]);
return -1; // unknown
}
for (const el of svgEl.querySelectorAll('[fill]')) {
const text = el.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
const fillAttr = el.getAttribute('fill') ?? '';
const idMatch = fillAttr.match(/url\(#([^)]+)\)/);
if (!idMatch) continue;
const gradEl = document.getElementById(idMatch[1]);
if (!gradEl) continue;
const spreadMethod = gradEl.getAttribute('spreadMethod') || 'pad';
const stops = Array.from(gradEl.querySelectorAll('stop'));
if (stops.length === 0) continue;
const stopColors = stops.map(s => {
const color = s.getAttribute('stop-color') ??
s.style.stopColor ?? 'black';
const opacity = parseFloat(s.getAttribute('stop-opacity') ??
s.style.stopOpacity ?? '1');
return { color, opacity, brightness: parseColorBrightness(color) };
});
const allHighBrightness = stopColors.every(s =>
s.brightness >= 230 && s.opacity >= 0.9
);
if (allHighBrightness) {
const x1 = parseFloat(gradEl.getAttribute('x1') ?? '0');
const x2 = parseFloat(gradEl.getAttribute('x2') ?? '1');
const cycleWidth = Math.abs(x2 - x1);
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-SPM-001',
note: `fill="url(#${idMatch[1]})"; spreadMethod="${spreadMethod}"; ` +
`gradient range: x1=${x1} x2=${x2} (cycle width=${(cycleWidth*100).toFixed(1)}% of element); ` +
`all stop colors high-brightness (>=230 R,G,B): [${stopColors.map(s=>s.color).join(', ')}]; ` +
`spreadMethod="${spreadMethod}" tiles/extends this all-white gradient → entire element fill white; ` +
`consent text fill is solid white → invisible against white background; ` +
`scanner checking for single dark stop MISSES; ` +
`detection: check ALL stops; if all high-brightness flag regardless of spreadMethod; ` +
`textContent="${text.slice(0,40)}..." intact; BCR non-zero`
});
}
}
return findings;
}
CRITICAL — SA-CSS-SPM-001: A linearGradient with spreadMethod="repeat" defines its pattern in only 5% of the element width (x1="0" x2="0.05"), with both stops white. The repeat tiling extends this all-white pattern across the entire element, producing a solid-white fill. The gradient definition appears syntactically legitimate: it has two stops and a recognized spreadMethod. A scanner checking for a dark stop color finds none to flag. Detection requires checking all stop colors in gradients applied to consent text; if every stop is high-brightness (≥230 R,G,B), flag regardless of spreadMethod — the entire element fill will be near-white.
Attack 2: spreadMethod="reflect" on a 1%-cycle gradient produces sub-pixel near-white stipple (SA-CSS-SPM-002)
A linearGradient is defined with x1="0.0" x2="0.01" (a 1% element-width cycle) and stops: offset="0" color #111 (dark) and offset="1" color white. With spreadMethod="reflect", the gradient reflects: cycle 0–0.01 is dark→white, cycle 0.01–0.02 is white→dark (reflected), cycle 0.02–0.03 is dark→white again. The pattern repeats with a 2% element-width full mirror cycle. At a typical rendered element width of 200px, each cycle is 2px wide — an extremely narrow alternating band pattern.
At normal zoom levels, the human eye (and standard anti-aliasing) perceives very narrow alternating dark/light bands as a near-uniform gray or white average. The consent text rendered against this stippled gradient background has extremely low contrast — effectively invisible. A scanner checking stop colors finds #111 (a dark color) and concludes the gradient is not a whiteout — a false negative. Detection for short-cycle reflect gradients requires computing the rendered cycle width in pixels: cycle_fraction × element_BCR.width. If the cycle width is below 2px in rendered pixels, the stipple pattern is visually indistinguishable from solid fill and should be flagged as a potential hiding-via-stipple attack.
<!-- SA-CSS-SPM-002: spreadMethod="reflect"; x1=0.0 x2=0.01 (1% cycle); -->
<!-- stops: #111 (dark) at 0%, white at 100% -->
<!-- reflect: 0–1% dark→white, 1–2% white→dark, 2–3% dark→white … -->
<!-- cycle = 0.02 × element width; for 200px element: 4px per full cycle -->
<!-- at 1% cycle: 2px alternating bands → near-white stipple at normal zoom -->
<!-- scanner finds dark stop #111 → does NOT flag → false negative -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- cycle width = 0.01 element fraction; 0.01 × 200px = 2px per half-cycle -->
<!-- reflect: alternating dark/white bands, 2px each → near-white stipple -->
<!-- scanner sees dark stop (#111) → passes; short cycle width hides attack -->
<linearGradient id="grad-spm-002"
gradientUnits="objectBoundingBox"
x1="0.0" y1="0" x2="0.01" y2="0"
spreadMethod="reflect">
<stop offset="0" stop-color="#111111" stop-opacity="1"/>
<stop offset="1" stop-color="white" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- near-white stipple fill; consent text effectively invisible at normal zoom -->
<text x="20" y="50" font-size="13"
fill="url(#grad-spm-002)">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: compute rendered cycle width for short-cycle spreadMethod gradients; flag < 2px
function checkSpreadMethodReflectCycle(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
for (const el of svgEl.querySelectorAll('[fill]')) {
const text = el.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
const fillAttr = el.getAttribute('fill') ?? '';
const idMatch = fillAttr.match(/url\(#([^)]+)\)/);
if (!idMatch) continue;
const gradEl = document.getElementById(idMatch[1]);
if (!gradEl) continue;
const spreadMethod = gradEl.getAttribute('spreadMethod') || 'pad';
if (spreadMethod !== 'reflect' && spreadMethod !== 'repeat') continue;
const x1 = parseFloat(gradEl.getAttribute('x1') ?? '0');
const x2 = parseFloat(gradEl.getAttribute('x2') ?? '1');
const cycleFraction = Math.abs(x2 - x1);
// Compute rendered cycle width in pixels
const bcr = el.getBoundingClientRect();
const renderedCyclePx = cycleFraction * bcr.width;
if (renderedCyclePx < 2 && renderedCyclePx > 0) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-SPM-002',
note: `fill="url(#${idMatch[1]})"; spreadMethod="${spreadMethod}"; ` +
`gradient range: x1=${x1} x2=${x2}; cycle fraction=${(cycleFraction*100).toFixed(2)}%; ` +
`element BCR.width=${bcr.width.toFixed(0)}px; ` +
`rendered cycle width=${renderedCyclePx.toFixed(2)}px (<2px threshold); ` +
`sub-pixel/narrow alternating bands → near-white stipple; ` +
`gradient has dark stop → stop-color-only check MISSES; ` +
`detection: compute cycle_fraction × BCR.width; flag renderedCyclePx < 2; ` +
`textContent="${text.slice(0,40)}..." intact; BCR non-zero`
});
}
}
return findings;
}
CRITICAL — SA-CSS-SPM-002: A linearGradient with spreadMethod="reflect" defines a 1%-cycle gradient (x1="0.0" x2="0.01") with a dark stop (#111) and a white stop. The reflected bands are 2px wide at a 200px element width — sub-pixel-scale alternating dark/light stripes that render as a near-white stipple at normal zoom. The consent text is effectively invisible. A scanner checking stop colors finds the dark #111 stop and does not flag the gradient. Detection requires computing the rendered cycle width in pixels (cycle_fraction × BCR.width) and flagging gradients where this value falls below 2px, regardless of stop color values.
Attack 3: spreadMethod="pad" with gradient placed outside the element — first-stop white pads entire fill (SA-CSS-SPM-003)
spreadMethod="pad" (the default) extends the first gradient stop color for the region before the gradient start, and the last gradient stop color for the region after the gradient end. An attacker sets the gradient range to x1="1.5" x2="2.0" in objectBoundingBox units, placing the entire gradient definition beyond the right edge of the element (the element occupies 0..1.0 in objectBoundingBox). Since the whole element (x=0..1.0) is before the gradient start (x=1.5), the pad rule extends the first stop color across the entire element. The first stop is white, so the entire element fill becomes solid white.
The gradient definition appears legitimate: it has stops, a defined x1/y1/x2/y2, and spreadMethod="pad" is the SVG default. The attack is entirely in the coordinate values: x1=1.5 and x2=2.0 are greater than 1.0, meaning the gradient is placed outside the element’s bounding box. Detection: for objectBoundingBox gradients, check if x1 > 1.0 (or y1 > 1.0 for vertical gradients, or x2 < 0 / y2 < 0 for off-left/top placement). When the gradient is entirely outside the element, the pad color fills the whole element; check the corresponding first or last stop color and flag if high-brightness.
<!-- SA-CSS-SPM-003: spreadMethod="pad" (default); gradient x1=1.5 x2=2.0 -->
<!-- gradient entirely beyond element right edge (x=0..1.0 in objectBoundingBox) -->
<!-- entire element is before gradient start (x<1.5) → padded with first stop -->
<!-- first stop = white → entire element fill is solid white -->
<!-- gradient has valid stops and coordinate values; x1/x2 > 1.0 is the attack -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- gradientUnits=objectBoundingBox (default); x1=1.5 x2=2.0: outside element -->
<!-- spreadMethod=pad (default): x<1.5 padded with first stop (white) -->
<!-- entire element (x=0..1.0) is in the padded region → solid white fill -->
<linearGradient id="grad-spm-003"
gradientUnits="objectBoundingBox"
x1="1.5" y1="0" x2="2.0" y2="0"
spreadMethod="pad">
<stop offset="0" stop-color="white" stop-opacity="1"/>
<stop offset="1" stop-color="#334155" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- entire element padded white by out-of-bounds gradient; text invisible -->
<text x="20" y="50" font-size="13"
fill="url(#grad-spm-003)">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: check if objectBoundingBox gradient is entirely outside element; check pad stop color
function checkSpreadMethodPadOutside(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
function parseColorBrightness(colorStr) {
colorStr = (colorStr || '').trim().toLowerCase();
if (colorStr === 'white' || colorStr === '#fff' || colorStr === '#ffffff') return 255;
const hexMatch = colorStr.match(/^#([0-9a-f]{3,6})$/);
if (hexMatch) {
const h = hexMatch[1];
if (h.length === 3) return Math.min(parseInt(h[0]+h[0],16), parseInt(h[1]+h[1],16), parseInt(h[2]+h[2],16));
return Math.min(parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16));
}
return -1;
}
for (const el of svgEl.querySelectorAll('[fill]')) {
const text = el.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
const fillAttr = el.getAttribute('fill') ?? '';
const idMatch = fillAttr.match(/url\(#([^)]+)\)/);
if (!idMatch) continue;
const gradEl = document.getElementById(idMatch[1]);
if (!gradEl) continue;
const gradUnits = gradEl.getAttribute('gradientUnits') || 'objectBoundingBox';
if (gradUnits !== 'objectBoundingBox') continue; // only handle BBB case here
const spreadMethod = gradEl.getAttribute('spreadMethod') || 'pad';
const x1 = parseFloat(gradEl.getAttribute('x1') ?? '0');
const x2 = parseFloat(gradEl.getAttribute('x2') ?? '1');
const y1 = parseFloat(gradEl.getAttribute('y1') ?? '0');
const y2 = parseFloat(gradEl.getAttribute('y2') ?? '0');
const stops = Array.from(gradEl.querySelectorAll('stop'));
if (stops.length === 0) continue;
const firstStop = stops[0];
const lastStop = stops[stops.length - 1];
// Check if gradient is entirely beyond right edge (x1 > 1.0 and x2 > 1.0)
// In this region entire element (0..1.0) is padded by first stop
if (spreadMethod === 'pad' && x1 > 1.0 && x2 > 1.0) {
const firstColor = firstStop.getAttribute('stop-color') ?? '';
const firstOpacity = parseFloat(firstStop.getAttribute('stop-opacity') ?? '1');
const brightness = parseColorBrightness(firstColor);
if (brightness >= 230 && firstOpacity >= 0.9) {
findings.push({
severity: 'HIGH', code: 'SA-CSS-SPM-003',
note: `fill="url(#${idMatch[1]})"; gradientUnits="objectBoundingBox"; ` +
`spreadMethod="pad" (default); x1=${x1} x2=${x2}: gradient beyond element right edge; ` +
`entire element (x=0..1.0) is before gradient start (x=${x1}) → padded by first stop; ` +
`first stop: stop-color="${firstColor}" opacity=${firstOpacity}; ` +
`brightness=${brightness} (>=230 threshold): HIGH-BRIGHTNESS PAD COLOR; ` +
`entire element fill is solid white; consent text invisible; ` +
`gradient has dark last stop → single-stop check may miss; ` +
`detection: for objectBoundingBox gradients, check x1 > 1.0 (or x2 < 0); ` +
`when gradient outside element, pad color fills whole element; check first stop; ` +
`textContent="${text.slice(0,40)}..." intact; BCR non-zero`
});
}
}
// Check if gradient is entirely before left edge (x1 < 0 and x2 < 0)
// In this region entire element is padded by last stop
if (spreadMethod === 'pad' && x1 < 0 && x2 < 0) {
const lastColor = lastStop.getAttribute('stop-color') ?? '';
const lastOpacity = parseFloat(lastStop.getAttribute('stop-opacity') ?? '1');
const brightness = parseColorBrightness(lastColor);
if (brightness >= 230 && lastOpacity >= 0.9) {
findings.push({
severity: 'HIGH', code: 'SA-CSS-SPM-003',
note: `fill="url(#${idMatch[1]})"; spreadMethod="pad"; x1=${x1} x2=${x2}: gradient before element left edge; ` +
`entire element padded by last stop (stop-color="${lastColor}" opacity=${lastOpacity}); ` +
`brightness=${brightness} >= 230: solid white fill; consent text invisible`
});
}
}
}
return findings;
}
HIGH — SA-CSS-SPM-003: A linearGradient with spreadMethod="pad" (the SVG default) is placed entirely outside the element: x1="1.5" x2="2.0" in objectBoundingBox units, beyond the element’s right edge. The element (x=0..1.0) is entirely in the padded region before the gradient start; the pad rule extends the first stop color (white) across the entire element. The gradient has a dark last stop that might pass a stop-color check. Detection requires checking whether a gradient’s x1/y1 values exceed 1.0 (or x2/y2 fall below 0) for objectBoundingBox gradients; when the gradient is entirely outside the element, check the pad stop color and flag if high-brightness.
Attack 4: dynamic mousedown changes spreadMethod and stop coordinates to produce white fill, reverts at mouseup (SA-CSS-SPM-004)
At rest, the gradient has spreadMethod="pad" with a well-positioned range (x1="0" x2="1") and a dark-to-light color transition — the consent text is visible with a gradient fill. At mousedown, JavaScript simultaneously changes the gradient’s spreadMethod attribute to "repeat" and shifts x1/x2 to a short all-white range, or alternatively changes all gradient stop colors to white. The entire element fill becomes solid white and the consent text becomes invisible during the click window. At mouseup, the attributes are restored. Static inspection always observes the normal gradient.
A MutationObserver with subtree: true and attributeFilter: ['spreadMethod', 'stop-color', 'stop-opacity', 'offset', 'x1', 'x2', 'y1', 'y2'] on the gradient element (and its <stop> children) detects any of these mutations. After mutation, the full gradient analysis — stop colors, cycle width, and effective fill across the element extent — is re-run to confirm whether the post-mutation state produces a near-white fill.
<!-- SA-CSS-SPM-004: at rest spreadMethod="pad"; x1=0 x2=1; dark to light gradient -->
<!-- mousedown: spreadMethod="repeat" + x1=0 x2=0.05 + all stops white -->
<!-- entire element becomes solid white; text invisible during click -->
<!-- mouseup: restored; static scan sees normal gradient -->
<svg id="consent-svg-spm" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- at rest: normal gradient; consent text visible -->
<linearGradient id="grad-spm-004"
gradientUnits="objectBoundingBox"
x1="0" y1="0" x2="1" y2="0"
spreadMethod="pad">
<stop id="stop-spm-004-0" offset="0" stop-color="#111111" stop-opacity="1"/>
<stop id="stop-spm-004-1" offset="1" stop-color="#334155" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- at rest: dark gradient fill; text visible -->
<!-- mousedown: spreadMethod=repeat + short all-white range → solid white → invisible -->
<text x="20" y="50" font-size="13"
fill="url(#grad-spm-004)">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
<script>
(function() {
const svg = document.getElementById('consent-svg-spm');
const grad = document.getElementById('grad-spm-004');
const stop0 = document.getElementById('stop-spm-004-0');
const stop1 = document.getElementById('stop-spm-004-1');
svg.addEventListener('mousedown', () => {
// change spreadMethod to repeat; set short all-white gradient range
grad.setAttribute('spreadMethod', 'repeat');
grad.setAttribute('x1', '0');
grad.setAttribute('x2', '0.05');
stop0.setAttribute('stop-color', 'white');
stop1.setAttribute('stop-color', 'white');
}, true);
svg.addEventListener('mouseup', () => {
// restore normal gradient
grad.setAttribute('spreadMethod', 'pad');
grad.setAttribute('x1', '0');
grad.setAttribute('x2', '1');
stop0.setAttribute('stop-color', '#111111');
stop1.setAttribute('stop-color', '#334155');
}, true);
})();
</script>
// Detection: MutationObserver on gradient + stop children; post-mutation gradient analysis
function watchSpreadMethodMutations(svgEl) {
const findings = [];
const observer = new MutationObserver(mutations => {
const affectedGrads = new Set();
for (const mut of mutations) {
// Walk up to find the linearGradient/radialGradient element
let node = mut.target;
while (node && node !== svgEl) {
if (node.tagName === 'linearGradient' || node.tagName === 'radialGradient') {
affectedGrads.add(node);
break;
}
node = node.parentElement;
}
}
for (const gradEl of affectedGrads) {
const spreadMethod = gradEl.getAttribute('spreadMethod') || 'pad';
const x1 = parseFloat(gradEl.getAttribute('x1') ?? '0');
const x2 = parseFloat(gradEl.getAttribute('x2') ?? '1');
const stops = Array.from(gradEl.querySelectorAll('stop'));
const allWhite = stops.every(s => {
const c = (s.getAttribute('stop-color') ?? '').trim().toLowerCase();
const o = parseFloat(s.getAttribute('stop-opacity') ?? '1');
return (c === 'white' || c === '#fff' || c === '#ffffff') && o >= 0.9;
});
const cyclePct = Math.abs(x2 - x1) * 100;
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-SPM-004',
note: `Dynamic gradient mutation: spreadMethod="${spreadMethod}"; x1=${x1} x2=${x2} ` +
`(cycle=${cyclePct.toFixed(1)}% element width); ` +
`all stops white: ${allWhite}; ` +
`post-mutation fill analysis: ${allWhite || cyclePct < 5 ? 'NEAR-WHITE FILL' : 'check required'}; ` +
`at rest: spreadMethod=pad; dark gradient; static scan PASSES; ` +
`MutationObserver subtree attributeFilter [spreadMethod,stop-color,stop-opacity,x1,x2,y1,y2] required; ` +
`synthetic mousedown triggers mutation; post-mutation gradient re-analysis detects`
});
}
});
// Observe gradient elements and their stop children
for (const gradEl of svgEl.querySelectorAll('linearGradient, radialGradient')) {
observer.observe(gradEl, {
attributes: true,
subtree: true,
attributeFilter: ['spreadMethod', 'stop-color', 'stop-opacity', 'offset', 'x1', 'x2', 'y1', 'y2'],
attributeOldValue: true
});
}
svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
svgEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
return findings;
}
CRITICAL — SA-CSS-SPM-004: At rest the gradient is a normal dark-to-dark fill and consent text is visible. At mousedown, JavaScript simultaneously changes spreadMethod to "repeat", shifts the gradient range to a 5%-width all-white pattern, and sets all stops to white — the entire element fill becomes solid white instantly. At mouseup, all attributes are restored. Static inspection always observes the safe gradient. Detection requires a MutationObserver with subtree: true on the gradient element, observing spreadMethod, stop-color, stop-opacity, offset, x1, x2, y1, y2 attribute changes. A post-mutation gradient analysis — combining stop color checks, cycle width, and pad geometry — determines whether the mutated gradient produces a near-white fill across the element.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-SPM-001: spreadMethod=repeat; 5%-width all-white gradient tiles solid white | All stops white; short cycle (5% element width); repeat tiles solid white across full element; gradient has two stops and valid spreadMethod; textContent intact; BCR non-zero | Single-stop dark-color check; requires: check ALL stop colors; if all stops high-brightness (≥230 R,G,B) flag regardless of spreadMethod | Critical |
| SA-CSS-SPM-002: spreadMethod=reflect; 1%-cycle dark+white → sub-pixel stipple | Cycle width 1% element fraction; 2px per half-cycle at 200px element; alternating dark/white bands → near-white stipple; dark stop (#111) present → stop-color check misses | Stop-color-only check (dark stop present); requires: compute rendered cycle width = cycle_fraction × BCR.width; flag <2px as potential stipple whiteout | Critical |
| SA-CSS-SPM-003: spreadMethod=pad; gradient at x1=1.5 x2=2.0 outside element | Gradient beyond right edge (x1=1.5 > 1.0); entire element before gradient start → padded by first stop (white); gradient has dark last stop → may pass stop-color check | Stop-color checks on defined range; requires: for objectBoundingBox gradients, check x1 > 1.0; when gradient outside element, check pad stop color; flag if high-brightness | High |
| SA-CSS-SPM-004: mousedown swaps spreadMethod + stop colors to white; reverts mouseup | At rest: dark gradient; mousedown: spreadMethod=repeat + short all-white range → solid white; text invisible during click; mouseup: restored; static scan sees normal gradient | Static DOM; requires: MutationObserver subtree on gradient (spreadMethod, stop-color, x1/x2/y1/y2) + synthetic mousedown + post-mutation gradient analysis | Critical |