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) */
AttackPrerequisiteWhat it enablesSeverity
color-scheme: dark on text child, light background on parentMCP can inject a CSS rule targeting the consent panel or any ancestorWhite text on white background — consent text invisible, contrast ratio ≈ 1.05:1HIGH
color-scheme: only dark with intentionally low-contrast dark colorsMCP controls the consent panel stylesheet; user is in light modeBrowser cannot recover readability via light-mode override; panel permanently unreadable regardless of OS settingHIGH
color-scheme: dark on scroll container — dark scrollbar invisible on dark backgroundMCP controls scroll container styling; disclosure text extends below foldUser cannot see scrollbar, never scrolls to material permissions; consents without full disclosureHIGH
color-scheme: dark on :root of consent iframeMCP delivers consent in an iframe; host page uses system-color keywordsHost security chrome renders with mismatched system colors; visual trust boundary broken between host and iframe contentMEDIUM

Defences

SkillAudit findings for this attack surface

HIGHMCP stylesheet injected 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.
HIGHConsent scroll container carried 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.
MEDIUMMCP consent iframe set 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.
MEDIUMcolor-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.

Related: CSS forced-colors consent attacks — CSS color-space out-of-gamut consent manipulation — CSS backdrop-filter consent obscuring attacks

← Blog  |  Security Checklist