craftory

My SSRF guard blocked 127.0.0.1 and almost nothing else

Cartify fetches URLs that strangers paste in. Here is every hole my first SSRF guard missed, from bracketed IPv6 to redirects, and how each one got closed.

I built Cartify, a tool that takes a URL from a stranger and fetches it server-side. Paste a link to a recipe, the server reads the page, pulls out the ingredients, and turns each one into a grocery search link.

That is the most dangerous shape a feature can have. A server that fetches arbitrary user-supplied URLs is a proxy into its own network, and mine runs on a cloud host where 169.254.169.254 hands out credentials to anyone who asks.

So I wrote an SSRF guard early, felt good about it, and shipped.

It was close to useless. Here is everything it missed, in the order I found it.

The guard I started with

export function isPublicUrl(urlString: string): boolean {
  try {
    const parsed = new URL(urlString);
    const hostname = parsed.hostname.toLowerCase();

    if (
      hostname === "localhost" ||
      hostname === "127.0.0.1" ||
      hostname === "0.0.0.0" ||
      hostname === "::1" ||
      hostname.endsWith(".localhost") ||
      hostname.endsWith(".local") ||
      hostname.endsWith(".internal")
    ) {
      return false;
    }

    const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
    if (ipv4Match) {
      const a = parseInt(ipv4Match[1], 10);
      const b = parseInt(ipv4Match[2], 10);
      if (a === 10) return false;
      if (a === 127) return false;
      if (a === 0) return false;
      if (a === 169 && b === 254) return false;
      if (a === 192 && b === 168) return false;
      if (a === 172 && b >= 16 && b <= 31) return false;
    }

    return true;
  } catch {
    return false;
  }
}

Reads fine. Blocks localhost, blocks RFC1918, blocks the metadata endpoint. Tests passed.

Finding out what it actually did

Rather than reason about it, I ran the real function against a list of things I expected it to block:

PASSES   http://[::1]/
PASSES   http://[::ffff:127.0.0.1]/
PASSES   http://[fd00::1]/
PASSES   http://[fe80::1]/
PASSES   http://127.0.0.1.nip.io/
blocked  http://169.254.169.254/
blocked  http://10.0.0.5/
blocked  http://2130706433/

Five of eight walked straight through.

I recommend this exercise on your own code. Not reading it, not unit-testing the cases you thought of — running it against a list of things that should obviously fail. It takes ten minutes and it is humbling.

Bug 1: the IPv6 check that could never fire

hostname === "::1"

This line can never be true.

WHATWG URL keeps the brackets on IPv6 literals:

new URL("http://[::1]/").hostname   // "[::1]"  — not "::1"

So the comparison fails and IPv6 loopback passes. I had written the check, seen it in the diff, and never verified it matched the value the parser actually produces.

That is the general lesson, and it is worth more than the specific bug: a security check that silently never matches looks identical to one that works. Nothing errors. Coverage still says the line ran.

Bug 2: no IPv6 coverage at all

Once the bracket problem was fixed, there was still only one IPv6 address in the list. Missing:

  • fc00::/7 — unique local, the IPv6 equivalent of RFC1918
  • fe80::/10 — link-local
  • :: — unspecified
  • ::ffff:0:0/96 — IPv4-mapped

That last one has a twist. Node normalises the mapped form to hex:

new URL("http://[::ffff:127.0.0.1]/").hostname   // "[::ffff:7f00:1]"

So a regex looking for a dotted quad inside the IPv6 literal will not find one. You have to handle the hex form too:

const hexMapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (hexMapped) {
  const high = parseInt(hexMapped[1], 16);
  const low = parseInt(hexMapped[2], 16);
  const dotted = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
  return isPrivateAddress(dotted);
}

Bug 3: hostnames that point inward

127.0.0.1.nip.io is a public hostname. It is not localhost, does not end in .local, and is not a dotted quad. Every string check passes it.

It resolves to 127.0.0.1, because that is the entire purpose of services like nip.io and sslip.io.

No amount of string inspection fixes this. You have to resolve the name and check what comes back:

const { lookup } = await import("node:dns/promises");
const resolved = await lookup(hostname, { all: true });

if (resolved.length === 0 || resolved.some((entry) => isPrivateAddress(entry.address))) {
  throw new Error("Invalid or prohibited URL.");
}

Note { all: true }. A hostname can resolve to several addresses, and checking only the first leaves an obvious gap.

Bug 4: the one that made the rest pointless

Here is the fetch, with the guard doing its job:

if (!isPublicUrl(urlString)) {
  throw new Error("Invalid or prohibited URL.");
}

const response = await fetch(urlString, {
  headers: { "User-Agent": "..." },
  signal: AbortSignal.timeout(12000),
});

fetch defaults to redirect: "follow".

So an attacker hosts https://totally-fine.example/recipe, which passes every check I have described, and answers with:

HTTP/1.1 302 Found
Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/

The guard validated the first URL. The runtime followed the redirect on its own. The server fetched the metadata endpoint and handed the body to my parser.

Every fix above was irrelevant while this was true.

The fix is to stop delegating redirects:

const MAX_REDIRECTS = 5;
let current = urlString;
let response: Response | null = null;

for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
  // Re-validate on EVERY hop, not just the first.
  await assertPublicUrl(current);

  response = await fetch(current, {
    method: "GET",
    redirect: "manual",
    signal: AbortSignal.timeout(12000),
  });

  const isRedirect = response.status >= 300 && response.status < 400;
  if (!isRedirect) break;

  const location = response.headers.get("location");
  if (!location) throw new Error("Incomplete redirect.");

  // Location may be relative.
  current = new URL(location, current).href;
  response = null;
}

Two details worth keeping: Location is allowed to be relative, so resolve it against the current URL rather than parsing it standalone. And cap the hops, or a redirect loop becomes a hang.

Bug 5: reading whatever they send

const html = await response.text();
const extracted = extractRecipeFromHtml(html);

extractRecipeFromHtml truncates to 10,000 characters. I had told myself that bounded the work.

It bounds what the parser sees. It does not bound the download. response.text() buffers the entire body first, so a URL pointing at a multi-gigabyte file exhausts memory before the truncation runs — on a serverless function with a fixed memory ceiling, that is a cheap way to knock the endpoint over.

Read the stream and stop:

const reader = response.body.getReader();
const decoder = new TextDecoder();
const chunks: string[] = [];
let total = 0;

for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  total += value.byteLength;
  chunks.push(decoder.decode(value, { stream: true }));
  if (total > MAX_RESPONSE_BYTES) break;
}
await reader.cancel().catch(() => {});

One thing that was better than I expected

I assumed obfuscated IPv4 would need handling:

new URL("http://2130706433/").hostname   // "127.0.0.1"
new URL("http://0177.0.0.1/").hostname   // "127.0.0.1"
new URL("http://127.1/").hostname        // "127.0.0.1"

WHATWG URL normalises decimal, octal and short forms for you. Those three classic bypasses are handled before your code runs — as long as you read .hostname from a parsed URL and never regex the raw string yourself.

Which is the counterpart to bug 1. The parser gives you normalisation for free, and one surprise. You need to know which is which, and the only reliable way is to print what it returns.

What is still not fixed

Between my DNS check and the runtime’s own resolution there are two lookups, so a name that answers with a public address for the first and a private one for the second — DNS rebinding — still gets through. Closing that means resolving once and connecting to the pinned IP with a custom agent, carrying the Host header yourself.

I have not done it. The threat model here is an ingredient extractor, not a bank, and I would rather state the remaining gap than imply it is airtight.

That is the part I would want to read in someone else’s post, so it is the part I am writing down.

The short version

  • Print what your parser returns before comparing against it. [::1] is not ::1.
  • IPv6 is not an edge case. Cover fc00::/7, fe80::/10, ::, and the IPv4-mapped forms including the hex rendering.
  • String checks cannot catch a hostname that resolves inward. Resolve, and check every address.
  • fetch follows redirects by default. Validate every hop or validate nothing.
  • Truncating after parsing does not bound the download.
  • Run your guard against a list of things it should reject. Do not just read it.

Where this code lives

Everything above is from Cartify — paste a recipe or a recipe link, get one-click grocery searches. Try it, no signup.

I sell the source, because the pipeline turned out not to be about food at all. It reads unstructured text, extracts a list of things, and builds affiliate search links. Swap the extraction prompt and the retailer URLs and it builds carts for PC parts, skincare routines or reading lists — that is two files.

What you get: the full Next.js 14 and TypeScript source, the URL extractor and SSRF guard from this post, the Vitest suite that covers them, region presets for India, the US and the UK, affiliate and AdSense wiring, and four setup guides. No database, deploys free. $29, or $49 with a promo video kit.

craftorytool.gumroad.com/l/dloqb

Happy to be told what I am still missing.

— Anirban Sarkar, who builds small tools at Craftory Tool

For builders

This code ships in the Cartify kit.

Next.js 14 + TypeScript source, URL extractor and SSRF guard, AI extraction on a free model, Vitest suite. $29, one-time.

See the kit