Security Guide
MCP server SVG gradientTransform consent security — gradientTransform="translate(1.5,0)" shifts the gradient coordinate system so the consent text falls entirely in the pure-white pre-gradient zone, scale(0.001) compresses the gradient so all text lands past the 100% mark in the terminal-white stop, per-element gradient scope hides only the consent fill, and mousedown injects the transform during the click window
The SVG gradientTransform attribute applies a coordinate-space transform to a gradient’s own coordinate system independently of the element being filled and independently of the SVG viewport coordinate system. For an objectBoundingBox gradient, the gradient interpolates across the normalized [0, 1] range of the element’s bounding box. Applying gradientTransform="translate(1.5, 0)" shifts the gradient start point to x=1.5 of the bounding box — 1.5× beyond the right edge of the element. The element’s bounding box (x=0 to x=1 in normalized space) therefore lies entirely before the gradient starts; the SVG specification defines the color before the first gradient stop as a constant equal to the first stop’s color. If the first stop is white, every pixel of the consent text is rendered white on a white background. All DOM-level checks pass: textContent is intact, BCR is non-zero, display and visibility are normal. The critical blind spot is that getComputedStyle(text).fill returns url(#gradient-id) — a reference, not a resolved color — so fill-color auditors never see “white”. Detection requires resolving the gradient reference, parsing the gradientTransform attribute, and evaluating the transformed coordinate range against the element’s bounding box. Four attack patterns: translate whiteout, scale compression, per-element gradient scope, and mousedown injection.
Attack 1: gradientTransform="translate(1.5,0)" — gradient start shifted beyond bounding box right edge; text fills with constant first-stop white (SA-CSS-GTF-001)
An SVG <linearGradient> with gradientUnits="objectBoundingBox" maps gradient coordinates to the element’s bounding box. The default x1="0" y1="0" x2="1" y2="0" gradient interpolates from the left edge (x=0) to the right edge (x=1) of the bounding box. The gradientTransform attribute applies an additional transform to this coordinate system. With gradientTransform="translate(1.5, 0)", the gradient start point (x1=0) maps to bounding-box x=1.5 and the end point (x2=1) maps to x=2.5 — both are beyond the right edge of the element (which runs from x=0 to x=1). The entire element lies at bounding-box coordinates x=0..1, which is before the translated gradient start at x=1.5. The SVG specification defines the fill color for points before the first stop as the first stop’s color (stop-color with stop-opacity). If stop 1 is white, every pixel of the consent text glyphs is painted white.
The fill-color check fails silently: getComputedStyle(textEl).fill returns url(#gtf-whiteout) — the gradient reference URL, not a color. A scanner checking fill !== “#fff” or fill !== “white” passes because the fill is a URL. Detection requires: (1) identify the gradient id from the fill URL, (2) locate the <linearGradient> or <radialGradient> element in <defs>, (3) parse its gradientTransform and x1/y1/x2/y2 attributes, (4) apply the gradientTransform to compute the effective gradient range in normalized bounding-box coordinates, (5) check if the element’s bounding-box x=0..1 falls entirely outside the transformed gradient range — and if so, evaluate the constant-extension color (first or last stop) for whiteness.
<!-- SA-CSS-GTF-001: gradientTransform="translate(1.5,0)" whiteout -->
<!-- objectBoundingBox gradient; x1=0→x=1.5 (bbx), x2=1→x=2.5 (bbx) after translate -->
<!-- text fills bbx coords x=0..1; all before gradient start (x=1.5); constant = stop-0 = white -->
<!-- getComputedStyle.fill = "url(#gtf-whiteout)" — not "white"; fill-color check PASSES -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- gradient looks innocent: white→black, x1=0 to x2=1 (full bounding box) -->
<!-- gradientTransform shifts start to x=1.5 (bbx); entire text at x=0..1 is pre-gradient -->
<!-- pre-gradient color = first stop = stop-color white -->
<linearGradient id="gtf-whiteout" gradientUnits="objectBoundingBox"
x1="0" y1="0" x2="1" y2="0"
gradientTransform="translate(1.5, 0)">
<stop offset="0%" stop-color="white" stop-opacity="1"/>
<stop offset="100%" stop-color="#111" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- fill references gradient; getComputedStyle.fill = "url(#gtf-whiteout)" not "white" -->
<!-- all glyph pixels painted white (pre-gradient constant); consent text invisible -->
<text x="20" y="50" font-size="13" fill="url(#gtf-whiteout)">
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: resolve gradient fill, evaluate gradientTransform
function checkGradientTransformWhiteout(svgEl) {
const findings = [];
for (const textEl of svgEl.querySelectorAll('text')) {
const fill = getComputedStyle(textEl).fill || textEl.getAttribute('fill') || '';
const gradientIdMatch = fill.match(/url\(["']?#([\w-]+)["']?\)/);
if (!gradientIdMatch) continue;
const gradientId = gradientIdMatch[1];
const gradientEl = svgEl.ownerDocument.getElementById(gradientId);
if (!gradientEl) continue;
if (gradientEl.tagName !== 'linearGradient' &&
gradientEl.tagName !== 'radialGradient') continue;
const gradientTransformAttr = gradientEl.getAttribute('gradientTransform') || '';
if (!gradientTransformAttr) continue;
// Parse translate(tx, ty) from gradientTransform
const translateMatch = gradientTransformAttr.match(/translate\(\s*([+-]?\d*\.?\d+)\s*,?\s*([+-]?\d*\.?\d+)?\s*\)/);
if (!translateMatch) continue;
const tx = parseFloat(translateMatch[1]);
// For objectBoundingBox gradient with x1=0 x2=1:
// After translate(tx, 0), gradient runs from bbx-x = tx to tx+1
// Text fills bbx x=0..1
// If tx > 0: text is entirely before gradient start (x < tx) → pure first-stop color
// If tx < -1: text is entirely after gradient end (x > tx+1) → pure last-stop color
const gradientUnits = gradientEl.getAttribute('gradientUnits') || 'objectBoundingBox';
if (gradientUnits !== 'objectBoundingBox') continue; // simplified check for bbx gradients
const textIsBeforeGradient = tx > 0.9; // start shifted >90% right; text entirely pre-gradient
const textIsAfterGradient = tx < -1.9; // end shifted >90% left; text entirely post-gradient
if (textIsBeforeGradient || textIsAfterGradient) {
// Determine the constant extension color
const stops = Array.from(gradientEl.querySelectorAll('stop'));
const constantStop = textIsBeforeGradient ? stops[0] : stops[stops.length - 1];
const constantColor = constantStop?.getAttribute('stop-color') || '';
const isWhiteOrNearWhite = /^(white|#fff|#ffffff|rgb\(255,\s*255,\s*255\))$/i.test(constantColor);
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-GTF-001',
note: `text fill=url(#${gradientId}); gradientTransform="${gradientTransformAttr}"; ` +
`tx=${tx}; gradientUnits=objectBoundingBox; ` +
(textIsBeforeGradient ? `text BBX (0..1) is entirely before gradient start (${tx}); ` : '') +
(textIsAfterGradient ? `text BBX (0..1) is entirely after gradient end (${tx+1}); ` : '') +
`constant-extension color = stop ${textIsBeforeGradient ? '0%' : '100%'} = "${constantColor}"; ` +
(isWhiteOrNearWhite ? 'WHITEOUT: consent glyphs rendered white on white background; ' : '') +
`getComputedStyle.fill = "url(#${gradientId})" (not "white"; fill-color check PASSES); ` +
`textContent="${textEl.textContent.trim().slice(0,30)}..." intact; BCR non-zero`
});
}
}
return findings;
}
CRITICAL — SA-CSS-GTF-001: gradientTransform="translate(1.5, 0)" shifts the gradient start point to bounding-box x=1.5 — beyond the right edge of the element. The entire element (bbx x=0..1) is before the gradient start; the SVG constant-extension rule gives every pixel of the text the first stop’s color (white). getComputedStyle.fill returns a URL, not “white”. Detection must resolve the gradient URL, parse gradientTransform, and evaluate whether the element’s normalized bounding box falls outside the transformed gradient range.
Attack 2: gradientTransform="scale(0.001)" — gradient compressed to near-zero range; entire text falls in constant last-stop white zone (SA-CSS-GTF-002)
A gradientTransform="scale(0.001)" on an objectBoundingBox gradient compresses the gradient coordinate system by a factor of 1000. The gradient definition x1="0" x2="1" normally spans the full bounding box (0..1). After scale(0.001), the gradient runs from bbx-x=0 to bbx-x=0.001 — only the first 0.1% of the bounding box. Any point in the element with bbx-x > 0.001 (which is essentially the entire element beyond the leftmost pixel) is past the gradient’s 100% mark. The SVG constant-extension rule for the end of a gradient assigns the last stop’s color to all points past 100%. If the last stop is white, all consent text pixels beyond x=0.001 of the bounding box are painted white.
<!-- SA-CSS-GTF-002: gradientTransform="scale(0.001)" — gradient compressed to first 0.1% -->
<!-- gradient: x1=0 to x2=0.001 (bbx) after scale; text at x=0.001..1 is post-gradient -->
<!-- post-gradient constant = last stop = white; entire visible text area painted white -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- last stop is white; after scale(0.001) the gradient end is at bbx-x=0.001 -->
<!-- all text at bbx-x>0.001 (entire visible portion) takes constant last-stop = white -->
<linearGradient id="gtf-scale" gradientUnits="objectBoundingBox"
x1="0" y1="0" x2="1" y2="0"
gradientTransform="scale(0.001)">
<stop offset="0%" stop-color="#111" stop-opacity="1"/>
<stop offset="100%" stop-color="white" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- gradient end at bbx-x=0.001; text at x=0.001..1 is post-gradient; constant last-stop white -->
<text x="20" y="50" font-size="13" fill="url(#gtf-scale)">
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: parse scale() from gradientTransform; check if gradient range is near-zero
function checkGradientTransformScale(gradientEl) {
const gradTransform = gradientEl.getAttribute('gradientTransform') || '';
const scaleMatch = gradTransform.match(/scale\(\s*([+-]?\d*\.?\d+)\s*(?:,\s*([+-]?\d*\.?\d+))?\s*\)/);
if (!scaleMatch) return null;
const scaleX = parseFloat(scaleMatch[1]);
// After scale(s), gradient runs from bbx-x=0 to bbx-x=s (for x1=0, x2=1 gradient)
// If s << 1 (near-zero), entire element (bbx 0..1) is post-gradient → constant last stop
if (scaleX >= 0.01) return null; // reasonable scale; gradient covers >1% of element
const stops = Array.from(gradientEl.querySelectorAll('stop'));
const lastStop = stops[stops.length - 1];
const lastColor = lastStop?.getAttribute('stop-color') || '';
return {
severity: 'CRITICAL', code: 'SA-CSS-GTF-002',
note: `gradientTransform scale(${scaleX}); gradient range compressed to bbx-x=0..${scaleX}; ` +
`entire text (bbx-x=${scaleX}..1) is post-gradient → constant last-stop="${lastColor}"; ` +
`getComputedStyle.fill returns URL (not a color; fill-color check PASSES)`
};
}
CRITICAL — SA-CSS-GTF-002: gradientTransform="scale(0.001)" compresses the gradient range to bbx-x=0..0.001 (0.1% of the element). The entire visible portion of the text (bbx-x=0.001..1) is past the gradient end; the SVG constant-extension rule assigns the last stop’s color (white) to all of it. Detection must parse the scale factor from gradientTransform, compute the effective gradient range, and check the constant-extension color of the terminal stop for whiteness.
Attack 3: per-element gradient scope — header element uses solid dark fill; consent text uses gradient with manipulated gradientTransform (SA-CSS-GTF-003)
The per-element scope attack uses two separate <text> elements in the SVG. The header element has fill="#111" (solid dark; visible). The consent text element has fill="url(#gtf-whiteout)" where the referenced gradient has gradientTransform="translate(1.5, 0)". A scanner inspecting fill colors across all text elements finds a mix of solid-color and URL references. An auditor checking only whether any text element has a readable fill will find the header’s solid dark fill and potentially stop there. Per-element gradient resolution is required: for each text element that references a gradient, the scanner must resolve the gradient, evaluate gradientTransform, and determine the effective fill color for that specific element’s bounding box.
<!-- SA-CSS-GTF-003: per-element scope -->
<!-- header: fill="#111" (solid dark; readable; no gradient) -->
<!-- consent: fill="url(#gtf-scope)" with gradientTransform="translate(1.5,0)" → whiteout -->
<svg width="440" height="140" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gtf-scope" gradientUnits="objectBoundingBox"
x1="0" y1="0" x2="1" y2="0"
gradientTransform="translate(1.5, 0)">
<stop offset="0%" stop-color="white" stop-opacity="1"/>
<stop offset="100%" stop-color="#111" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="440" height="140" fill="white"/>
<!-- header: solid fill; readable; any-dark-fill check PASSES -->
<text x="20" y="30" font-size="13" fill="#111">Permission Request:</text>
<!-- consent: gradient fill; translate(1.5,0) → whiteout; invisible -->
<text x="20" y="50" font-size="13" fill="url(#gtf-scope)">
Allow: shell execution, credential access
</text>
<rect x="20" y="90" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="110" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="90" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="110" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: per-element gradient fill resolution
// Each text element that references a gradient must be individually evaluated
// The header's solid fill check PASSES; per-element gradient resolution required
function checkPerElementGradientFill(svgEl) {
const findings = [];
const allText = svgEl.querySelectorAll('text');
for (const textEl of allText) {
const fillValue = textEl.getAttribute('fill') || getComputedStyle(textEl).fill || '';
const gradIdMatch = fillValue.match(/url\(["']?#([\w-]+)["']?\)/);
if (!gradIdMatch) continue; // solid fill; skip gradient check
const gradEl = svgEl.ownerDocument.getElementById(gradIdMatch[1]);
if (!gradEl) continue;
const gt = gradEl.getAttribute('gradientTransform') || '';
if (!gt) continue;
// Simplified: check for translate with tx > 0.9 (text before gradient start)
const tm = gt.match(/translate\(\s*([+-]?\d*\.?\d+)/);
if (tm && parseFloat(tm[1]) > 0.9) {
const stops = Array.from(gradEl.querySelectorAll('stop'));
findings.push({
severity: 'HIGH', code: 'SA-CSS-GTF-003',
note: `per-element gradient scope: consent text fill=url(#${gradIdMatch[1]}); ` +
`gradientTransform="${gt}"; tx=${parseFloat(tm[1])}; ` +
`text BBX before gradient start → constant first-stop="${stops[0]?.getAttribute('stop-color')}"; ` +
`header text uses solid fill "#111" (dark; readable); ` +
`any-dark-fill check finds header and PASSES; ` +
`per-element gradient resolution required; ` +
`textContent="${textEl.textContent.trim().slice(0,30)}..." intact`
});
}
}
return findings;
}
HIGH — SA-CSS-GTF-003: The consent text element references a gradient with gradientTransform="translate(1.5, 0)" (whiteout); the header element uses a solid dark fill. A scanner finding at least one dark-fill text element in the SVG passes the check. Detection requires per-element gradient resolution: for each text element with a gradient fill URL, resolve the gradient, evaluate gradientTransform, and determine the effective fill color for that element’s bounding box independently.
Attack 4: JS mousedown injects gradientTransform on the gradient element, reverts at mouseup (SA-CSS-GTF-004)
The dynamic form injects gradientTransform="translate(1.5, 0)" on the <linearGradient> element during a mouse press. At rest, the gradient has no gradientTransform (default identity transform) and the text fills with the gradient’s normally distributed colors. A mousedown listener (capture phase) calls gradientEl.setAttribute(‘gradientTransform’, ‘translate(1.5, 0)’). Because the gradient definition is shared — referenced by the text element via fill="url(#id)" — mutating the gradient element immediately affects all elements that reference it. The text fill transitions to pure white for all consent text elements referencing this gradient. At mouseup, the attribute is removed.
This attack is harder to detect than direct mutation of the text element because the mutation target is the gradient definition in <defs>, not the text element itself. A MutationObserver watching the text element’s attributes misses this attack. The observer must be set up on the gradient element (or the entire <defs> subtree) with attributeFilter: [‘gradientTransform’].
<!-- SA-CSS-GTF-004: at rest no gradientTransform on gradient (identity; normal fill) -->
<!-- mousedown: setAttribute('gradientTransform', 'translate(1.5,0)') on gradient el -->
<!-- all text elements referencing gradient immediately fill white (whiteout) -->
<!-- mouseup: removeAttribute('gradientTransform'); gradient restores normal fill -->
<svg id="consent-svg-gtf" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- at rest: no gradientTransform; gradient covers full BBX (0..1); normal dark-to-light fill -->
<linearGradient id="gtf-dynamic" gradientUnits="objectBoundingBox"
x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="white" stop-opacity="1"/>
<stop offset="100%" stop-color="#111" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- at rest: gradient at x=0..1 (full BBX); gradients from white to #111 -->
<!-- text fill interpolates: some dark glyphs visible -->
<!-- mousedown: gradientTransform="translate(1.5,0)"; text all-white; invisible -->
<text x="20" y="50" font-size="13" fill="url(#gtf-dynamic)">
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-gtf');
const gradient = svg.querySelector('#gtf-dynamic');
svg.addEventListener('mousedown', () => {
gradient.setAttribute('gradientTransform', 'translate(1.5, 0)'); // shift gradient to all-white
}, true);
svg.addEventListener('mouseup', () => {
gradient.removeAttribute('gradientTransform'); // restore identity transform
}, true);
})();
</script>
// Detection: MutationObserver on gradientTransform on gradient elements in defs
// Text element attribute observer MISSES this; mutation target is the gradient el in defs
function watchGradientTransformInject(svgEl) {
const findings = [];
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (mut.attributeName !== 'gradientTransform') continue;
const gradEl = mut.target;
if (gradEl.tagName !== 'linearGradient' &&
gradEl.tagName !== 'radialGradient') continue;
const gt = gradEl.getAttribute('gradientTransform') || '';
const tm = gt.match(/translate\(\s*([+-]?\d*\.?\d+)/);
if (!tm) continue;
const tx = parseFloat(tm[1]);
if (Math.abs(tx) < 0.9) continue;
const stops = Array.from(gradEl.querySelectorAll('stop'));
const affectedEls = svgEl.querySelectorAll(`[fill="url(#${gradEl.id})"]`);
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-GTF-004',
note: `Dynamic gradientTransform="${gt}" injected on gradient #${gradEl.id}; ` +
`tx=${tx}; text BBX (0..1) before gradient start → constant first-stop white; ` +
`${affectedEls.length} text element(s) immediately fill white (whiteout); ` +
`mutation target is gradient el in defs — text element observer MISSES this; ` +
`MutationObserver on gradient el with attributeFilter=['gradientTransform'] required; ` +
`at rest: no gradientTransform on gradient (identity; normal fill visible); ` +
`static scan PASSES`
});
}
});
// Watch gradient elements, not text elements
for (const grad of svgEl.querySelectorAll('linearGradient, radialGradient')) {
observer.observe(grad, { attributes: true, attributeFilter: ['gradientTransform'] });
}
svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
svgEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
return findings;
}
CRITICAL — SA-CSS-GTF-004: JavaScript injects gradientTransform on the <linearGradient> element in <defs>. All text elements referencing that gradient immediately fill with white because the mutation propagates through the fill URL reference. A MutationObserver watching the text element’s attributes misses this. Detection requires observing gradient elements in <defs> with attributeFilter: [‘gradientTransform’]; the observer fires on the gradient definition, not the text element.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-GTF-001: gradientTransform="translate(1.5,0)" — pre-gradient whiteout | Gradient start shifted to BBX x=1.5; text at BBX x=0..1 is pre-gradient; SVG constant-extension = first stop = white; glyphs painted white; getComputedStyle.fill returns URL not "white" | textContent; BCR; fill-color check (returns URL); requires: resolve gradient URL; parse gradientTransform; compute effective gradient range; check constant-extension color | Critical |
| SA-CSS-GTF-002: gradientTransform="scale(0.001)" — post-gradient whiteout | Gradient compressed to BBX x=0..0.001; text at x>0.001 is post-gradient; constant last-stop = white; entire visible text white | textContent; BCR; fill-color check; requires: parse scale factor; compute effective gradient range; check constant last-stop for whiteness | Critical |
| SA-CSS-GTF-003: per-element scope — header solid dark fill; consent gradient whiteout | Header fill="#111" (dark; readable); consent fill="url(#id)" with gradientTransform whiteout; any-dark-fill check passes on header | textContent; any-dark-fill check; BCR; requires: per-element gradient resolution; evaluate gradientTransform for each text element referencing a gradient independently | High |
| SA-CSS-GTF-004: mousedown injects gradientTransform on gradient el in defs; reverts mouseup | Mutation target is gradient el (not text el); all referencing text els immediately fill white; at rest no gradientTransform (normal fill visible); static checks PASS | Static DOM; text-element attribute observer; requires: MutationObserver on gradient elements in defs with attributeFilter=['gradientTransform'] + synthetic mousedown | Critical |