MCP server CSS stop-color security: SVG gradient stop transparent consent text, stop-opacity zero, CSS vs attribute override, and precise word-position targeting
Published 2026-09-26 — SkillAudit Research
SVG gradients (<linearGradient> and <radialGradient>) are defined by a series of color stops. Each stop specifies a color via stop-color and an optional alpha via stop-opacity. When an SVG <text> element uses a gradient as its fill (fill="url(#gradient)"), the rendered text takes the color of the gradient at each horizontal (or radial) position along the text element's bounding box.
CSS allows both stop-color and stop-opacity to be set as CSS properties on <stop> elements, overriding their SVG presentation attributes via the cascade. This creates an audit surface similar to the CSS fill-opacity override pattern: an SVG gradient's stop attributes may define correct, visible colors, while CSS rules override them to transparent or background-matching values. An audit tool reading SVG attributes finds the original colors; only computing CSS property values reveals the override.
gradient fill → stop-color attack surface: If an SVG consent text element uses a gradient fill, the text color is determined by the gradient stops — not a single fill color. Auditing the fill property of the text element finds url(#gradient-id) (a reference, not a color). To determine the actual rendered color, the auditor must resolve the gradient reference, read all its stops, and compute their colors at each text position. Each stop is a separate attack surface via stop-color and stop-opacity.
Attack findings
An SVG linear gradient fills consent text. All gradient stop colors are overridden via CSS to
transparent (alpha=0). The text renders with a gradient that is transparent at every position — effectively invisible. The fill property of the text element is url(#consent-gradient) — a non-null reference. An auditor checking fill for 'none' or 'transparent' finds a URL reference and may proceed to check whether the gradient exists (it does) without verifying the colors of its stops. The element is not display:none, has positive dimensions, and has a non-empty gradient reference — all standard checks pass.
<svg>
<defs>
<linearGradient id="consent-gradient">
<stop offset="0%" stop-color="#333"/> <!-- attribute: dark color -->
<stop offset="100%" stop-color="#111"/> <!-- attribute: dark color -->
</linearGradient>
</defs>
<text fill="url(#consent-gradient)">
By clicking Accept, you agree to the above terms.
</text>
</svg>
/* CSS overrides all stops to transparent */
#consent-gradient stop {
stop-color: transparent; /* overrides all stop-color attributes */
}
/* el.getAttribute('stop-color'): "#333", "#111" → visible colors → PASS (incorrect)
getComputedStyle(stop).stopColor: "rgba(0,0,0,0)" → transparent → ATTACK DETECTED
text fill: url(#consent-gradient) → reference exists → PASS (incorrect)
Rendered: invisible — gradient is fully transparent */
Instead of using transparent stops (which might be caught by an alpha-check), an MCP server sets gradient stop colors to match the dialog background color at positions corresponding to authorization framing text. For a white background,
stop-color: #ffffff produces text that is fully visible in the DOM (non-transparent fill color) but visually identical to the background — white text on white background. The attack is more subtle than transparent stops: the color is a valid, non-transparent value, and a contrast-ratio check is needed to identify it, rather than an alpha check.
<linearGradient id="consent-gradient"> <stop offset="0%" stop-color="#ffffff"/> /* matches white background */ <stop offset="45%" stop-color="#ffffff"/> /* still matching background */ <stop offset="50%" stop-color="#222222"/> /* visible: acceptance clause */ <stop offset="100%" stop-color="#222222"/> /* visible: binding terms */ </linearGradient> <!-- Text layout (LTR, left-to-right gradient): 0–45% = "By clicking Accept, you agree to" → white on white → invisible 50-100% = "binding arbitration in Delaware." → dark on white → visible User sees: "binding arbitration in Delaware." without the consent framing Audit: stopColor "#ffffff" is non-transparent → passes alpha check Contrast ratio check: #fff on #fff = 1:1 → fails 4.5:1 → ATTACK DETECTED -->
CSS provides both
stop-color and stop-opacity as independent properties. An MCP server sets gradient stops with non-transparent stop-color values (passing color audits) while overriding stop-opacity to 0 via CSS. The resulting gradient is fully transparent — the color exists but has zero alpha. Auditors that check stop-color values find the non-transparent colors. Only checking stop-opacity via computed style reveals the zero alpha. This is the gradient equivalent of the fill-opacity:0 with non-transparent fill color attack.
<linearGradient id="consent-gradient">
<stop offset="0%" stop-color="#333"/> <!-- non-transparent color -->
<stop offset="100%" stop-color="#111"/> <!-- non-transparent color -->
</linearGradient>
/* CSS sets stop-opacity to 0 independently */
#consent-gradient stop {
stop-opacity: 0; /* zero alpha regardless of stop-color value */
}
/* stop-color audit: "#333", "#111" → non-transparent → PASS (incorrect)
getComputedStyle(stop).stopOpacity: "0" → zero alpha → ATTACK DETECTED
stop-color and stop-opacity are separate properties — both must be checked */
Like other SVG presentation attributes,
stop-color can be set both as a CSS property and as an SVG presentation attribute. The CSS cascade gives author stylesheets higher priority. An MCP server sets stop-color="#222" as an SVG attribute on each <stop> element (visible dark color) while a CSS rule overrides it: #consent-gradient stop { stop-color: transparent; }. A tool that reads stop.getAttribute('stop-color') or parses the SVG element's attribute list finds the dark color. Only getComputedStyle(stop).stopColor reveals the transparent override. This is the same CSS cascade override pattern seen in fill-opacity attribute attacks.
<!-- SVG attribute: visible color -->
<linearGradient id="consent-gradient">
<stop offset="0%" stop-color="#2d2d2d" stop-opacity="1"/>
<stop offset="100%" stop-color="#1a1a1a" stop-opacity="1"/>
</linearGradient>
/* CSS cascade override: transparent */
#consent-gradient stop {
stop-color: rgba(0, 0, 0, 0); /* overrides attribute "#2d2d2d" */
}
/* stop.getAttribute('stop-color'): "#2d2d2d" → PASS (incorrect)
getComputedStyle(stop).stopColor: "rgba(0, 0, 0, 0)" → ATTACK DETECTED
CSS author stylesheet > SVG presentation attribute in cascade */
Detection
function checkGradientStops(svgRoot) {
const findings = [];
/* Find all text elements using gradient fills */
const textEls = svgRoot.querySelectorAll('text, tspan');
for (const el of textEls) {
const fillAttr = el.getAttribute('fill') || getComputedStyle(el).fill || '';
const gradMatch = fillAttr.match(/url\(#([^)]+)\)/);
if (!gradMatch) continue;
const gradId = gradMatch[1];
const gradient = svgRoot.getElementById(gradId);
if (!gradient) continue;
const stops = gradient.querySelectorAll('stop');
let allTransparent = true;
let anyBackgroundMatch = false;
for (const stop of stops) {
/* Read CSS computed values — not SVG attributes */
const cs = getComputedStyle(stop);
const color = cs.stopColor || stop.getAttribute('stop-color') || 'black';
const opacity = parseFloat(cs.stopOpacity || stop.getAttribute('stop-opacity') || '1');
/* Check CSS vs attribute mismatch */
const attrColor = stop.getAttribute('stop-color') || '';
if (attrColor && cs.stopColor && attrColor !== cs.stopColor) {
findings.push({
severity: 'medium', stop,
issue: `stop-color attribute "${attrColor}" overridden by CSS computed "${cs.stopColor}" — attribute audit sees wrong color`
});
}
/* Check stop-opacity separately */
const attrOpacity = parseFloat(stop.getAttribute('stop-opacity') || '1');
const cssOpacity = parseFloat(cs.stopOpacity || '1');
if (Math.abs(attrOpacity - cssOpacity) > 0.01) {
findings.push({
severity: 'medium', stop,
issue: `stop-opacity attribute="${attrOpacity}" overridden by CSS="${cssOpacity}"`
});
}
/* Check if this stop is transparent */
const isTransparent = opacity === 0 || color === 'transparent' ||
color === 'rgba(0, 0, 0, 0)';
if (!isTransparent) allTransparent = false;
}
if (allTransparent && stops.length > 0) {
findings.push({
severity: 'critical', el,
issue: `SVG text uses gradient fill url(#${gradId}) but all gradient stops are transparent — text invisible`
});
}
}
return findings.length ? findings : null;
}
Remediation
| Control | How it helps |
|---|---|
Resolve fill="url(#...)" references and audit gradient stops when consent text uses gradient fills | A gradient fill URL reference passes simple fill-is-transparent checks; only resolving the reference and checking stop colors reveals invisible gradient attacks |
Read gradient stop colors via getComputedStyle(stop).stopColor, not via getAttribute('stop-color') | CSS cascade overrides SVG presentation attributes; computed style reflects the true rendering value after all CSS rules are applied |
Check stop-opacity independently from stop-color | A non-transparent stop-color with zero stop-opacity renders as transparent; both properties must be checked to determine effective alpha |
| Apply contrast-ratio checks to gradient stop colors against the page background | Background-matching stop colors are non-transparent but produce contrast ratio 1:1 — only contrast measurement catches camouflage attacks that pass alpha checks |
SkillAudit resolves gradient fill references on SVG consent text and audits all gradient stops for transparent, background-matching, and CSS-overridden stop colors. Run a free audit on any MCP server GitHub URL to detect SVG gradient stop manipulation and the full consent rendering attack surface.