SSRF in agent-written code

Any feature that fetches a URL a user typed makes your server the one knocking on the door, and your server can reach doors the user cannot.

Aevral,

In plain words

Picture a receptionist in an office building who will fetch any parcel you name. You cannot get past the front desk, but the receptionist has keys to every floor. Ask for "the parcel in room 12" and they bring it; ask for "the folder in the finance safe" and, if nobody told them otherwise, they bring that too. Server-side request forgery, SSRF, is that receptionist: your server fetches a URL a user chose, from inside your network, with your network's access.

Inside a cloud network, the most valuable door is often the metadata address, 169.254.169.254, which can hand out the server's own cloud credentials to any request that comes from the machine. Internal admin panels, databases with HTTP interfaces and services bound to localhost are the other doors.

Why agents write it: fetching a URL is one line

Link previews, webhook test buttons, import from URL, and an AI feature that reads a web page for a model all reduce to fetch(url). For an agent, that is a one-line, correct-looking solution with an obvious test: paste a public URL, get the title back. Nothing in the request says the URL might point inward, and nothing in the test does either.

The partial fixes are also agent-shaped. A check that the hostname is not localhost is easy to write and easy to get around: another spelling of the same address, a public hostname whose DNS answers with a private address, or a public page that redirects inward, which fetch follows by default.

The shapes it takes in a pull request

  • fetch() on a URL from the request

    A preview, import, avatar-from-URL or webhook feature that passes the user's URL straight to fetch, axios or requests.

  • A response echoed back

    The fetched body, or a slice of it, returned to the user. This turns a blind request into a full read of whatever the server can reach.

  • A hostname blocklist

    A string check against localhost or 127.0.0.1 that other encodings of the same address, IPv6, or a hostname that resolves to a private address pass straight through.

  • Redirects followed after the check

    The URL is checked once, then the HTTP client follows a redirect to an address that was never checked.

  • A model tool that fetches

    A tool an LLM can call to read a page, where the model chooses the URL. Text on an untrusted page can steer the model to request an internal one.

Worked example, an illustration

A link preview that fetches any URL and returns the body.

The request: show a preview card when a user pastes a link into chat. The agent fetched the URL on the server, pulled the title, and returned a snippet of the page for the card.

app/api/preview/route.ts+5 -0
export async function POST(req: Request) {  const { url } = await req.json();  const res = await fetch(url);  const html = await res.text();  const title = html.match(/<title>(.*?)<\/title>/i)?.[1] ?? url;  const snippet = html.slice(0, 500);  return Response.json({ title, snippet });}
Illustration written for this page, not from a customer repository. Posting the cloud metadata credentials URL returns the server's temporary cloud credentials in the snippet, where the instance allows unauthenticated metadata requests. Posting an internal admin URL returns the admin page. The attacker reads both from the preview card.

What an Aevral finding on it looks like

On a reviewed pull request, a finding is an inline comment pinned to the added line, next to an advisory Check that never blocks the pull request. This is an illustration of that shape for the diff above.

AevralIllustration

app/api/preview/route.ts, added line: `const res = await fetch(url);`

url comes from the request body and is fetched from the server with no scheme or destination check, and up to 500 characters of the response are returned to the caller. Internal addresses, including the cloud metadata address, are reachable and readable.

Missing control: A destination check on the address the connection actually uses (public unicast only), no automatic redirects, a timeout and size cap, and a response that returns only the fields the feature needs.

Fix with your agent
In app/api/preview/route.ts, POST fetches the user-supplied url with no destination check and returns a snippet of the body. Accept only http and https, resolve the hostname and reject any address that is not public unicast (ipaddr.js range() === "unicast"), fetch through an undici Agent whose connect.lookup rejects non-unicast addresses (so the checked address is the one connected to), with redirect: "manual", a timeout and a size cap, and return only the title. Check literal IP hosts before fetching too, because Node skips the lookup hook for them. Add tests for http://169.254.169.254/, http://127.0.0.1/, http://[::ffff:127.0.0.1]/ and a hostname resolving to 10.0.0.1, each expecting the request to be refused without reaching the address.

Check this finding against the code before changing anything. Make the smallest change that restores the control, add a test for it, and stop before commit or push.

A finding is a lead with evidence, not a confirmation. You or your agent check it against the code; Aevral never applies or merges a change. See handing a finding to your coding agent.

How to fix it

Check where the request will actually go, not what the URL looks like, and check it at the moment the connection is made. The safe version gives the HTTP client its own DNS lookup: every address a hostname resolves to must be public unicast (not loopback, private, link-local or the rest), and the socket connects to the address that was checked. That closes the trick where a hostname answers with a public address for a check and a private one for the fetch. A URL that names an IP address directly never goes through DNS, so the handler checks that case itself before fetching.

Then narrow what a request can do: http and https only, no automatic redirects, a timeout, a cap on how much is read, and only the title returned. An egress proxy that blocks internal ranges for all outbound fetches is the sturdier control where you run one; the code-level check is what a pull request can show.

app/api/preview/route.ts (safe version)
import { lookup as dnsLookup } from "node:dns";
import ipaddr from "ipaddr.js";
import { Agent, fetch } from "undici";

const isPublic = (address: string) => ipaddr.process(address).range() === "unicast";

// Validates the addresses the socket will connect to, at connection time.
// Node skips lookup for literal IP hosts, so those are checked in POST.
const publicOnly = new Agent({
  connect: {
    lookup(hostname, options, callback) {
      dnsLookup(hostname, { all: true }, (err, addrs) => {
        if (err) return callback(err, "", 4);
        const allowed = addrs.every(({ address }) => isPublic(address));
        if (!allowed) return callback(new Error("destination not allowed"), "", 4);
        if (options.all) return callback(null, addrs);
        callback(null, addrs[0].address, addrs[0].family);
      });
    },
  },
});

async function readCapped(res: Response, max: number): Promise<string> {
  const reader = res.body?.getReader();
  if (!reader) return "";
  const chunks: Uint8Array[] = [];
  let size = 0;
  while (size < max) {
    const { done, value } = await reader.read();
    if (done) break;
    chunks.push(value);
    size += value.length;
  }
  await reader.cancel();
  return new TextDecoder().decode(Buffer.concat(chunks).subarray(0, max));
}

export async function POST(req: Request) {
  const { url } = await req.json();
  if (!URL.canParse(String(url))) return new Response("Bad URL", { status: 400 });
  const target = new URL(String(url));
  if (target.protocol !== "https:" && target.protocol !== "http:") {
    return new Response("URL not allowed", { status: 400 });
  }
  const host = target.hostname.replace(/^\[|\]$/g, "");
  if (ipaddr.isValid(host) && !isPublic(host)) {
    return new Response("URL not allowed", { status: 400 });
  }
  try {
    const res = await fetch(target, {
      dispatcher: publicOnly,
      redirect: "manual",
      signal: AbortSignal.timeout(5000),
    });
    const html = res.ok ? await readCapped(res as unknown as Response, 64 * 1024) : "";
    const title = html.match(/<title>(.*?)<\/title>/i)?.[1] ?? target.hostname;
    return Response.json({ title });
  } catch {
    return new Response("Preview unavailable", { status: 502 });
  }
}

What this page does not claim

  • Aevral's PR security review, live on install, looks for SSRF on the diff of the pull requests it reviews, as one class among several. Looking for a class does not mean finding each instance of it: a review can miss one, and a quiet review is not proof that the code is clean.
  • The whole-repo scan reads authorization, IDOR, and business-logic access control only. It does not read SSRF; on this class, the pull request is where Aevral looks.
  • No class-specific performance evidence for SSRF is published. The receipts page reports named runs of the whole PR review eval suite, not a result for this class.
  • The diff, the finding and the fix on this page are illustrations written for this page. They are not output from a customer repository.
  • This page covers requests made by application code. Cloud network rules and metadata-service settings are infrastructure controls that also limit SSRF; a pull request review reads the code, not your cloud account.

Sources

CWE-918: Server-Side Request Forgery (SSRF); OWASP API7:2023 Server Side Request Forgery; OWASP Server-Side Request Forgery Prevention Cheat Sheet; ipaddr.js; undici documentation.

Read next

Reviewing a pull request that wires in an LLM; Path traversal in agent-written code; PR security review.

Other classes


Security review, handled.

One GitHub App. Reviews start when the App is installed. A Check on each pull request it reviews, with inline comments when there is a grounded finding. Free tier live: public repos free, 500/org/month, 25 private reviews a month. Paid plans are live in the console.

Install the GitHub AppLog in

For professional use. By installing, you confirm you can act for the account or organization that owns it, and you accept the Terms and DPA on its behalf.