Skip to content

Search docs

Jump between documentation pages.

Browse docs

SSRF guard (fetchGuard)

Think of it like…a corporate firewall on your office laptop. You can still browse the public internet, but the firewall won't let you dial the building's admin console at 10.0.0.5: even if a phishing email tells you to. SSRF is the exact same trick aimed at your server: an attacker gives your code a URL, hoping it'll quietly fetch your own internal admin panel or the cloud provider's metadata endpoint. fetchGuard()is the firewall.

Any handler that calls fetch()on a URL the user can influence (an avatar fetch, a webhook delivery, an “import from URL” feature, an OAuth discovery endpoint, an embed unfurler) is a Server-Side Request Forgery (SSRF) sink. The canonical exploit is the Aikido write-up in which a contact form that emailed an avatar was redirected to http://169.254.169.254/, the AWS cloud metadata service, which handed back short-lived IAM credentials and pivoted into the startup’s S3 buckets.

What every guarded fetch goes through
  1. urlCheck protocolhttp: / https: only
  2. dnsResolve hostnameto one or more IPs
  3. ipMatch deny rangesRFC1918, loopback, 169.254.x
  4. safeDispatch requestredirects re-validated per hop
A request only leaves the box after the protocol, the resolved IPs, and every redirect Location pass the deny floor. Anything that resolves to an internal or metadata address throws SsrfBlockedError instead of being sent.

fetchGuard() wraps the global fetch and refuses to dispatch a request whose target resolves to a dangerous internal address, including every documented cloud metadata IP (AWS / Azure / DigitalOcean 169.254.169.254, Oracle Cloud 192.0.0.192, Alibaba 100.100.100.200).

Quick start

ts
import { App, fetchGuard, SsrfBlockedError } from "@daloyjs/core";
import { z } from "zod";

const app = new App();
const safeFetch = fetchGuard();

app.route({
  method: "POST",
  path: "/import",
  operationId: "importFromUrl",
  request: { body: z.object({ url: z.url() }) },
  responses: {
    200: { description: "ok" },
    422: { description: "bad url or refused: ssrf" },
  },
  handler: async ({ body }) => {
    const { url } = body;
    try {
      const upstream = await safeFetch(url);
      const body = await upstream.text();
      return { status: 200 as const, body };
    } catch (err) {
      if (err instanceof SsrfBlockedError) {
        return { status: 422 as const, body: { reason: err.reason } };
      }
      throw err;
    }
  },
});

What gets blocked by default

  • Loopback: 127.0.0.0/8, ::1. Opt in with allowLoopback: true for local-dev fixtures.
  • RFC1918 private: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. Opt in with allowPrivate: true.
  • Link-local (covers every cloud-metadata IP): 169.254.0.0/16, fe80::/10. Opt in with allowLinkLocal: true.
  • IPv6 unique-local: fc00::/7. Opt in with allowUniqueLocal: true.
  • Always-deny floor (no flag lifts these): 0.0.0.0/8, 100.64.0.0/10 (CGNAT, Alibaba metadata), 192.0.0.0/24 (Oracle Cloud metadata), all IANA-reserved TEST-NET / benchmarking / docs ranges, 224.0.0.0/4 multicast,240.0.0.0/4 reserved, broadcast 255.255.255.255, IPv6 ::/128 and ff00::/8.
  • Protocols other than http: / https: (file:, data:, gopher:, ftp:, dict:, ldap:).

IPv4-mapped IPv6 (::ffff:a.b.c.d) is re-checked against the embedded IPv4 address, so http://[::ffff:169.254.169.254]/ is rejected the same way as http://169.254.169.254/.

Redirects are re-validated at every hop

A common SSRF bypass is to return 302 Location: http://169.254.169.254/ from a public host. fetchGuard() follows redirects manually: it re-checks the protocol and re-resolves DNS for every Location header before issuing the next request. Set maxRedirects: 0 to return the 3xx directly, or pass redirect: "manual" per call for the same effect.

Custom allowlists

ts
const safeFetch = fetchGuard({
  // IP / CIDR allowlist (overrides the deny defaults).
  allowAddresses: ["198.51.100.0/24", "2001:db8::/32"],
  // Hostname allowlist (skips DNS check entirely; useful for known internal services).
  allowHosts: ["api.example.com", "billing.internal"],
  // Extra deny matchers on top of the floor.
  denyAddresses: ["10.6.6.0/24"],
  // Permit loopback for local-dev fixtures only.
  allowLoopback: process.env.NODE_ENV !== "production",
});

Custom DNS resolution (non-Node runtimes)

The default resolver uses Node’s node:dns/promises.lookup(). On Cloudflare Workers, Deno without --allow-net, or any runtime without Node-style DNS, supply a resolver:

ts
const safeFetch = fetchGuard({
  resolve: async (host) => {
    const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${host}&type=A`, {
      headers: { accept: "application/dns-json" },
    });
    const json = (await res.json()) as { Answer?: Array<{ data: string }> };
    return (json.Answer ?? []).map((a) => a.data);
  },
});

Residual risk: DNS rebinding (TOCTOU)

The guard resolves the hostname once and validates every returned address, but between that resolution and the underlying TCP connect, an attacker who controls the authoritative DNS (TTL=0) could change the answer. We close this at two layers, both opt-in:

  1. Operator-side (recommended). Run behind a network policy that already blocks egress to RFC1918 / metadata IPs: Kubernetes NetworkPolicy, step-security/harden-runner in CI, iptables -A OUTPUT -d 169.254.169.254 -j DROP on the host. This neutralises rebinding even if the app is naive.
  2. Caller-side, Node-only. Daloy ships zero runtime dependencies, so we do not bundle undici. If you install it yourself, you can pin the socket to the IP you validated by plumbing a custom dispatcher through the existing fetch option:
    ts
    import { fetchGuard } from "@daloyjs/core";
    import { Agent, fetch as undiciFetch } from "undici";
    import * as dns from "node:dns/promises";
    
    const safeFetch = fetchGuard({
      fetch: async (input, init) => {
        const url = new URL(typeof input === "string" ? input : input.url);
        const { address, family } = await dns.lookup(url.hostname, { verbatim: true });
        const dispatcher = new Agent({
          connect: { lookup: (_h, _o, cb) => cb(null, address, family) },
        });
        return undiciFetch(input, { ...init, dispatcher });
      },
    });
    The socket connects to the pre-resolved IP; TLS SNI and certificate validation still use the original hostname.

fetchGuard() remains defense-in-depth on top of these controls.

Error shape

Blocked requests throw SsrfBlockedError with a structured reason:

  • protocol-not-allowed: URL was file:, data:, etc.
  • address-not-allowed: resolved IP fell in a blocked range.
  • dns-resolution-failed: lookup threw or returned no records.
  • too-many-redirects: chain exceeded maxRedirects.
  • invalid-url: URL or Location header could not be parsed.

Network failures from the underlying fetch(DNS timeouts, TLS errors, connection refused) bubble through unchanged so your retry logic can distinguish “Daloy refused” from “the upstream is sad.”