Using Custom HTTP Headers Safely in Screenshot APIs
Send custom headers to screenshot targets without leaking credentials or creating an SSRF hole. Includes Playwright, Puppeteer, validation, and fixes.
Direct answer: Treat custom headers as a page-wide security policy. Validate the destination before navigation, allow only a documented header subset, keep your screenshot service key separate from target-site credentials, enforce HTTPS and an allowlist, reject private and metadata IP ranges, and re-check every redirect. Render in a disposable browser context with bounded time, memory, requests and egress.
Playwright and Puppeteer attach extra headers to requests initiated by the page. That is broader than one API call: images, scripts, stylesheets, XHR and fetch requests can receive the headers too. Playwright requires header values to be strings; Puppeteer lowercases header names and does not guarantee their order. Design headers as an origin-scoped policy, not a convenience parameter.
Threat model: what can go wrong
- Credential leakage: an Authorization or Cookie header intended for one tenant can reach a third-party origin, an image host or a redirect target.
- SSRF: an attacker submits a URL that resolves to loopback, RFC1918, link-local, multicast or cloud metadata space.
- Redirect escape: a public URL redirects to an internal host after your initial validation.
- DNS rebinding: the hostname passes one DNS check and resolves somewhere else when Chromium connects.
- Parser disagreement: different URL parsers interpret credentials, encoded hosts, ports or schemes differently.
- Resource exhaustion: a page downloads huge responses, opens excessive connections or never reaches a stable state.
OWASP recommends positive allowlists because deny-lists are bypass-prone. A screenshot endpoint is an SSRF boundary even when it only returns an image. Read the OWASP SSRF Prevention Cheat Sheet and the Puppeteer security guidance before exposing this feature to untrusted callers.
Define a safe header contract
Write down exactly which caller-controlled headers are accepted. A typical contract allows a tenant-specific preview token and a correlation ID, while rejecting credentials that could be replayed elsewhere.
| Header class | Default | Reason |
|---|---|---|
| Correlation or trace ID | Allow after length and character checks | Useful for observability and usually low risk |
| Tenant preview token | Allow only for an allowlisted origin | Scope the token to one tenant and destination |
| Authorization | Reject by default | Easy to leak across subresources or redirects |
| Cookie | Reject by default | Cookies are origin-sensitive and often long-lived |
| Connection, Host, TE, Trailer, Upgrade | Always reject | Hop-by-hop or connection-management fields |
| Headers containing control characters | Reject | Prevents request smuggling and parser confusion |
Reject duplicate representations, oversized names or values, empty names, invalid token characters and newline characters. Keep the screenshot service API key on its own authentication path. Never copy it into target-page headers.
Validate the destination before launching Chromium
- Parse the URL once with a standards-compliant URL implementation.
- Permit
httpsonly unless a controlled exception is documented. - Prefer an allowlist of tenant-owned hosts or fixed destinations.
- Resolve both A and AAAA records and reject loopback, link-local, RFC1918, multicast, unspecified and cloud metadata ranges.
- Reject userinfo, unexpected ports and alternate encodings of the host.
- Pin or re-check the resolved address at connection time where your network stack permits it.
Do not rely on a string prefix such as startsWith('https://trusted.example'). Validate the parsed hostname, scheme and port. A hostname check alone does not stop DNS rebinding.
Revalidate every redirect
The initial URL is not enough. If redirects are disabled, fail closed when a redirect response appears. If redirects are required, inspect each Location, parse it, apply the same scheme, host, port, DNS and IP rules, and count redirects. Strip sensitive headers on every cross-origin hop unless the destination is explicitly authorized for that credential.
Playwright: a constrained implementation
The following Node.js example validates an allowlisted host, accepts only two caller headers, blocks unsafe response types, limits navigation time and captures a screenshot. Replace the example host and token lookup with your own tenant policy.
import { chromium } from 'playwright';
import dns from 'node:dns/promises';
const allowedHosts = new Set(['preview.example.com']);
const allowedHeaders = new Set(['x-preview-token', 'x-request-id']);
function validHeaderValue(value) {
return typeof value === 'string' && value.length <= 2048 && !/[\r\n\u0000-\u001f\u007f]/.test(value);
}
async function validateTarget(raw) {
const url = new URL(raw);
if (url.protocol !== 'https:' || url.username || url.password) throw new Error('Only credential-free HTTPS URLs are allowed');
if (url.port && url.port !== '443') throw new Error('Unexpected port');
if (!allowedHosts.has(url.hostname.toLowerCase())) throw new Error('Destination is not allowlisted');
const answers = await dns.lookup(url.hostname, { all: true });
for (const answer of answers) {
const ip = answer.address;
if (/^(127\\.|10\\.|192\\.168\\.|169\\.254\\.|172\\.(1[6-9]|2[0-9]|3[0-1])\\.|::1$|fc|fd|fe80)/i.test(ip)) {
throw new Error('Destination resolves to a private or link-local address');
}
}
return url;
}
export async function capture(rawUrl, suppliedHeaders) {
const target = await validateTarget(rawUrl);
const headers = {};
for (const [name, value] of Object.entries(suppliedHeaders ?? {})) {
const lower = name.toLowerCase();
if (!allowedHeaders.has(lower) || !validHeaderValue(value)) throw new Error(`Header rejected: ${name}`);
headers[lower] = value;
}
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ extraHTTPHeaders: headers, acceptDownloads: false });
const page = await context.newPage();
page.setDefaultNavigationTimeout(15000);
page.setDefaultTimeout(10000);
page.on('response', response => {
const type = response.request().resourceType();
if (['media', 'font'].includes(type)) return;
if (response.status() >= 500) console.warn('upstream failure', response.status(), new URL(response.url()).hostname);
});
try {
await page.goto(target.href, { waitUntil: 'domcontentloaded' });
return await page.screenshot({ type: 'png', fullPage: true });
} finally {
await context.close();
await browser.close();
}
}
const image = await capture('https://preview.example.com/', {
'X-Preview-Token': process.env.PREVIEW_TOKEN,
'X-Request-ID': 'job-123'
});
See the Playwright network documentation and screenshot options for the supported capture controls.
Puppeteer: equivalent controls
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: 'new', args: ['--disable-dev-shm-usage'] });
const page = await browser.newPage();
await page.setExtraHTTPHeaders({
'x-preview-token': process.env.PREVIEW_TOKEN,
'x-request-id': 'job-123'
});
await page.setRequestInterception(true);
page.on('request', request => {
const url = new URL(request.url());
if (url.protocol !== 'https:' || !['preview.example.com'].includes(url.hostname)) {
return request.abort();
}
if (['font', 'media'].includes(request.resourceType())) return request.abort();
return request.continue();
});
try {
await page.goto('https://preview.example.com/', { waitUntil: 'domcontentloaded', timeout: 15000 });
await page.screenshot({ path: 'shot.png', fullPage: true, type: 'png' });
} finally {
await browser.close();
}
Puppeteer lowercases header names and does not guarantee ordering. Compare names case-insensitively and do not build security decisions around header order.
Isolation and network limits
- Use a disposable browser context or worker for each job or tenant.
- Run without sensitive filesystem mounts or ambient cloud credentials.
- Apply CPU, memory, page-size, request-count and total-duration limits.
- Disable downloads and unnecessary schemes such as
file:,data:and custom protocols. - Control egress at the container or VPC layer; browser code is not a substitute for firewall policy.
- Record request ID, destination host, resolved IP class, redirect count, duration and failure reason, never raw credentials or secret-bearing URLs.
Options that affect header safety
| Option | Safer default | When to change it |
|---|---|---|
| Redirects | Disable or validate each hop | Only when the business flow requires redirects |
| Header scope | Allowlist by exact origin | Broaden only for documented tenant domains |
| Navigation wait | domcontentloaded plus a hard timeout |
Use network idle only for pages that settle reliably |
| Resource types | Block downloads, media and unneeded fonts | Allow when the visual result depends on them |
| Cookies | Fresh context with no inherited cookies | Inject short-lived, origin-scoped cookies deliberately |
| User agent | Fixed, documented value | Change only for a known compatibility requirement |
Observability without secret leakage
Log policy decisions, not payloads. A useful event contains a job ID, normalized destination hostname, resolved address class, selected header names (not values), redirect count, elapsed time, response verdict and reason. Redact Authorization, Cookie, API keys, preview tokens and query parameters that contain secrets. Alert on private-IP rejections, repeated redirect escapes, unusual header names and jobs that exceed resource limits.
Performance, reliability and cost
- Browser startup dominates small jobs; reuse a controlled browser process while creating a fresh context per job.
- Full-page screenshots and lazy-loaded images increase transfer and rendering time. Set a maximum page height or use an element capture when possible.
- Network-idle waits are fragile on analytics-heavy sites. Prefer a known selector or bounded delay when the page has a stable readiness signal.
- Cache only responses that are safe to reuse. Include destination, viewport, relevant headers and a policy version in the cache key; never cache personalized pages under a public key.
- Retry only transient navigation failures, with a small limit and backoff. Do not retry policy rejections or authentication failures.
- Cost includes browser CPU, memory, bandwidth and operations. Blocking unnecessary resources and enforcing limits protects both latency and spend.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| 401 or 403 from the page | Header missing, malformed or sent to the wrong origin | Confirm the exact allowlisted origin, string values and token scope; do not add the service API key |
| Header appears on an image or script request | Extra headers are page-wide | Use an origin policy and strip sensitive headers for subresources or other origins |
| Navigation reaches an internal address | Redirect or DNS rebinding bypassed the first check | Revalidate every redirect and resolve addresses at connection time; enforce egress firewall rules |
| Playwright rejects a header value | Value is not a string or contains control characters | Convert trusted values to strings and reject CR, LF, NUL and other controls |
| Puppeteer comparison fails by case | Names were lowercased | Normalize names before comparison |
| Screenshot times out | Never-ending requests or an overly strict readiness condition | Use a hard timeout, a specific selector or bounded delay; block unnecessary resources |
| Blank or partial image | Page error, blocked asset or capture before layout settled | Capture response status, wait for a known element and inspect blocked resource types |
| Secrets appear in logs | Raw headers or full URLs were logged | Redact values and secret-bearing query parameters; log only names and policy outcomes |
Or skip the browser setup
ScreenshotNeo provides a hosted screenshot API and MCP server. It accepts custom headers while handling the browser infrastructure, and its other capture controls include full-page and element shots, device and viewport settings, cookies, user agent, Authorization, waits, request blocking, caching and signed links. Read the ScreenshotNeo API documentation for the header and policy parameters.
curl -G 'https://api.screenshotneo.com/v1/shot' \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
import requests
r = requests.get('https://api.screenshotneo.com/v1/shot', params={'access_key': 'YOUR_API_KEY', 'url': 'https://stripe.com'}, timeout=90)
r.raise_for_status()
open('shot.webp', 'wb').write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
await Bun.write('shot.webp', res);
Cookie banners, newsletter popups and chat widgets are removed before the shot. Bot checks, blank pages, failed loads and cache hits are never billed, and response headers identify the page verdict and billing result. An MCP server lets Claude, Cursor and other MCP clients take screenshots with take_screenshot, inspect pages with get_page_info and create PDFs with capture_pdf. The Free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
Security checklist
- Destination uses HTTPS and matches an exact allowlist.
- A and AAAA resolutions are checked against private, link-local, multicast and metadata ranges.
- Redirects are disabled or revalidated at every hop.
- Only documented, origin-scoped headers are accepted.
- Authorization, cookies and service keys follow separate code paths.
- Browser context, filesystem, egress, time, memory and response sizes are bounded.
- Logs contain policy metadata, never secret values.
- Retries are limited to transient failures.
FAQ
Are custom headers limited to the main document?
No. Playwright and Puppeteer apply extra headers to requests initiated by the page, so subresources can receive them too.
Can I validate a URL once and trust the browser?
No. Redirects and DNS changes can move the request after validation. Re-check each redirect and enforce network egress controls.
Should I forward my screenshot API key as Authorization?
No. Keep service authentication separate from credentials intended for the target site.
Is a deny-list of private IPs sufficient?
No. Prefer an allowlist of exact schemes, ports and hosts, then reject private and special-use resolutions as an additional control.
When is a hosted API preferable?
Use one when you need repeatable rendering, isolation and operational controls without maintaining Chromium workers. Verify its header scoping, redirect behavior, SSRF protections and billing semantics before sending authenticated pages.


