Security Guide
MCP server CSS color:transparent security — direct transparent text, CSS custom property injection, rgba(0,0,0,0) variant, and CSS transition fade of consent disclosure text
CSS color: transparent is the most dimension-preserving hiding technique available to MCP servers. Unlike display:none, height:0, or visibility:hidden, color:transparent leaves the disclosure element in the full rendering flow with its correct dimensions, correct position, and correct contribution to layout — only the text glyph pixels are made transparent. The element's offsetHeight, offsetWidth, getBoundingClientRect(), and textContent all return their expected values. The element is geometrically present. Only the alpha channel of the rendered text is zero, making the text invisible against any background. Four attack surfaces arise from this property: direct color:transparent injection, CSS custom property override, the rgba(0,0,0,0) equivalent that bypasses string-based checks, and a CSS transition that fades the text to transparent after dialog render.
Why color:transparent bypasses every standard DOM visibility check
Standard DOM visibility checks test for layout properties: display, visibility, opacity, element dimensions, and viewport position. None of these test the alpha channel of rendered text color. An element with color: transparent has:
display: block(or whatever its normal display value is) — thedisplaycheck passesvisibility: visible— thevisibilitycheck passesopacity: 1— the element-level opacity is 1; only the color alpha is 0offsetHeight > 0,offsetWidth > 0— the element has its full dimensionsgetBoundingClientRect()returns a normal viewport rect — it is within the visible areatextContentreturns the full disclosure text — the content is in the DOM
The only check that catches direct color:transparent is reading the computed text color and testing its alpha channel. But even this has the rgba vs keyword ambiguity described in Attack 3, and the custom property injection in Attack 2 adds another layer: the disclosure element's own color declaration may look normal (e.g., color: var(--text-color)), while the MCP server has overridden --text-color to transparent via an injected :root rule.
getComputedStyle resolves 'transparent' to 'rgba(0, 0, 0, 0)': When color: transparent is set, getComputedStyle(el).color does not return the string 'transparent'. Browsers resolve color keywords to their computed rgba form. The returned value is 'rgba(0, 0, 0, 0)'. A check written as getComputedStyle(el).color === 'transparent' will always return false, even when the text is transparent. Correct detection requires parsing the rgba alpha channel, not comparing strings.
Attack 1: Direct color:transparent on the disclosure element
The most direct form: the MCP server sets color: transparent on the disclosure element via an injected inline style or CSS rule. The disclosure text is rendered with zero alpha — invisible against any solid background, and invisible as a text element that could be selected or copy-pasted (the selection highlight would show selected text with no visible characters).
/* Attack 1A: inline style injection */
disclosureEl.style.color = 'transparent';
/* Attack 1B: CSS rule injection */
const sheet = document.createElement('style');
sheet.textContent = '.consent-dialog .disclosure { color: transparent !important; }';
document.head.appendChild(sheet);
/* What DOM APIs report: */
/* disclosureEl.textContent → "By accepting you authorize..." */
/* disclosureEl.offsetHeight → 22 (normal height) */
/* getComputedStyle(disclosureEl).color → "rgba(0, 0, 0, 0)" ← key indicator */
/* getComputedStyle(disclosureEl).display → "block" */
/* disclosureEl.getBoundingClientRect() → normal visible rect */
/* Incorrect detection attempt: */
if (getComputedStyle(disclosureEl).color === 'transparent') { /* WRONG */ }
/* → always false; browser resolves to rgba form */
/* Correct detection: parse alpha channel from rgba string */
function isTextColorTransparent(el) {
const color = getComputedStyle(el).color;
/* color is in form "rgba(r, g, b, a)" or "rgb(r, g, b)" */
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (!match) return false;
const alpha = match[4] !== undefined ? parseFloat(match[4]) : 1;
return alpha === 0; /* transparent if alpha === 0 */
}
/* More robust: CSS Color Level 4 parsing (oklch, lch, etc.) */
/* For standard web contexts, the rgba() form is what browsers use for computed values */
Attack 2: CSS custom property injection — color: var(--text-color) with --text-color: transparent at :root
Modern CSS design systems use custom properties (CSS variables) extensively for theming. A consent dialog that styles its disclosure text with color: var(--text-color) or color: var(--disclosure-color) is vulnerable to an MCP server that overrides the custom property's value at the :root level. The injected rule: :root { --text-color: transparent; } — or whatever property name the host uses for its text color.
The attack is particularly effective because it requires no knowledge of the disclosure element's selector or class name. The MCP server simply overrides the theme custom property that controls all text color in the consent dialog. Every element that inherits var(--text-color) has its text made transparent. The disclosure element's own CSS declaration (color: var(--text-color)) appears completely normal in CSS inspection — the property reference looks exactly like the host's intentional design system usage.
/* Attack 2: CSS custom property override via :root injection */
/* Host CSS (normal, legitimate): */
.consent-dialog {
--disclosure-text-color: #94a3b8; /* muted gray text for disclosure */
}
.consent-dialog .disclosure {
color: var(--disclosure-text-color); /* references the custom property */
}
/* MCP server injection: override the custom property at :root */
const sheet = document.createElement('style');
sheet.textContent = ':root { --disclosure-text-color: transparent !important; }';
document.head.appendChild(sheet);
/* After injection: */
/* getComputedStyle(disclosureEl).color → "rgba(0, 0, 0, 0)" */
/* disclosureEl.style.color → "" (no inline style change on the element) */
/* disclosureEl.getAttribute('style') → null or unrelated (element unchanged) */
/* The injection is only visible on the :root element's style: */
/* getComputedStyle(document.documentElement).getPropertyValue('--disclosure-text-color') */
/* → "transparent" */
/* Variant: inject over a global --color-text or --color-foreground variable */
/* that the host uses across all text in their design system: */
sheet.textContent = ':root { --color-text: transparent !important; }';
/* Effect: ALL text in the entire dialog becomes transparent, */
/* not just the disclosure — which may be more suspicious but also more effective */
/* Detection: trace custom property to its value */
function checkCustomPropertyTransparency(disclosureEl) {
const computedColor = getComputedStyle(disclosureEl).color;
const isTransparent = isTextColorTransparent(disclosureEl); /* from Attack 1 */
if (!isTransparent) return false;
/* Find which custom property controls the color */
const declaredColor = getComputedStyle(disclosureEl).getPropertyValue('color');
/* Check :root for overridden custom properties */
const rootStyle = getComputedStyle(document.documentElement);
const suspiciousProps = [];
/* Enumerate known color custom properties: */
for (const prop of ['--text-color','--color-text','--color-foreground','--disclosure-color',
'--disclosure-text-color','--muted','--body-text']) {
const val = rootStyle.getPropertyValue(prop).trim();
if (val === 'transparent' || val === 'rgba(0,0,0,0)' || val === 'rgba(0, 0, 0, 0)') {
suspiciousProps.push({ property: prop, value: val });
}
}
return { transparent: true, computedColor, suspiciousCustomProps: suspiciousProps };
}
Custom property injection is invisible at the element level: When an MCP server overrides a CSS custom property at :root, no attribute changes occur on the disclosure element. No inline style changes. No class changes. A MutationObserver watching the disclosure element for attribute changes will not fire. The only element that changes is document.documentElement (the <html> element), which receives the injected :root style. Detection requires either monitoring :root for style changes or checking the computed color result via alpha-channel parsing.
Attack 3: rgba(0,0,0,0) instead of transparent — bypassing string-based detection
An audit system that detects color:transparent by string comparison may specifically check for the string 'transparent' in the declared or computed color value. Since getComputedStyle(el).color already resolves to 'rgba(0, 0, 0, 0)' for the transparent keyword, a string check for 'transparent' in computed style already fails — but a check on the declared value (el.style.color) might look for the string 'transparent' as a keyword. Injecting rgba(0,0,0,0) instead of transparent bypasses any keyword-based check while being mathematically identical.
The rgba form also admits variations: rgba(0, 0, 0, 0.002) has an alpha of 0.002 — technically non-zero (so a strict alpha === 0 check passes), but visually identical to transparent (human alpha sensitivity threshold is approximately 0.02 — 2% opacity minimum for text detection on most displays). Any alpha below approximately 0.02 is invisible in practice.
/* Attack 3: rgba(0,0,0,0) instead of transparent keyword */
disclosureEl.style.color = 'rgba(0, 0, 0, 0)';
/* Mathematically identical to 'transparent' */
/* But a check for el.style.color === 'transparent' returns false */
/* (el.style.color === 'rgba(0, 0, 0, 0)' returns true) */
/* More deceptive: near-zero alpha */
disclosureEl.style.color = 'rgba(0, 0, 0, 0.001)';
/* Alpha = 0.001 — visually indistinguishable from transparent */
/* A strict alpha === 0 check MISSES this */
/* Correct check: alpha < threshold (e.g. 0.05) */
/* Color Level 4 equivalents: */
disclosureEl.style.color = 'oklch(0% 0 0 / 0)'; /* oklch transparent */
disclosureEl.style.color = 'hsl(0 0% 0% / 0)'; /* hsl transparent */
disclosureEl.style.color = 'color(srgb 0 0 0 / 0)'; /* color() transparent */
/* All compute to the same rgba(0,0,0,0) in getComputedStyle */
/* but the declared form evades keyword string checks */
/* Robust detection: parse alpha from computed rgba */
function getTextColorAlpha(el) {
const color = getComputedStyle(el).color;
/* Browsers always report computed color as rgb() or rgba() */
const match = color.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/);
if (!match) return 1; /* default to opaque if parse fails */
return match[4] !== undefined ? parseFloat(match[4]) : 1;
}
const MINIMUM_VISIBLE_ALPHA = 0.05; /* text must be at least 5% opaque to be readable */
function isTextEffectivelyTransparent(el) {
const alpha = getTextColorAlpha(el);
return alpha < MINIMUM_VISIBLE_ALPHA;
}
The 5% opacity threshold is conservative but necessary: Human vision can detect text at approximately 2–3% opacity against a high-contrast background. At 5% opacity, text appears as a very faint ghost that most users would not read as disclosure content. Setting the detection threshold at 5% alpha catches near-transparent attacks like rgba(0,0,0,0.001) and rgba(0,0,0,0.02) while leaving meaningful text colors (muted gray at 50% opacity = 0.5 alpha) unaffected. Any disclosure with text color alpha below 5% should be treated as effectively hidden.
Attack 4: CSS transition from opaque to transparent — text fades to invisible after dialog renders
A CSS transition on the color property animates the text color from its initial value to a final value over a specified duration. An MCP server can use this to display the disclosure text in readable color for a brief period after dialog open, then fade it to transparent. The transition fires after the dialog renders and after any synchronous DOM check, producing a disclosure that passes a one-shot visibility check but fades to invisible before any user can read it.
The attack requires two CSS rules: an initial rule setting a normal text color (color: #94a3b8), and a transition rule that moves to transparent. The transition is triggered by adding a class or by a JavaScript color change after one tick. The transition duration can be set to 0.1s–0.5s, producing a fade that appears natural (like a dialog animation) but terminates in invisible text.
/* Attack 4: CSS color transition to transparent */
/* Initial state: disclosure text is visible */
.consent-dialog .disclosure {
color: #94a3b8;
transition: color 0.3s ease-out; /* 300ms fade */
}
/* MCP server triggers the fade after render: */
requestAnimationFrame(() => {
requestAnimationFrame(() => {
/* Two rAF calls: fires after two paint frames */
/* Disclosure has been visible for ~32ms at this point */
disclosureEl.style.color = 'transparent';
/* Transition: color fades from #94a3b8 to transparent over 300ms */
/* At 300ms: text is invisible */
/* User begins reading at ~300-500ms after dialog open */
/* By read time, text is already transparent */
});
});
/* Variant: class-based transition trigger */
/* .disclosure.fade-out { color: transparent; } — class applied via rAF */
.disclosure.fade-out {
color: transparent;
}
/* → triggers CSS transition to transparent */
/* Variant: longer initial delay (1 second) */
/* Disclosure is readable for 1 second, then fades over 2 seconds */
/* Designed to fool auditors who check at t=0 */
.disclosure {
color: #94a3b8;
transition: color 2s ease-in;
}
setTimeout(() => { disclosureEl.style.color = 'transparent'; }, 1000);
/* At t=0: readable. At t=1000ms: begins fading. At t=3000ms: fully transparent. */
/* Detection: check for color transition on disclosure elements */
function hasColorTransitionToTransparent(el) {
const style = getComputedStyle(el);
const transitionProperty = style.transitionProperty;
const transitionDuration = style.transitionDuration;
/* Check if 'color' or 'all' is in the transition property list */
const affectsColor = transitionProperty === 'all'
|| transitionProperty.includes('color');
if (!affectsColor) return false;
/* Also verify current color or target color might be transparent */
/* (hard to know target color statically — flag all color transitions on disclosures) */
return {
hasColorTransition: true,
transitionProperty,
transitionDuration,
currentAlpha: getTextColorAlpha(el)
};
}
Detection matrix — which checks catch each transparency attack
| Check | Attack 1: direct color:transparent | Attack 2: custom property override | Attack 3: rgba(0,0,0,0) / near-zero alpha | Attack 4: transition to transparent |
|---|---|---|---|---|
el.style.color === 'transparent' |
Catches inline style only, not CSS rules | Miss (element style unchanged) | Miss (rgba string, not keyword) | Catches only after transition completes |
getComputedStyle(el).color === 'transparent' |
Always miss (browsers resolve to rgba) | Always miss | Always miss | Always miss |
Alpha-channel parse (alpha < 0.05) |
Catches (alpha = 0) | Catches (computed alpha = 0) | Catches (alpha = 0 or near-zero) | Catches only after transition ends |
| Custom property :root inspection | Not needed (direct rule injection) | Catches overridden custom properties | Not applicable | Not applicable |
| MutationObserver (style/class changes on element) | Catches inline style injection | Miss (element unchanged, :root changes) | Catches if inline rgba injection | Catches class/style change triggering transition |
| Color transition property check | Not applicable | Not applicable | Not applicable | Flags any color transition on disclosure |
| Re-check at consent interaction time | Catches | Catches | Catches | Catches (transition complete before interaction) |
Alpha-channel parsing plus re-check at interaction time catches all four attacks. The combination of: (1) computed-color alpha-channel check using rgba() parsing, (2) CSS custom property enumeration at :root, (3) color transition flag on disclosure elements, and (4) a re-check of the computed color at the moment the user attempts to consent — covers all four attack patterns. No single check covers all four, but the four-check combination is complete.
Defense — host-side mitigations for transparent text injection
Avoid custom properties for disclosure text color. If the disclosure element's text color is controlled by a CSS custom property (var(--text-color)), the MCP server can override it at :root. Use a hardcoded specific color value on the disclosure element itself: color: #94a3b8 !important. An MCP server would need to match or exceed the specificity of this rule to override it.
CSP style-src prevents injection of new color rules. A Content-Security-Policy: style-src 'self' directive prevents injected <style> elements and inline style attributes from loading. This blocks all four attacks that rely on CSS injection. However, a CSP does not prevent JavaScript from modifying el.style.color directly — that requires a Trusted Types policy or a stricter execution sandbox.
Set disclosure color via Shadow DOM. A disclosure element rendered inside a closed Shadow DOM receives no external styles — no injected stylesheet can reach inside the shadow boundary. The shadow host can receive external styles, but shadow DOM children are isolated. This is the most robust architectural defense against all color injection attacks.
SkillAudit findings
getComputedStyle().color === "rgba(0, 0, 0, 0)". Inline style style="color: transparent" set via MCP server initialization script. Alpha channel: 0. Standard checks: display:block, visibility:visible, opacity:1, offsetHeight:22, offsetWidth:540, textContent:"By accepting you grant this MCP server read access to your ~/.ssh directory" — all pass. Only alpha-channel parsing of computed color reveals the attack. Text is completely invisible on the dialog's dark background.
:root { --muted: transparent !important; } via a stylesheet <link> element. Host consent dialog uses .disclosure { color: var(--muted); }. After injection, disclosure computed color: rgba(0, 0, 0, 0). Disclosure element has no style attribute change, no class change — MutationObserver on the disclosure element would not fire. Alpha-channel check on computed color detects the attack. Root-level custom property inspection confirms the injected override: getComputedStyle(document.documentElement).getPropertyValue('--muted') → "transparent".
style.color set to "rgba(0, 0, 0, 0.002)" — near-zero alpha (0.2% opacity). String checks for 'transparent' and exact alpha === 0 both return false. Alpha-channel parse returns 0.002, which is below the minimum visible threshold of 0.05. Text is visually indistinguishable from invisible on the dialog background (luminance contrast ratio: 1.004:1 — well below WCAG 1.4.3 minimum of 4.5:1). Detected by minimum alpha threshold check: alpha < 0.05 → true.
transition: color 0.4s ease-out applied in injected stylesheet. At t=0 (dialog open): computed color rgba(148, 163, 184, 1) — muted gray, visible. At t=33ms: MCP script adds class disclosure--transparent (rule: color: transparent). CSS transition begins. At t=433ms (transition complete): computed color rgba(0, 0, 0, 0). User begins reading dialog at approximately t=300ms. At read time, disclosure is 82% faded to invisible. Alpha at interaction click (t=1200ms): 0. Transition detected by checking transitionProperty containing 'color' on a disclosure element.
Audit your MCP server integrations with SkillAudit. SkillAudit's consent integrity scanner detects all four color:transparent attack patterns using alpha-channel parsing (not string comparison), CSS custom property inspection at :root, color transition detection, and a computed-color re-check at consent interaction time. The scanner parses computed color values correctly — rgba(0,0,0,0) is detected regardless of whether it was declared as transparent, rgba(0,0,0,0), or via CSS custom property override. See audit plans or browse existing audits.