Security reference · CSS injection · Color transparency · Consent hiding
MCP server CSS color and opacity security
CSS color: transparent and opacity: 0 are two distinct mechanisms for hiding MCP consent disclosures while bypassing standard visibility audits. color: transparent targets text only — the element remains display: block, visibility: visible, and returns non-zero dimensions from getBoundingClientRect(). opacity: 0 makes the entire element invisible but keeps it in the layout and still blocking pointer events. Both properties pass every standard display/visibility/dimension check. Four attack patterns: transparent text, zero-opacity element overlay, near-invisible rgba() color, and JS-deferred opacity collapse triggered at mousedown before the install click fires.
color / opacity attack surface
| Attack pattern | Property / value | What passes standard checks | Detection method |
|---|---|---|---|
| Transparent text | color: transparent | display:block, visibility:visible, getBoundingClientRect non-zero | Check getComputedStyle.color for rgba(0,0,0,0) or "transparent" |
| Zero-opacity element | opacity: 0 | display:block, visibility:visible, getBoundingClientRect non-zero | Check getComputedStyle.opacity for 0 or near-zero |
| Near-invisible rgba color | color: rgba(255,255,255,0.01) | All standard checks; color is technically non-zero alpha | Check computed color alpha channel < 0.1 threshold |
| JS deferred opacity collapse | el.style.opacity = '0' on mousedown | All load-time checks; hiding activates at interaction time | MutationObserver on style attribute + mousedown simulation |
color vs. opacity audit gap: Standard visibility audits check display, visibility, and getBoundingClientRect().height. Neither color: transparent nor opacity: 0 affects any of these three properties. An element with color: transparent passes all three checks and is reported as visible — while its text is completely invisible to the user.
Attack 1: color:transparent — invisible text, visible element
CSS color: transparent sets the foreground text color to transparent — equivalent to rgba(0,0,0,0). The element remains in the normal document flow: it is display: block, its height and width are non-zero (determined by the font metrics and content box of the invisible text), and visibility is visible. An auditor checking for hidden consent using getComputedStyle(el).display !== 'none', getComputedStyle(el).visibility !== 'hidden', and el.getBoundingClientRect().height > 0 will find all three conditions satisfied and report consent as visible:
/* Malicious CSS — SA-CSS-COLOP-001 */
.mcp-consent-disclosure {
color: transparent;
/* Computed: color = rgba(0, 0, 0, 0) */
/* display: block — element is in layout */
/* visibility: visible — element is not hidden */
/* getBoundingClientRect: width and height are non-zero (text content occupies space) */
/* The text is invisible because its color is transparent — but the ELEMENT is present */
}
/* Standard audit checks: */
/* getComputedStyle(el).display → "block" ← passes */
/* getComputedStyle(el).visibility → "visible" ← passes */
/* el.getBoundingClientRect().height → e.g. 48 ← passes */
/* What the audit misses: */
/* getComputedStyle(el).color → "rgba(0, 0, 0, 0)" ← text is invisible */
/* Detection: */
function detectTransparentText() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
const s = getComputedStyle(el);
/* Skip elements hidden by display/visibility (caught by other checks) */
if (s.display === 'none' || s.visibility === 'hidden') continue;
/* Check color transparency */
const color = s.color; /* e.g. "rgba(0, 0, 0, 0)" or "transparent" */
const alpha = parseColorAlpha(color);
if (alpha < 0.05) {
findings.push({ id: 'SA-CSS-COLOP-001', severity: 'critical',
message: `Consent-content element has transparent text: color = "${color}". Element is display:block and visible in layout but text is invisible to the user.` });
}
}
return findings;
}
function parseColorAlpha(colorStr) {
/* "rgba(r, g, b, a)" → a */
const match = colorStr.match(/rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)/);
if (match) return parseFloat(match[1]);
if (colorStr === 'transparent') return 0;
/* "rgb(r, g, b)" → alpha is 1 */
return 1;
}
Attack 2: opacity:0 — invisible element that still blocks pointer events
CSS opacity: 0 makes the element and all its descendants fully transparent while keeping the element in the layout and — importantly — still blocking pointer events (clicks pass through only if pointer-events: none is also set). An opacity: 0 consent disclosure is invisible to the user but occupies its layout position. Standard visibility audits that do not check opacity pass this as "visible":
/* Malicious CSS — SA-CSS-COLOP-002 */
.mcp-consent-disclosure {
opacity: 0;
/* The element is in the layout, takes up space, blocks pointer events */
/* display: block remains — standard display check passes */
/* visibility: visible remains — standard visibility check passes */
/* getBoundingClientRect: returns the element's full dimensions */
}
/* Why opacity:0 is worse than display:none for evasion:
display:none removes the element from layout — getBoundingClientRect returns {0,0,0,0}
visibility:hidden makes the element invisible but keeps layout space
opacity:0 makes the element transparent but keeps layout space AND pointer events */
/* Combined attack: opacity:0 consent element with pointer-events:none overlaid on install button */
/* The invisible consent element appears to be positioned over the install button.
It blocks clicks to the "install" button via pointer events — but since pointer-events:none
is also set, clicks pass through. Auditors see a "visible" element (opacity:0 passes display check)
that appears to be above the install button — but users cannot read it. */
/* Detection: */
function detectZeroOpacity() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
const s = getComputedStyle(el);
if (s.display === 'none' || s.visibility === 'hidden') continue;
const opacity = parseFloat(s.opacity);
if (opacity < 0.1) {
findings.push({ id: 'SA-CSS-COLOP-002', severity: 'critical',
message: `Consent-content element has opacity ${opacity} — effectively invisible. Element passes display/visibility checks but is not readable. pointer-events: ${s.pointerEvents}.` });
}
}
return findings;
}
Attack 3: near-invisible rgba color — technically non-zero alpha
An MCP server may use color: rgba(255, 255, 255, 0.01) — a nearly-white color with 1% alpha — which passes any audit checking for the literal value transparent or rgba(0,0,0,0). On a white background, text rendered in rgba(255, 255, 255, 0.01) is approximately 99% invisible. On a dark background, rgba(0, 0, 0, 0.01) achieves the same near-invisibility. The color is technically non-zero alpha, making pattern-matching against "transparent" insufficient:
/* Malicious CSS — SA-CSS-COLOP-003 */
.mcp-consent-text {
color: rgba(255, 255, 255, 0.01); /* Near-white on white background — 99% invisible */
/* Alternatively for dark backgrounds: */
/* color: rgba(0, 0, 0, 0.01); */
/* Or a color that matches the background: rgba(var(--bg-rgb), 0.02) */
}
/* Why near-zero alpha evades simple checks:
- color !== "transparent" → does not match transparent literal
- color !== "rgba(0, 0, 0, 0)" → does not match pure-transparent rgba
- getComputedStyle.color returns "rgba(255, 255, 255, 0.01)" — a seemingly valid color
- Auditors checking for "transparent" string miss this
- Only a threshold check on the alpha channel catches it */
/* Defense-in-depth evasion:
The attacker sets the color to match the background color at low opacity.
On a #0a0a0a dark background: rgba(10, 10, 10, 0.02) — near-invisible dark text on dark bg.
On a white background: rgba(255, 255, 255, 0.02) — near-invisible white text on white bg.
The alpha channel value 0.02 is above the "0" threshold but the text is unreadable. */
/* Detection: */
function detectNearInvisibleColor() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
const s = getComputedStyle(el);
if (s.display === 'none' || s.visibility === 'hidden') continue;
/* Check color alpha — any alpha < 0.1 is suspicious for a consent text element */
const alpha = parseColorAlpha(s.color);
if (alpha < 0.1) {
findings.push({ id: 'SA-CSS-COLOP-003', severity: 'critical',
message: `Consent-content element text color has alpha channel ${alpha.toFixed(3)} — below 0.1 threshold. Color: "${s.color}". Near-invisible text even if not exactly transparent.` });
}
/* Also check opacity compound effect: color alpha * element opacity */
const effectiveAlpha = alpha * parseFloat(s.opacity);
if (effectiveAlpha < 0.1 && alpha >= 0.1) {
findings.push({ id: 'SA-CSS-COLOP-003-B', severity: 'high',
message: `Consent-content element effective text alpha is ${effectiveAlpha.toFixed(3)} (color alpha ${alpha.toFixed(3)} × opacity ${s.opacity}) — near-invisible through compound alpha reduction.` });
}
}
return findings;
}
function parseColorAlpha(colorStr) {
const match = colorStr.match(/rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)/);
if (match) return parseFloat(match[1]);
if (colorStr === 'transparent') return 0;
return 1; /* rgb() without alpha = fully opaque */
}
Attack 4: JS-deferred opacity collapse at mousedown — timing variant
The three static attacks above hide consent at page load. The timing variant defers the opacity collapse to the mousedown event on the install button. Consent is fully visible at page load and at any static audit time. The moment the user presses the install button, MCP JavaScript sets consent.style.opacity = '0' — before the click event fires and before the install API call executes:
/* Malicious JS — SA-CSS-COLOP-004 */
const installBtn = document.querySelector('.mcp-install-button');
const consentEl = document.querySelector('.mcp-consent-disclosure');
installBtn.addEventListener('mousedown', () => {
/* Fires before the click event */
consentEl.style.opacity = '0';
consentEl.style.transition = 'opacity 0.1s'; /* Optional: brief fade to avoid "flash" */
/* By the time the click event fires (~50–100ms after mousedown),
consent will be fully transparent (or fading toward 0) */
});
/* At audit time (page load): opacity = "" (unset, inherits 1) ✓ passes */
/* At interaction time (button pressed): opacity = "0" ✗ hidden */
/* Evasion: the style attribute change is detectable via MutationObserver,
but load-time computed-style checks miss it entirely. */
/* Detection: */
function detectDeferredOpacityCollapse() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
const installBtn = document.querySelector('[class*="install"], [id*="install"], button[data-action*="install"]');
if (!installBtn) return findings;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
/* Observe mutations to the consent element's style attribute */
const observer = new MutationObserver(() => {
const opacity = parseFloat(getComputedStyle(el).opacity);
if (opacity < 0.1) {
findings.push({ id: 'SA-CSS-COLOP-004', severity: 'critical',
message: `Consent-content element opacity dropped to ${opacity} after style attribute mutation. JS-deferred opacity collapse at mousedown detected.` });
}
});
observer.observe(el, { attributes: true, attributeFilter: ['style'] });
}
/* Simulate mousedown to trigger the handler */
installBtn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
return findings;
}
Compound opacity attack: An MCP server may combine multiple alpha-reducing properties: color: rgba(255,255,255,0.5) (50% text alpha) × opacity: 0.1 (10% element opacity) = 5% effective text alpha. Each property alone might pass a threshold check; only the compound alpha check catches the combination. SkillAudit computes effective text alpha as color-alpha × element-opacity × ancestor-opacity to catch compound attacks.
SkillAudit findings for CSS color and opacity consent attacks
rgba(0,0,0,0) or equivalent). Element passes display, visibility, and dimension checks but text is invisible. Auditors checking only display/visibility/height miss this.opacity: 0. Element is in the layout and may block pointer events but is fully transparent to the user. Standard display/visibility/dimension checks pass.transparent. Alpha-threshold check required; string comparison against "transparent" misses this.Related MCP consent attack research
- CSS opacity:0 — standalone opacity collapse and its layout implications
- CSS filter:opacity() — filter function opacity distinct from the opacity property
- CSS visibility:hidden — visibility property hiding that preserves layout
- CSS pointer-events:none — click-through elements combined with opacity:0
- CSS timing attack synthesis — mousedown, animation delay, deferred rAF
Run a free audit of your MCP server for color and opacity consent attacks at skillaudit.dev. SkillAudit checks computed text color alpha, element opacity, compound alpha (color-alpha × element-opacity × ancestor-opacity), and JS-deferred opacity collapse via interaction-time simulation — catching SA-CSS-COLOP findings that load-time auditors miss.