Web capture engineering

SSRF defense for screenshot APIs

A screenshot service accepts a URL and runs a network-capable browser near credentials and internal services. Validating only the first string turns the feature into an SSRF primitive after redirects, DNS changes or subresource loads.

4 min readPublished Updated
What you actually see

A public hostname reaches loopback, cloud metadata, RFC1918, Kubernetes service DNS or a custom storage/webhook endpoint that the caller could not access directly.

Reproduce it

  1. 01Test literal IPv4 and IPv6, IPv4-mapped IPv6, decimal/octal encodings and hostnames returning both public and private answers.
  2. 02Make the accepted URL redirect through multiple hosts and put the blocked address on a later hop.
  3. 03Load an image, iframe, font and XHR from a private fixture after the main public document succeeds.
The DIY version

Start with the smallest thing that works

Resolve every address, reject the whole answer set when any address is forbidden, pin navigation to the validated destination and repeat the policy at every egress boundary.

validate-egress.mts
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";

async function validateTarget(raw: string) {
  const url = new URL(raw);
  if (!new Set(["http:", "https:"]).has(url.protocol)) throw new Error("scheme blocked");

  const records = isIP(url.hostname)
    ? [{ address: url.hostname }]
    : await lookup(url.hostname, { all: true, verbatim: true });

  if (records.length === 0 || records.some(({ address }) => isPrivateOrSpecial(address))) {
    throw new Error("destination blocked");
  }
  return { url, addresses: records.map(({ address }) => address) };
}

// Production still needs canonical IP parsing, redirect revalidation,
// DNS pinning and request interception. A regex is not an IP policy.
Where it breaks

The call is small. Everything around it is the system.

Validation and connection resolve separately

If the browser performs a fresh DNS lookup after validation, an attacker can change the answer. Connect only to an address from the validated set and preserve the original Host/SNI semantics.

Redirects escape the first decision

Every hop is a new target. A public 302 endpoint is enough to reach metadata unless the navigation is paused and the destination is validated again.

The page loads its own network graph

Images, fonts, iframes, scripts, fetch and websocket requests are just as capable of reaching internal services as the main document.

Callbacks become second SSRF surfaces

Webhook URLs and S3-compatible endpoints are user-controlled egress. Applying a weaker helper there recreates the same vulnerability outside Chromium.

Keep the DIY version when

  • The renderer runs in a network namespace with no route to metadata, control-plane or private ranges and only an explicit outbound proxy is reachable.
  • Every target is application-owned and selected from a server-side allow-list rather than accepted from an untrusted caller.
The recipe

SSRF security in screenshot APIs

The live recipe demonstrates only the positive public-URL case; it is deliberately not an anonymous internal-network probe. The blocked cases live in the security corpus and run without public egress.

Capture settings
Enter the source, choose your options, then run the capture.
Result
Your capture will appear here and stay in view.
waiting

No result yet

Complete the settings and run the tool. Images, PDFs, text, and video all preview in this panel.

Send this exact request
cURL, TypeScript and Python are generated from the same configuration as the demo.
curl --fail-with-body "https://api.pagecapture.dev/v1/take" \
  -H "X-Access-Key: $PAGECAPTURE_KEY" \
  -H "Content-Type: application/json" \
  --data '{"url":"https://example.com","format":"png"}' \
  -o capture.png
What you get, and what it costs

Output, limits, cost, failures

Output

A capture only after URL, scheme, port, hostname and every resolved address pass one shared policy; redirects and browser subrequests are revalidated before leaving the process.

Cost

One successful, non-cached capture costs one credit. Failures, including platform failures, cost zero; cache hits cost zero.

1 creditper successful non-cached capture

Limits

  • Application validation is defense in depth, not a substitute for network-level egress denial.
  • `PAGECAPTURE_ALLOW_PRIVATE_TARGETS=true` disables the protection and exists only for local controlled fixtures.
  • The public error does not echo a blocked private IP, avoiding an internal-network oracle.

Common errors

network_errornever billed

The destination resolves to a blocked address, uses a blocked port or fails during connection.

HTTP 500 · retryable

name_not_resolvednever billed

DNS returns no usable address for the target.

HTTP 400 · terminal

Try it against your own page

The runner above carries this exact configuration into the playground — no retyping, no starting over.

Return to the configured runner
Tested and reviewed

Written by PageCapture Engineering. Reviewed by PageCapture security maintainers. Verified 2026-08-12 by running the published code against a fixture.

The security corpus covers private and special IPv4/IPv6 forms, encoded literals, blocked hostnames, ports and redirects; the public recipe is normalized separately and never exposes a private-target oracle.

engine-crunknown-pwunknown-f2026-07-1-b2026-07-1StatusEgress and isolation referenceEditorial method

Sources and verification basis

Sources support the browser and API behaviors named above. PageCapture-specific limits and billing are taken from the public contract; external sources are used for the underlying browser behavior.

  1. Threat model and layered SSRF mitigations.

  2. Defines the familiar private IPv4 ranges; the implementation blocks additional special-use ranges too.

  3. SecurityPageCapture

    Documents the exact shared egress policy and secret lifecycle.