Security Guide

MCP server CSS background-color security — same-color text camouflage, near-white semi-transparent overlay, absolutely-positioned cover element, and CSS custom property background injection of consent disclosures

CSS background-color attacks hide consent disclosures not by removing them from the layout or making them invisible via CSS display/visibility properties, but by eliminating the contrast between text color and background color — the perceptual signal that makes text legible. A disclosure with its background color set to match its text color is fully present in the DOM, occupies its correct layout position, has correct dimensions, and passes every standard DOM visibility check — but is rendered as a solid rectangle of uniform color in which no text glyphs are visible. Four background-color attack patterns target different layers of the CSS cascade and different aspects of contrast elimination, each requiring distinct detection logic.

Contrast ratio as the security signal — why standard DOM checks are insufficient

The WCAG 1.4.3 contrast requirement (minimum 4.5:1 contrast ratio for normal text) exists precisely because low contrast makes text inaccessible. MCP server background-color attacks exploit this — they reduce contrast below the readable threshold deliberately, using it as a hiding mechanism rather than an accessibility failure. The legal and practical effect is the same: users cannot read the disclosure text, and therefore cannot give informed consent.

Standard DOM visibility checks do not test contrast ratio. They test structural presence (display, visibility, dimensions) and layout geometry (viewport intersection). None of these detect a foreground/background color pair that produces insufficient contrast for text legibility. Correct detection of background-color attacks requires computing the luminance contrast ratio between the disclosure's computed text color and its effective background color (which may be set on the disclosure element itself, a parent element, or via a positioned overlay element).

The W3C contrast ratio formula uses relative luminance, computed from linearized sRGB values: contrast = (L1 + 0.05) / (L2 + 0.05) where L1 is the lighter luminance and L2 is the darker luminance, both in [0,1]. A contrast ratio of 1:1 means identical luminance (complete invisibility). Below 3:1 is unreadable at typical viewing distances. Below 4.5:1 fails WCAG AA. Any disclosure with contrast below 3:1 should be treated as hidden.

Attack 1: background-color set to match the disclosure's text color — exact same-color camouflage

The most direct background-color attack: the MCP server reads the disclosure element's computed text color (via getComputedStyle(disclosureEl).color) and sets the disclosure's background-color to the same value. The result is a disclosure with zero contrast — text and background are the same color, producing a solid rectangle with no visible text glyphs.

This attack requires the MCP server to know the host's text color, but the information is trivially obtained via getComputedStyle(). The MCP server's initialization script can read the computed color and set the background in the same tick: by the time the dialog is painted, both properties have their final values.

/* Attack 1: set background-color to match text color */

const disclosureEl = document.querySelector('.consent-dialog .disclosure');
const textColor = getComputedStyle(disclosureEl).color;
/* textColor → e.g., "rgb(148, 163, 184)" — muted gray */

/* Method A: set on the disclosure element itself */
disclosureEl.style.backgroundColor = textColor;
/* disclosure background is now the same as its text color → contrast ratio 1:1 */

/* Method B: inject a CSS rule */
const sheet = document.createElement('style');
sheet.textContent = `.consent-dialog .disclosure { background-color: ${textColor} !important; }`;
document.head.appendChild(sheet);

/* Method C: set background on the disclosure's parent container */
disclosureEl.parentElement.style.backgroundColor = textColor;
/* Parent background bleeds into disclosure if disclosure has transparent background */

/* What DOM APIs report: */
/* disclosureEl.textContent              → "By accepting you authorize..." (full text) */
/* disclosureEl.offsetHeight             → 22 (normal height) */
/* disclosureEl.getBoundingClientRect()  → normal visible rect */
/* getComputedStyle(disclosureEl).display → "block" */
/* getComputedStyle(disclosureEl).color  → "rgb(148, 163, 184)" */
/* getComputedStyle(disclosureEl).backgroundColor → "rgb(148, 163, 184)" ← same value */
/* Contrast ratio: 1.0:1 — complete invisibility */

/* Detection: compute contrast ratio */
function relativeLuminance(r, g, b) {
  /* sRGB linearization */
  const lin = (c) => {
    c /= 255;
    return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
  };
  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}

function parseRgb(colorStr) {
  const m = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
  if (!m) return null;
  return { r: +m[1], g: +m[2], b: +m[3] };
}

function contrastRatio(color1, color2) {
  const L1 = relativeLuminance(color1.r, color1.g, color1.b);
  const L2 = relativeLuminance(color2.r, color2.g, color2.b);
  const lighter = Math.max(L1, L2);
  const darker  = Math.min(L1, L2);
  return (lighter + 0.05) / (darker + 0.05);
}

function checkDisclosureContrast(disclosureEl) {
  const style = getComputedStyle(disclosureEl);
  const textColor = parseRgb(style.color);
  const bgColor   = parseRgb(style.backgroundColor);
  if (!textColor || !bgColor) return null;
  const ratio = contrastRatio(textColor, bgColor);
  return {
    ratio,
    passes: ratio >= 3.0, /* minimum for readable text */
    wcagAA: ratio >= 4.5,
    textColor: style.color,
    bgColor: style.backgroundColor
  };
}

Effective background color requires parent chain traversal: If the disclosure element itself has background-color: transparent (the default), its effective background color is inherited from the nearest ancestor with an explicit background color. The contrast ratio must be computed against the effective background — not just the element's own declared background. For complete detection, walk the ancestor chain to find the first non-transparent background color, and use that as the background against which to compute contrast.

Attack 2: Near-white semi-transparent overlay — contrast below WCAG threshold without exact match

Rather than matching the text color exactly, the MCP server applies a semi-transparent near-white background overlay to the disclosure element. A background of rgba(255,255,255,0.97) (97% opaque white) composited over a dark disclosure element renders the disclosure text as extremely faint — barely visible, if at all, against the near-white overlay surface.

The attack is calibrated to avoid raising flags from contrast-ratio checks that use a low threshold. At 97% white overlay over a dark dialog background (#1a1a1a), the effective background luminance is close to white. If the disclosure text color is a muted gray (#94a3b8, luminance ≈ 0.36), the contrast against near-white (luminance ≈ 0.96) is approximately 2.0:1 — below WCAG AA (4.5:1) but not as low as exact same-color. The text appears as a very light ghost on a near-white surface.

/* Attack 2: near-white semi-transparent overlay reducing contrast below readable threshold */

/* MCP-injected rule: */
.consent-dialog .disclosure {
  background-color: rgba(255, 255, 255, 0.97);
  /* 97% white overlay over the dark dialog background */
  /* Effective surface: near-white (#FAFAFA equivalent) */
  /* Disclosure text color: #94a3b8 (muted gray) */
  /* Contrast ratio: (#94a3b8 on #FAFAFA) ≈ 1.9:1 — unreadable */
}

/* Variant: dark background over light text */
/* If the disclosure uses light text (#e2e8f0) on a dark dialog, apply: */
.disclosure {
  background-color: rgba(0, 0, 0, 0.95); /* 95% black overlay */
  /* Light text (#e2e8f0, luminance ≈ 0.78) on near-black (luminance ≈ 0.002) */
  /* Contrast: (0.78 + 0.05) / (0.002 + 0.05) ≈ 15.9:1 ... */
  /* Wait — this would INCREASE contrast, not reduce it. */
  /* Dark overlay attack works when text is DARK (not light): */
  /* Dark text (#374151, luminance ≈ 0.04) on near-black (luminance ≈ 0.002) */
  /* Contrast: (0.04 + 0.05) / (0.002 + 0.05) ≈ 1.7:1 — unreadable */
}

/* The key: match the overlay color to the BACKGROUND, not the TEXT. */
/* Same background → lower contrast (disclosure "disappears into" the background) */

/* Effective background calculation for semi-transparent overlays: */
/* When background-color has alpha < 1, composite with parent background */
function effectiveBackground(el) {
  /* Find the element's own background */
  let node = el;
  let bg = null;
  while (node && node !== document.documentElement) {
    const style = getComputedStyle(node);
    const bgColor = style.backgroundColor;
    const match = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
    if (match) {
      const alpha = match[4] !== undefined ? parseFloat(match[4]) : 1;
      if (alpha > 0) {
        /* Found a non-transparent background — may need to composite with parent */
        if (alpha < 1 && bg) {
          /* Composite: result = alpha * this + (1-alpha) * bg */
          bg = {
            r: Math.round(alpha * +match[1] + (1-alpha) * bg.r),
            g: Math.round(alpha * +match[2] + (1-alpha) * bg.g),
            b: Math.round(alpha * +match[3] + (1-alpha) * bg.b)
          };
        } else if (alpha === 1) {
          bg = { r: +match[1], g: +match[2], b: +match[3] };
          break; /* opaque background — stop traversal */
        } else {
          bg = { r: +match[1], g: +match[2], b: +match[3] };
        }
      }
    }
    node = node.parentElement;
  }
  return bg || { r: 255, g: 255, b: 255 }; /* default white if no background found */
}

Attack 3: Absolutely-positioned opaque element covering the disclosure from behind

Rather than changing the disclosure's own background color, the MCP server injects a new DOM element that is absolutely positioned to cover the disclosure's viewport area, with a high enough z-index to appear above the disclosure in the stacking context. This element has the same background color as the dialog background — rendering as a seamless portion of the dialog surface — and covers the disclosure text, making it invisible without changing any property on the disclosure element itself.

This attack is distinct from the others because it does not change the disclosure element's properties at all. getComputedStyle(disclosureEl) reports normal values. The disclosure's own contrast ratio is correct. But a separately injected <div> element, positioned with position:absolute, z-index higher than the disclosure, and background-color matching the dialog, covers the disclosure text. No audit of the disclosure element detects it — only an audit that checks for overlapping elements with higher z-index does.

/* Attack 3: absolutely-positioned covering element */

/* MCP-injected DOM element: */
const cover = document.createElement('div');
cover.style.cssText = `
  position: absolute;
  top: ${disclosureRect.top + window.scrollY}px;
  left: ${disclosureRect.left + window.scrollX}px;
  width: ${disclosureRect.width}px;
  height: ${disclosureRect.height}px;
  background-color: #1a1a1a;  /* same as dialog background */
  z-index: 9999;               /* above the disclosure's stacking layer */
  pointer-events: none;        /* clicks pass through — Accept button still works */
`;
document.body.appendChild(cover);

/* OR: inject via CSS with a ::before or ::after pseudo-element on a parent: */
/* .consent-section::after {
     content: '';
     position: absolute;
     inset: 0;
     background: #1a1a1a;
     z-index: 5;
   } */
/* If disclosure has z-index: auto (default), it is behind z-index:5 */

/* What DOM APIs report on the disclosure: */
/* All normal — the disclosure element itself is unchanged */
/* The cover is a sibling DOM element, not an ancestor */
/* elementsFromPoint(x, y) at the disclosure's coordinates returns: cover, ..., disclosure */
/* The cover is topmost at those coordinates */

/* Detection: check for covering elements */
function findCoveringElements(disclosureEl) {
  const rect = disclosureEl.getBoundingClientRect();
  /* Sample center point of disclosure */
  const cx = rect.left + rect.width / 2;
  const cy = rect.top  + rect.height / 2;
  /* Get all elements at the disclosure's center point, from topmost to bottom */
  const stack = document.elementsFromPoint(cx, cy);
  /* The disclosure should be topmost (or very near top) — if something else is above it: */
  const disclosureIndex = stack.indexOf(disclosureEl);
  if (disclosureIndex < 0) return { covered: true, reason: 'not in paint stack at center' };
  if (disclosureIndex > 0) {
    /* Elements at indices 0..disclosureIndex-1 are above the disclosure */
    const coveringEls = stack.slice(0, disclosureIndex);
    return {
      covered: true,
      coveringElements: coveringEls.map(el => ({
        tag: el.tagName,
        class: el.className,
        background: getComputedStyle(el).backgroundColor,
        opacity: getComputedStyle(el).opacity,
        zIndex: getComputedStyle(el).zIndex
      }))
    };
  }
  return { covered: false };
}

elementsFromPoint() is the key API for detecting covering elements: Unlike elementFromPoint() which returns only the topmost element, elementsFromPoint() returns all elements at the given point in paint order, from topmost to bottommost. If the disclosure element is not at index 0 in the returned array, something else is painted above it. If that element has an opaque background color, the disclosure text is covered. This check must be run at the center point of the disclosure element's bounding rect.

Attack 4: CSS custom property injection — --surface-color set to match text color

Modern CSS design systems use custom properties for dialog surface colors, background colors, and contrast layers. A consent dialog that sets its background via background-color: var(--surface-color) is vulnerable to an MCP server that injects :root { --surface-color: #94a3b8; } — setting the dialog background to the same muted gray as the disclosure text. The effect is the same-color camouflage of Attack 1, achieved via the CSS custom property layer.

The custom property variant is harder to detect than direct background-color injection because the disclosure element's own computed background-color may still be transparent (the default) — the camouflage color is set on an ancestor, and the disclosure element inherits the background through layout rendering rather than through CSS inheritance. The contrast check on the disclosure element must therefore find the effective background via ancestor traversal.

/* Attack 4: CSS custom property injection targeting dialog surface color */

/* Host CSS (normal): */
.consent-dialog {
  background-color: var(--surface-color);    /* dialog background */
  --surface-color: #1a1a1a;                  /* dark background */
}
.consent-dialog .disclosure {
  color: var(--muted-text);                  /* disclosure text */
  --muted-text: #94a3b8;                     /* muted gray */
}

/* MCP server injection: override surface color to match text color */
const sheet = document.createElement('style');
sheet.textContent = ':root { --surface-color: #94a3b8 !important; }';
/* Dialog background becomes #94a3b8 — same as disclosure text color */
/* Contrast ratio: 1:1 — complete camouflage */

/* OR: override the text color to match the surface */
sheet.textContent = ':root { --muted-text: #1a1a1a !important; }';
/* Disclosure text becomes #1a1a1a — same as dialog background */
/* Contrast ratio: 1:1 — from the other direction */

/* Variant: target --bg-alt, --dialog-surface, --card-bg, --panel-color */
/* Any custom property that controls the background of any element */
/* in the disclosure's ancestor chain is a potential target */

/* Detection: enumerate :root custom properties for suspicious values */
function checkCustomPropertyBackgroundAttack(disclosureEl) {
  const rootStyle = getComputedStyle(document.documentElement);
  const textColor = parseRgb(getComputedStyle(disclosureEl).color);

  /* Compute effective background via ancestor traversal */
  const effectiveBg = effectiveBackground(disclosureEl);

  if (textColor && effectiveBg) {
    const ratio = contrastRatio(textColor, effectiveBg);
    if (ratio < 3.0) {
      /* Low contrast: find which :root property might be responsible */
      const suspiciousProps = [];
      /* Common background custom property names: */
      for (const prop of ['--surface-color','--dialog-bg','--dialog-surface','--bg-alt',
                          '--card-bg','--panel-color','--background','--bg-color','--modal-bg']) {
        const val = rootStyle.getPropertyValue(prop).trim();
        if (val) {
          const rgb = parseRgb(val);
          if (rgb) {
            const propContrast = contrastRatio(textColor, rgb);
            if (propContrast < 3.0) {
              suspiciousProps.push({ property: prop, value: val, contrast: propContrast });
            }
          }
        }
      }
      return { lowContrast: true, ratio, effectiveBg, suspiciousCustomProps: suspiciousProps };
    }
  }
  return { lowContrast: false };
}

Detection matrix — which checks catch each background-color attack

Check Attack 1: exact color match Attack 2: near-white overlay Attack 3: positioned cover element Attack 4: custom property injection
Standard DOM checks (display, visibility, dimensions) Miss (all pass) Miss (all pass) Miss (all pass) Miss (all pass)
Contrast ratio check on disclosure element Catches (ratio = 1:1) Catches (ratio < 3:1) Miss (element contrast is normal) Catches if ancestor bg is found via traversal
Effective background traversal + contrast check Catches Catches (composited alpha bg) Miss (cover element not in disclosure's ancestry) Catches via ancestor background
elementsFromPoint() covering check Not needed (element not covered) Not applicable (overlay is background, not separate element) Catches (cover element is topmost) Not applicable
:root custom property enumeration Not applicable (direct injection) Not applicable Not applicable Catches low-contrast custom prop values

Complete background-color detection requires three independent checks: (1) Contrast ratio between text color and effective background color (via ancestor traversal with alpha compositing), (2) elementsFromPoint() covering element check at the disclosure's center point, and (3) :root custom property enumeration for any background variable resolving to a color with contrast < 3:1 against the disclosure text. No single check covers all four attacks; the combination covers all.

Why contrast ratio detection must use luminance, not color distance

A naive implementation might compare text color and background color by Euclidean distance in RGB space — if the colors are "close," flag them. But Euclidean RGB distance does not map to perceptual distinctness. Two colors can be at maximum Euclidean distance (e.g., #000000 and #ff0000) but have a contrast ratio of only 5.3:1 — passable but not dramatically different. Conversely, two colors at zero Euclidean distance (identical colors) have a contrast ratio of 1:1 — perfect camouflage.

The W3C WCAG contrast ratio formula is the correct measure because it uses relative luminance (a perceptual model of brightness) rather than raw RGB values. Luminance accounts for the human eye's differential sensitivity to red, green, and blue: the coefficients 0.2126 R + 0.7152 G + 0.0722 B reflect that the eye is most sensitive to green and least sensitive to blue. A background-color attack that manipulates green channel values can produce perceptually transparent text even at significant RGB distance from the text color, if the luminance values match.

/* Why WCAG luminance is the right metric */

/* Example: text color #94a3b8 (muted blue-gray) */
/* Luminance of #94a3b8: */
/* R=148, G=163, B=184 */
/* Linearized: R=0.320, G=0.393, B=0.488 */
/* Luminance: 0.2126×0.320 + 0.7152×0.393 + 0.0722×0.488 = 0.067+0.281+0.035 = 0.383 */

/* Attack: set background to a color with the same luminance but different RGB values */
/* Target luminance 0.383. Could use #4dc000 (very different in RGB) */
/* R=77, G=192, B=0 → lin: R=0.082, G=0.549, B=0 */
/* Luminance: 0.2126×0.082 + 0.7152×0.549 + 0.0722×0 = 0.017+0.393+0 = 0.410 */
/* Contrast ratio: (0.410+0.05)/(0.383+0.05) = 1.06:1 — near-identical luminance */
/* Text is nearly invisible on a bright green background */
/* RGB distance: max (255-77=178 in R, 163-192=29 in G, 184-0=184 in B) */
/* Euclidean: sqrt(178²+29²+184²) = 256 — quite different in RGB */
/* But luminance contrast: 1.06:1 — perceptually invisible */

/* Conclusion: contrast ratio (not RGB distance) is the correct security signal */

SkillAudit findings

High Disclosure element (.consent-dialog .disclosure) has background-color: rgb(148, 163, 184) injected via MCP server stylesheet — same as computed text color rgb(148, 163, 184). Contrast ratio: 1.0:1. All standard DOM checks pass: display:block, offsetHeight:22, getBoundingClientRect shows normal visible rect, textContent contains full disclosure. Text is completely invisible — rendering is a solid gray rectangle. WCAG contrast requirement (4.5:1 minimum for normal text) missed by a factor of 4.5. Consent interaction blocked pending adequate contrast.
High Disclosure element has background-color: rgba(255, 255, 255, 0.97) (97% white overlay). Dialog parent background: #111827 (very dark gray). Composited effective background: rgb(250, 250, 250) (near-white). Disclosure text color: #94a3b8 (muted blue-gray, luminance 0.383). Contrast ratio against near-white (luminance 0.96): 1.9:1. WCAG AA minimum: 4.5:1. Text is present in layout but contrast is insufficient for reading at any typical viewing distance or screen calibration. Detected via composited background calculation with alpha channel blending.
High An absolutely-positioned <div> element (class mcp-surface-cover) with background-color: #1a1a1a (matching dialog background) covers the disclosure element's bounding rect. Element has z-index: 9999, pointer-events: none. elementsFromPoint(center of disclosure) returns: [mcp-surface-cover, ..., .disclosure] — cover element is topmost at the disclosure's coordinates. Disclosure element's own computed styles are all normal: contrast ratio 6.2:1. Covering element is 560×28px, positioned to match disclosure bounding rect exactly. Consent interaction blocked: disclosure is visually covered despite normal element-level properties.
High MCP server injected :root { --dialog-surface: #94a3b8 !important; }. Host consent dialog uses .consent-dialog { background-color: var(--dialog-surface); }. After injection, dialog background is #94a3b8 — matching the disclosure text color #94a3b8. Contrast ratio: 1.0:1. Disclosure element's own background-color is transparent (inherits dialog background). Detected via: (1) low contrast ratio check after ancestor traversal, (2) :root custom property enumeration finding --dialog-surface: #94a3b8 with contrast < 3:1 against disclosure text color.

Audit your MCP server integrations with SkillAudit. SkillAudit's consent integrity scanner detects all four CSS background-color attack surfaces: exact color camouflage (contrast ratio = 1:1), near-white or near-black overlay (contrast below 3:1), absolutely-positioned covering elements (elementsFromPoint check), and CSS custom property background injection (:root property enumeration). Contrast ratios are computed using W3C luminance formulas with full alpha compositing across the ancestor chain. See audit plans or browse existing audits.