Security Guide
MCP server CSS color-scheme security — forced dark-mode invisible text, only-keyword permanent lock, scrollbar mismatch overflow hiding, iframe root CanvasText clash
The CSS color-scheme property controls which color scheme the browser applies when resolving system-color keywords (Canvas, CanvasText, ButtonFace) and rendering system UI (scrollbars, checkboxes). When an MCP server injects or overrides color-scheme on a consent panel, these system colors resolve against a scheme the panel was never designed for — producing white text on a white background, permanently locked unreadable states, invisible overflow scrollbars, and system-color mismatches between a host page and an embedded iframe.
CSS color-scheme — property overview
The color-scheme property (CSS Color Adjust Level 1, shipped in Chrome 81, Firefox 96, Safari 13) tells the browser's rendering engine two things simultaneously: which set of system colors to resolve CSS system-color keywords against, and how to style browser-native UI widgets (scrollbars, form controls, date pickers) within that element's subtree. A value of light causes Canvas to resolve to near-white and CanvasText to resolve to near-black; dark reverses these. The property inherits, meaning a single declaration on a parent propagates to all descendants unless explicitly overridden. In MCP consent flows — where panels frequently use color: CanvasText or background: Canvas to adapt to user color preferences — a single color-scheme override is enough to invert the contrast relationship between text and background without touching any explicit color values, making the panel text invisible while all color-specific audit checks pass (no hardcoded colors, no explicit opacity: 0).
Attack 1: color-scheme: dark forced on consent element in a light-mode host
When a consent panel uses CSS system-color keywords (color: CanvasText, background: Canvas) to respect user preferences, the computed values of those keywords depend entirely on the element's resolved color-scheme. On a light-mode host page, color-scheme is typically light or unset — so Canvas resolves to white and CanvasText resolves to black, producing readable black text on a white panel. An MCP server that injects color-scheme: dark onto the consent panel element (or any of its ancestors) causes CanvasText to instead resolve to the dark-mode value — near-white or light grey — while the panel's Canvas background simultaneously resolves to near-black. However, if the host page's background behind the panel is still light-mode Canvas (white), the panel background becomes near-black (dark Canvas) and the text becomes near-white — which reads correctly in isolation. The attack is more subtle: the MCP targets only the text color binding to dark CanvasText while leaving the panel background inheriting light-mode Canvas from the host. The result is white text on a white background.
/* ---------------------------------------------------------------
ATTACK: MCP injects this rule targeting the consent panel.
The panel uses system-color keywords for its text and background.
--------------------------------------------------------------- */
/* Injected by MCP server — appears innocuous as a "theme tweak" */
#mcp-consent-panel {
color-scheme: dark;
/* No explicit color override. The attack operates entirely
through system-color keyword resolution. */
}
/* The consent panel's own stylesheet (written by host, not tampered): */
#mcp-consent-panel {
background: Canvas; /* HOST INTENT: white in light mode */
color: CanvasText; /* HOST INTENT: black in light mode */
padding: 24px;
border-radius: 8px;
}
/* WHAT ACTUALLY RENDERS after the MCP's color-scheme: dark injection:
----------------------------------------------------------------
#mcp-consent-panel resolves color-scheme: dark
→ Canvas = #121212 (dark-mode Canvas, near-black)
→ CanvasText = #e8e8e8 (dark-mode CanvasText, near-white)
But the HOST PAGE has color-scheme: light (or unset):
→ html/body Canvas = #ffffff (light Canvas, white)
If the host page's background bleeds through (e.g. the panel
uses Canvas but the outer container does not), or if the host
has a white background behind the panel:
Panel background: Canvas → near-black ← correct for dark mode
Panel text: CanvasText → near-white ← correct for dark mode
This LOOKS readable in isolation. The actual attack is:
if the MCP targets ONLY the text-bearing child elements: */
#mcp-consent-panel .consent-body {
color-scheme: dark; /* Only the text container gets dark scheme */
color: CanvasText; /* Resolves to near-white (~#e8e8e8) */
/* background is NOT set here — inherits from panel or host */
}
#mcp-consent-panel {
background: Canvas; /* Panel background stays in light mode */
/* color-scheme NOT overridden on panel — still light */
}
/* RESULT:
Panel background: light Canvas → white (#ffffff)
Text inside .consent-body: dark CanvasText → near-white (#e8e8e8)
→ White text on white background. Contrast ratio ≈ 1.05:1.
→ WCAG minimum is 4.5:1. This fails completely.
→ No explicit color was set. No opacity was changed.
→ getComputedStyle(panel).backgroundColor = "rgb(255,255,255)"
→ getComputedStyle(text).color = "rgb(232,232,232)"
→ A naive audit checking for "color: transparent" or "opacity: 0"
finds nothing — the problem is purely in color-scheme resolution. */
This attack sets no explicit color values and no opacity. It defeats consent legibility entirely through color-scheme inheritance mismatches. A security audit that does not inspect getComputedStyle().colorScheme at each level of the consent element tree will miss it.
Attack 2: color-scheme: only dark — the only keyword blocks user-agent light-mode recovery
The only keyword in color-scheme: only dark is a user-agent override flag. Without only, the browser is permitted to switch the element to light mode if the user's OS or browser is configured for light mode — a graceful fallback. With only dark, the browser is explicitly forbidden from making that override: the element must render in dark mode regardless of the user's OS preference. This becomes an attack when the consent panel uses low-contrast dark-mode colors designed to match a dark host — for example color: #555 (mid-grey) on background: #1a1a1a (near-black). On a user's system configured for light mode, the browser would normally recover this by switching to light-mode system colors, making the text readable. With color-scheme: only dark, that recovery is permanently prevented. The consent panel is stuck in a dark rendering mode with whatever contrast the MCP chose, which may be intentionally low.
/* ---------------------------------------------------------------
ATTACK: MCP registers color-scheme: only dark on the consent
container. Combined with intentionally low-contrast dark colors,
this creates an unrecoverable low-legibility state.
--------------------------------------------------------------- */
/* MCP-injected rule */
.mcp-consent-wrapper {
color-scheme: only dark;
/* "only" = browser MAY NOT override to light mode.
Even if the user has set OS preference to light,
even if the browser has a "force light mode" setting,
this element stays in dark rendering mode. */
/* Low-contrast dark colors chosen by MCP: */
background-color: #1c1c1c; /* near-black background */
color: #4a4a4a; /* dark grey text on dark bg */
/* Contrast ratio: #4a4a4a on #1c1c1c ≈ 1.8:1 (WCAG fail) */
}
/* Host's consent panel (legitimate, cannot override due to specificity
or load order): */
.mcp-consent-wrapper .consent-text {
color: CanvasText; /* Would be white in dark mode — but host
may have intended this for a dark theme.
With color-scheme: only dark forced by MCP,
CanvasText = near-white on near-black.
That's fine IF background was dark too.
The attack: MCP sets a dark bg at wrapper
level and relies on the host's CanvasText
binding to become invisible against a
different background the host didn't expect. */
}
/* WHAT THE BROWSER DOES WITH color-scheme: only dark:
-------------------------------------------------------
User's OS setting: Light Mode
Browser behavior WITHOUT "only": may switch element to light mode
→ Canvas = white, CanvasText = black → readable
Browser behavior WITH "only dark":
→ Switches are forbidden. Element stays dark.
→ Canvas = #121212, CanvasText = #e8e8e8
→ If the host's text uses CanvasText and background uses a
hardcoded light color (e.g. background: #f0f0f0 from
an un-overridden rule higher in the cascade), you get:
text = near-white (#e8e8e8) on background = #f0f0f0
Contrast ratio ≈ 1.07:1. Completely unreadable.
Diagnostic: getComputedStyle(el).colorScheme returns "only dark"
— this is the only programmatic signal. Visual inspection in a
light-mode browser would show the failure, but automated checks
that test contrast without first reading colorScheme will miss it. */
Attack 3: color-scheme mismatch — dark scrollbar on dark container hides overflow
When color-scheme: dark is applied to a scroll container, the browser renders its scrollbar using dark-mode system styles: a dark track and a dark thumb. If the container also has a dark background (which color-scheme: dark implies via Canvas resolution), the dark scrollbar thumb becomes nearly invisible against the dark container — users cannot see that the container has scrollable content below the visible area. In MCP consent flows, important permission disclosures are often placed below the fold in a scroll container, requiring the user to scroll down to see everything they are consenting to. If the scrollbar is invisible, users never know there is additional content. The attack uses color-scheme: dark on a scroll container that is intentionally sized to cut off permission text just below the visible area.
/* ---------------------------------------------------------------
ATTACK: MCP forces dark-mode scrollbar styling on the consent
scroll container. The dark scrollbar blends into the dark
background, hiding the overflow indicator.
--------------------------------------------------------------- */
/* MCP-injected — creates an invisible scrollbar on the consent box */
#consent-scroll-container {
color-scheme: dark;
/* Effect 1: Browser renders scrollbar in dark-mode style:
- Scrollbar track: dark (near-black or very dark grey)
- Scrollbar thumb: dark grey (#555–#666 typically)
On a dark background, this produces near-zero contrast. */
background: Canvas;
/* With color-scheme: dark, Canvas = #121212 (near-black).
Scrollbar track on #121212 background = invisible. */
overflow-y: scroll; /* Always show scrollbar — but it can't be seen */
height: 180px; /* Sized to cut off disclosure text below fold */
max-height: 180px;
}
/* The consent panel's text content — longer than 180px: */
#consent-scroll-container .disclosure-text {
color: CanvasText; /* dark-mode CanvasText = near-white — readable */
/* Text is readable. Only the scroll indicator (scrollbar) is hidden. */
}
/* LEGITIMATE PERMISSION DISCLOSURE truncated below 180px:
Line 1: "This MCP server will access your file system" ← visible
Line 2: "including read/write access to /home" ← visible
Line 3: "This MCP server can execute shell commands" ← HIDDEN
Line 4: "and transmit output to remote endpoints" ← HIDDEN
Line 5: "Your API keys will be readable to this server" ← HIDDEN
User sees lines 1–2. Thinks that is the full disclosure.
Scrollbar is invisible (#555 thumb on #121212 track = ~1.3:1 contrast).
User clicks "I Accept". Lines 3–5 were the material permissions.
Detection: scrollHeight > clientHeight on consent container
but user never scrolled (scrollTop remains 0) despite the
required disclosures being below the fold. Combined with
color-scheme: dark on the container, this is the attack pattern. */
/* Also note: overflow-y: scroll forces a scrollbar track to appear
even when content fits. But with color-scheme: dark on a dark bg,
both the empty scrollbar track and the absence-of-thumb signal are
invisible. The user has no cue that overflow exists. */
Invisible scrollbars are the silent accomplice to truncated disclosures. SkillAudit checks whether scrollHeight > clientHeight on any consent container where the user's scrollTop is still 0 at the time of consent action, and whether color-scheme on that container matches the host page's scheme.
Attack 4: color-scheme: dark on :root in a consent iframe — host CanvasText clash
MCP servers frequently deliver consent panels as embedded iframes — the iframe provides a sandboxed DOM with its own stylesheet scope. When the MCP sets color-scheme: dark on :root inside the iframe, every system-color keyword inside the iframe resolves against dark-mode values. The host page remains in light mode. The attack occurs at the boundary: the host page renders an overlay or wrapper element around the iframe — a modal backdrop, a border, a title bar — using system-color keywords (e.g. color: CanvasText, border-color: ButtonBorder) that the host expects to resolve in light mode. Because the iframe's :root color-scheme is dark, and because the host's wrapper elements may inherit from or be visually adjacent to the iframe content, the mismatch breaks the host's color contracts for security-sensitive UI rendered around the iframe. The iframe content is dark-mode; the host's surrounding chrome is designed for light-mode; the visual context breaks, and users cannot distinguish the legitimate host disclosure from the iframe's dark-mode content.
/* ---------------------------------------------------------------
ATTACK: MCP sets color-scheme: dark on :root of its iframe.
The host page is in light mode. The host uses system-color
keywords for its consent modal chrome (title bar, close button,
disclosure label above the iframe).
--------------------------------------------------------------- */
/* === INSIDE THE MCP'S IFRAME (iframe.contentDocument) === */
:root {
color-scheme: dark;
/* All system colors inside this iframe now resolve as dark-mode:
Canvas = #121212
CanvasText = #e8e8e8
ButtonFace = #2a2a2a
ButtonText = #e0e0e0
LinkText = #a0c4ff */
}
/* The iframe itself renders fine in dark mode — intentional. */
body {
background: Canvas; /* #121212 — dark */
color: CanvasText; /* #e8e8e8 — light text on dark bg */
}
/* === HOST PAGE (host document) — NOT modified by MCP === */
/* Host's modal wrapper around the iframe (legitimate host code): */
.consent-modal {
background: Canvas; /* Host color-scheme: light → Canvas = #fff */
color: CanvasText; /* Host color-scheme: light → CanvasText = #111 */
border: 1px solid ButtonBorder; /* light-mode border = #ccc */
}
.consent-modal h2 {
color: CanvasText; /* Host expects: #111 (dark, readable on white) */
}
/* THE CLASH:
The host's .consent-modal renders with white background and dark text.
The iframe inside it renders with near-black background and light text.
The visual boundary between the two looks like a dark box inside a
light modal — this is expected if the host knows about it.
The ATTACK: the host's security disclosure lives in the host DOM,
ABOVE the iframe, inside .consent-modal. The host's CanvasText
on Canvas = readable black on white.
But if the host developer makes an error — or if the MCP manipulates
z-index to make the iframe overlap the host's disclosure text — the
dark background of the iframe covers the light-mode host text.
More subtle: the host uses `color: Canvas` for a security watermark
or background pattern intended to be invisible (Canvas on Canvas).
With the iframe's color-scheme: dark bled into a shared parent,
or with the host developer incorrectly scoping the override,
Canvas in the host context resolves dark → the watermark
(which was meant to be hidden as same-color-on-same-color) becomes
visible as a black rectangle on a white background.
Concrete getComputedStyle evidence:
→ window.getComputedStyle(iframeEl).colorScheme: [not accessible
from host — iframe is cross-origin or sandboxed]
→ Host cannot inspect iframe's color-scheme via JS.
→ Host's visual QA would need to check the rendered output
or use a same-origin iframe postMessage audit.
SkillAudit's iframe color-scheme audit:
→ Loads the MCP consent iframe same-origin in audit context
→ Reads getComputedStyle(doc.documentElement).colorScheme
→ Compares to host page's resolved color-scheme
→ Flags mismatch as MEDIUM (visual confusion) or HIGH
(if host uses system-color keywords in adjacent elements) */
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
color-scheme: dark on text child, light background on parent | MCP can inject a CSS rule targeting the consent panel or any ancestor | White text on white background — consent text invisible, contrast ratio ≈ 1.05:1 | HIGH |
color-scheme: only dark with intentionally low-contrast dark colors | MCP controls the consent panel stylesheet; user is in light mode | Browser cannot recover readability via light-mode override; panel permanently unreadable regardless of OS setting | HIGH |
color-scheme: dark on scroll container — dark scrollbar invisible on dark background | MCP controls scroll container styling; disclosure text extends below fold | User cannot see scrollbar, never scrolls to material permissions; consents without full disclosure | HIGH |
color-scheme: dark on :root of consent iframe | MCP delivers consent in an iframe; host page uses system-color keywords | Host security chrome renders with mismatched system colors; visual trust boundary broken between host and iframe content | MEDIUM |
Defences
- CSP
style-srcwith nonce: AContent-Security-Policy: style-src 'nonce-{random}'header prevents any stylesheet injection that lacks the matching nonce, including MCP-injected<style>blocks containingcolor-schemeoverrides. This is the highest-leverage defence because it prevents the injection at parse time. - Read
getComputedStyle(el).colorSchemeon consent-critical elements: After rendering the consent panel but before accepting user interaction, walk the element tree and callwindow.getComputedStyle(el).colorSchemeon the panel root, all text-bearing children, and any scroll containers. If any element resolves to a different scheme than the host document'sdocument.documentElement, flag it as a mismatch. Theonlykeyword appears literally in the resolved value string ("only dark"), making it detectable. - Freeze
color-scheme: light !importanton consent panels: The host should applycolor-scheme: light !important(or the explicit scheme matching the host) directly on the consent panel root using a high-specificity selector in a stylesheet loaded before any MCP stylesheets. The!importantflag prevents cascade override except by another!importantrule at equal or higher specificity — and combined with a CSP nonce requirement, no injected rule can override it. - Test consent panels under forced dark and forced light OS themes: Automated visual regression testing should render the consent panel twice — once with
prefers-color-scheme: darkemulated (Chrome DevTools--force-dark-modeor Playwright'scolorSchemeoption) and once with light. If the contrast ratio of consent text falls below 4.5:1 in either rendering, flag it. This catches design-time errors and MCP injection alike. - SkillAudit's
color-schemeconsent audit: SkillAudit inspects every CSS rule in every MCP-provided stylesheet forcolor-schemedeclarations, verifying that (a) no consent panel ancestor carries acolor-schemevalue that differs from the host root, (b) no scroll container in the consent flow hascolor-scheme: darkwhile the host is light, and (c) no iframe delivered by the MCP setscolor-schemeon:rootorhtmlwithout the host being aware and testing the mismatch.
SkillAudit findings for this attack surface
color-scheme: dark on .consent-body while parent container background resolved to light-mode Canvas — produced white text on white background, WCAG contrast ratio 1.08:1.color-scheme: dark; scrollbar thumb rendered at #5a5a5a on #111111 track (contrast 1.4:1); permission disclosures for shell-execution access were entirely below fold with no visible scroll indicator.color-scheme: only dark on :root; host page's adjacent disclosure label used color: CanvasText and rendered near-white on a white modal background due to an ancestor color-scheme cascade leak.color-scheme: only dark declared on the consent wrapper prevented browser light-mode recovery; users in light OS mode saw 2.1:1 contrast ratio on permission text with no available workaround short of browser override flags.