takeaseat

When an SSRF check cannot resolve a hostname, “allow and let the browser fail” is not a safe fallback.

A URL-rendering service usually performs two separate actions:

  1. resolve the hostname to decide whether the destination is private; and
  2. let a browser fetch the page, including redirects and subresources.

If DNS resolution fails and the guard returns “not blocked”, the decision is fail-open. A later retry, a resolver change, or a rebinding window can produce an address the original check never approved. The browser request may also be a different URL from the one checked, so the guard has to run at the request boundary too.

The safe default is small and boring:

const dns = require('node:dns');

async function isBlockedSSRF(targetUrl) {
  const parsed = new URL(targetUrl);
  if (!['http:', 'https:'].includes(parsed.protocol)) return 'blocked_scheme';

  const host = parsed.hostname.toLowerCase();
  if (isPrivateIp(host)) return 'private_ip';

  try {
    const addresses = await dns.promises.lookup(host, { all: true });
    for (const address of addresses) {
      if (isPrivateIp(address.address)) return 'resolved_private';
    }
  } catch {
    // Unknown is not public. Fail closed.
    return `dns_resolution_failed:${host}`;
  }

  return null;
}

Enter fullscreen mode Exit fullscreen mode

That function is necessary but not sufficient. In a Playwright renderer, route every browser request (**/*), not only the initial page. Apply the same check to images, stylesheets, iframes, fetches, and redirect targets. Block link-local and metadata addresses, IPv4-mapped IPv6 forms, unspecified/local IPv6, and carrier-grade NAT ranges. Keep the URL length and scheme allowlists before DNS work.

The regression test should force the resolver to throw, then assert a block reason—not merely assert that the function does not crash. In the implementation I worked on, the production helper was separated from a copied test fixture, the forced-DNS-failure case was added, and the security suite ended at 21 passed and 0 failed. The deployed container was then checked as a non-root node process; its direct origin, independent VM-WAN path, and public edge health all returned HTTP 200 after the change.

There is a trade-off: fail-closed DNS behavior can reject legitimate renders during resolver incidents. That is an availability cost, but it is the correct side of the security boundary for a service that fetches attacker-selected URLs. Operators can add retry/backoff or an explicit, audited allowlist later; silently treating an unknown destination as public is not a recovery strategy.

If you are building a URL-to-document service, the practical checklist is:

  • reject non-HTTP(S) schemes before DNS;
  • reject private and special-use IPs in every address family;
  • fail closed on resolver errors and empty answers;
  • re-check every browser request and redirect;
  • test the real helper used in production;
  • verify the image contains the helper after deployment.

For a live example of the managed PDF API discussed here, see https://pdffleet.com/docs