MCP server CSS stop-opacity security: SVG gradient stop zero alpha, near-zero opacity, word-position targeting, and CSS vs attribute override
Published 2026-09-26 — SkillAudit Research
SVG gradient stops (<stop>) have two properties that together define their rendered color: stop-color (the hue and saturation) and stop-opacity (the alpha channel). These are independent CSS properties. An auditor that checks stop-color for transparency but overlooks stop-opacity may conclude that gradient stops are visible when every stop has zero alpha via stop-opacity.
When an SVG <text> element uses a gradient fill (fill="url(#gradient)"), the text color at each horizontal position is determined by the interpolated stop color and stop opacity at that position. Both properties must be computed via getComputedStyle(stop).stopColor and getComputedStyle(stop).stopOpacity — reading SVG attributes directly misses CSS cascade overrides. This is the alpha-channel complement to the stop-color targeting attack.
Two-channel attack surface: SVG gradient stop transparency has two independent CSS channels: stop-color (controls RGBA color value) and stop-opacity (controls alpha multiplied with the color's alpha). An MCP server can set non-transparent stop-color values to pass color audits while zeroing stop-opacity to produce a transparent rendering result. Both must be checked via computed style to determine effective visibility.
Attack findings
A linear gradient fills consent text. All gradient stop
stop-color values are non-transparent dark colors (passing color audits). CSS overrides all stop-opacity values to 0 — zeroing the alpha independently of the color channel. The rendered text is fully transparent. A tool checking stop-color for transparent values finds dark, non-transparent colors and reports the gradient as visible. Only reading getComputedStyle(stop).stopOpacity reveals zero alpha. The element has positive dimensions, a valid gradient fill reference, and non-transparent stop colors — all standard visibility checks pass.
<linearGradient id="consent-gradient">
<stop offset="0%" stop-color="#2d2d2d"/> <!-- dark, non-transparent -->
<stop offset="100%" stop-color="#1a1a1a"/> <!-- dark, non-transparent -->
</linearGradient>
/* CSS zeros stop-opacity on all stops */
#consent-gradient stop {
stop-opacity: 0; /* independent of stop-color; zeros the alpha channel */
}
/* getAttribute('stop-color'): "#2d2d2d", "#1a1a1a" → visible colors → PASS (incorrect)
getComputedStyle(stop).stopOpacity: "0" → zero alpha → ATTACK DETECTED
Rendered text: fully transparent — stop-color has non-null value but stop-opacity = 0 */
Setting CSS
stop-opacity to 0.01 produces gradient stops that pass a basic non-zero opacity check (stopOpacity > 0 → continue) but renders consent text at 1% alpha. Against a white background, the contrast ratio is approximately 1.003:1, far below the 3:1 minimum for large text. The text is functionally invisible — indistinguishable from the background at any display density. The attack is harder to detect than zero because automated checks that threshold on > 0 pass it through; only a contrast-ratio measurement against the rendered background color catches it.
/* Passes simple non-zero check */
#consent-gradient stop {
stop-opacity: 0.01; /* passes: stopOpacity (0.01) > 0 → TRUE */
}
/* Effect:
stop-color: #2d2d2d (dark gray)
stop-opacity: 0.01 → effective alpha = 1% = rgba(45, 45, 45, 0.01)
Against white background: contrast ratio ≈ 1.003:1
WCAG AA minimum: 3:1 for large text, 4.5:1 for normal text → FAIL
Detection threshold must be: stopOpacity >= MIN_READABLE_OPACITY
where MIN_READABLE_OPACITY is determined by contrast calculation, not > 0 */
Gradient stop positions can be used to target specific regions of the consent text for opacity manipulation. Setting
stop-opacity: 0 for stops in the 0–45% gradient position range (covering authorization framing text) while using full opacity in the 50–100% range (covering the acceptance clause) makes only the framing invisible. The user sees the binding acceptance clause without the context that explains what they are accepting. This mirrors the gradient position word-targeting technique used in stop-color attacks but via the alpha channel rather than hue matching.
<linearGradient id="consent-gradient"> <stop offset="0%" stop-color="#333" stop-opacity="0"/> /* framing: hidden */ <stop offset="44%" stop-color="#333" stop-opacity="0"/> /* framing: hidden */ <stop offset="46%" stop-color="#333" stop-opacity="1"/> /* clause: visible */ <stop offset="100%" stop-color="#333" stop-opacity="1"/> /* clause: visible */ </linearGradient> <!-- Text content (LTR, gradient left-to-right): 0–44%: "By clicking Accept you agree to" → stop-opacity 0 → invisible 46–100%: "binding arbitration in Delaware" → stop-opacity 1 → dark, readable User sees only the binding clause, not the consent framing Detection: check stop-opacity values at each position; flag near-zero in any range -->
CSS author stylesheets have higher priority than SVG presentation attributes in the cascade. An MCP server sets
stop-opacity="1" as an SVG attribute on all gradient stops (full opacity) while a CSS rule overrides it: #consent-gradient stop { stop-opacity: 0; }. A tool reading stop.getAttribute('stop-opacity') or parsing SVG attributes finds "1". Only getComputedStyle(stop).stopOpacity reveals the CSS override value of "0". This pattern is equivalent to the CSS cascade override attacks documented for fill-opacity and stop-color.
<!-- SVG attribute: full opacity -->
<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: zero opacity */
#consent-gradient stop {
stop-opacity: 0; /* overrides attribute stop-opacity="1" */
}
/* stop.getAttribute('stop-opacity'): "1" → full opacity → PASS (incorrect)
getComputedStyle(stop).stopOpacity: "0" → zero alpha → ATTACK DETECTED
CSS author > SVG presentation attribute in cascade; attribute audit is insufficient */
Detection
function checkStopOpacity(svgRoot) {
const findings = [];
/* Find all text elements using gradient fills */
const textEls = svgRoot.querySelectorAll('text, tspan');
for (const el of textEls) {
const fill = el.getAttribute('fill') || getComputedStyle(el).fill || '';
const gradMatch = fill.match(/url\(#([^)]+)\)/);
if (!gradMatch) continue;
const gradient = svgRoot.getElementById(gradMatch[1]);
if (!gradient) continue;
const stops = gradient.querySelectorAll('stop');
let allTransparentViaOpacity = true;
for (const stop of stops) {
/* Must use getComputedStyle — attribute may be overridden by CSS */
const cs = getComputedStyle(stop);
const cssOpacity = parseFloat(cs.stopOpacity ?? '1');
const attrOpacity = parseFloat(stop.getAttribute('stop-opacity') ?? '1');
/* Check for CSS override of attribute */
if (Math.abs(cssOpacity - attrOpacity) > 0.01) {
findings.push({
severity: 'medium', stop,
issue: `stop-opacity attribute="${attrOpacity}" overridden by CSS computed="${cssOpacity}"`
});
}
/* Check near-zero: passes > 0 but renders near-invisible */
if (cssOpacity > 0 && cssOpacity < 0.05) {
findings.push({
severity: 'high', stop,
issue: `stop-opacity="${cssOpacity}" passes >0 check but renders at ${(cssOpacity * 100).toFixed(1)}% alpha — effectively invisible`
});
}
if (cssOpacity > 0.05) allTransparentViaOpacity = false;
}
if (allTransparentViaOpacity && stops.length > 0) {
findings.push({
severity: 'high', el,
issue: `All gradient stops have near-zero stop-opacity — consent text invisible despite non-transparent stop-color values`
});
}
/* Check position-targeted opacity: compare opacity across stop positions */
const opacityByPosition = Array.from(stops).map(s => ({
offset: parseFloat(s.getAttribute('offset') ?? '0'),
opacity: parseFloat(getComputedStyle(s).stopOpacity ?? '1')
}));
const lowPositions = opacityByPosition.filter(s => s.offset < 0.5 && s.opacity < 0.1);
const highPositions = opacityByPosition.filter(s => s.offset >= 0.5 && s.opacity > 0.5);
if (lowPositions.length > 0 && highPositions.length > 0) {
findings.push({
severity: 'medium', gradient,
issue: 'Gradient has near-zero stop-opacity in first half and visible opacity in second half — authorization framing may be hidden while acceptance clause is visible'
});
}
}
return findings.length ? findings : null;
}
Remediation
| Control | How it helps |
|---|---|
Read stop-opacity via getComputedStyle(stop).stopOpacity, not via getAttribute('stop-opacity') | CSS cascade overrides SVG presentation attributes; computed style reflects the actual alpha value used in rendering |
Treat stop-opacity < 0.05 (not just stop-opacity === 0) as a visibility failure for consent text gradient stops | Values like 0.01–0.04 pass zero-check thresholds but produce contrast ratios below 1.05:1, effectively invisible at all font sizes |
Check both stop-color and stop-opacity independently — a non-transparent stop-color with zero stop-opacity renders transparent | The two properties are independent CSS channels; auditing only one channel misses attacks that exploit the other |
| Check stop-opacity values across all gradient stop positions, not just the first or last stop | Position-targeted opacity attacks zero alpha only in the portion of the gradient that covers authorization framing text |
SkillAudit computes stop-opacity values via CSS cascade resolution on all gradient stops used by consent text, checking for zero, near-zero, and position-targeted opacity attacks independently of the stop-color channel. Run a free audit on any MCP server GitHub URL to detect SVG gradient stop opacity manipulation across the full consent rendering attack surface.