Let me state the conclusion first. SSRF (Server-Side Request Forgery) is born when "the server goes to fetch a URL the user pointed to as-is." And what's dangerous is that the server is inside the trust boundary. The cloud's metadata (169.254.169.254) that the attacker's browser can never reach, the localhost admin panel, the internal API of the internal network — your server reaches them "on behalf of the attacker." This is the essence of SSRF.
There's one line-drawing running through this article. "Tainted input is flowing into the sink called fetch" can be mechanically detected by data-flow (taint) analysis. But the judgment of the allowed destination (allowlist) of "so, which hosts may it go to fetch from" can only be made by a human who knows your app's meaning. A tool exposes the path, but a human decides the design. This article, taking the Next.js App Router's Server Actions and Route Handlers as the subject, concretizes vulnerable code, fixed code, and the idea of detection along this line-drawing.
1. What SSRF is — a confused deputy where the server reaches into the "inside"
SSRF is a relatively new attention-getter, added in OWASP Top 10 (2021) as the independent category A10:2021 — Server-Side Request Forgery (OWASP Top 10). The mechanism is anticlimactically simple and can be summarized like this — the server goes to fetch a URL passed from external without verifying it.
Why does that become a fatal blow? The key is "who makes that request."
[attacker's browser] --(doesn't reach directly)--X--> 169.254.169.254 / 10.0.0.0/8 / localhost:6379
^
| (fetch)
[attacker] --?url=...--> [your Next.js server] --+--> reaches here "on behalf"
The attacker's browser can't enter your VPC or the cloud's metadata network. But the server is there. So the attacker issues a command to the server in the form of a URL, and reaches inside borrowing the server's privileges and position. In security terms, the server becomes a confused deputy.
SSRF is a kind of "injection class." It shares the structure that a client-manipulable input (source) reaches a dangerous process (sink) without being verified with SQL injection and path traversal. The map of the whole injection class, and how it differs from a design risk like authorization (IDOR), is organized in the Next.js × Supabase application-security complete guide. This article digs one level deeper into "SSRF" within that map.
2. The places SSRF is born in Next.js — where is fetch
Those who thought "we don't let users enter a URL" should be the most careful. SSRF's entrance isn't only an explicit URL input field. Every route where a request-derived value becomes (part of) fetch's URL is a candidate. Let me list the representative ones in the Next.js App Router.
| Feature | source (tainted input) | sink |
|---|---|---|
| Image proxy / resize | searchParams.get("url") | fetch(url) |
| Link preview / OG retrieval | formData.get("url") | fetch(url) (HTML retrieval) |
| Webhook registration/sending | (await req.json()).webhookUrl | fetch(webhookUrl) |
| External-API relay (BFF) | params.target | fetch("https://" + target) |
| Import (URL-specified) | searchParams.get("src") | fetch(src) |
| Callback verification | headers.get("x-callback") | fetch(callback) |
What's common is that all or part of the URL passed to fetch comes from the request. It's the same for a Server Action ("use server") and a Route Handler (route.ts); from the SSRF viewpoint, there's no distinction between them. Both are external entrances hittable over HTTP.
"It's safe because there's no URL input field on screen" is wrong. A Server Action is effectively a POST endpoint, and the arguments and
FormDatacan be hit by anyone with arbitrary values. Hidden fields and hardcoded values can also be tampered via the network. "Not shown in the UI" isn't a defense.
3. An attack example — what leaks from one fetch
When a tainted URL can be fetched, let me concretely see the destinations the attacker aims at. All shown with placeholders (169.254.169.254 and example.com).
- Cloud metadata (most important):
http://169.254.169.254/latest/meta-data/...is the cloud's instance metadata. Depending on the configuration, it returns temporary IAM credentials and leads to direct hijacking of cloud privileges. Being a link-local address (169.254.0.0/16), it doesn't reach from outside but reaches from the server — a typical SSRF target. - localhost / loopback: it reaches internal services that tend to start without authentication, like
http://127.0.0.1:6379/(Redis) andhttp://localhost:9200/(a search engine). - Internal network: admin panels and internal APIs of a private IP range (
10.0.0.0/8/172.16.0.0/12/192.168.0.0/16) likehttp://10.0.0.5/admin. - Dangerous schemes:
file:///etc/passwdfor a local file,gopher://ordict://for smuggling to another protocol. Allowing anything other than HTTP widens the attack surface at once. - DNS rebinding: a time-difference attack that shows the hostname as a harmless public IP and switches resolution to an internal IP only at the moment
fetchactually connects. Since this is an independent pitfall, it's detailed in section 6.
The one lesson to remember here is — think of the destination not as "a string" but as "the IP and scheme it finally connects to." The attacker can transform the URL string however they like (encoding, described later). The defending side needs to look not at the string's appearance but at the entity after resolution.
4. Vulnerable code — fetching a user-specified URL as-is
First, two pieces of vulnerable code AI typically writes (and that work perfectly in a demo). An image proxy (Route Handler) and a link preview (Server Action).
// app/api/image/route.ts — 脆弱(SSRF)。画像プロキシのつもりが内部到達の踏み台になる
export async function GET(req: Request) {
const target = new URL(req.url).searchParams.get("url"); // ← 汚染入力
if (!target) return new Response("missing url", { status: 400 });
const upstream = await fetch(target); // ← 危険シンク:行き先を一切縛っていない
return new Response(upstream.body, {
headers: {
"content-type": upstream.headers.get("content-type") ?? "application/octet-stream",
},
});
}
This route works correctly with /api/image?url=https://images.example.com/a.png. So it passes review. But the attacker, just by sending ?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/, might receive the cloud's credentials as an HTTP response. ?url=http://127.0.0.1:6379/ can be used for reachability confirmation to an internal service.
A Server Action falls into the same hole.
// app/actions/preview.ts — 脆弱(SSRF)。"use server" は実質POSTエンドポイント
"use server";
export async function fetchLinkPreview(formData: FormData) {
const target = String(formData.get("url")); // ← クライアントが自由に送れる汚染入力
const res = await fetch(target); // ← 危険シンク
const html = await res.text();
return extractOgTags(html); // og:title 等を抜き出して返す(=内部レスポンスが漏れうる)
}
A link preview's very feature of "pass a URL and return meta info" has the shape of SSRF. If the implementation further fetches the og:image URL to save the image, the attack surface widens in two stages of retrieve → re-retrieve.
The structure of the problem is identical for both. The tainted target reaches the sink called fetch without its destination being bound. In the next section, I plug this in layers.
5. Countermeasure — bind the "destination" in layers
There's no silver bullet of "this one thing solves SSRF." Since the attacker has many evasion means (encoding, redirect, rebinding), binding the destination in layers and reducing the damage is the correct answer. OWASP's Web Security Testing Guide too positions SSRF as something to evaluate including not only input validation but network-side control. There are 6 layers.
- Scheme restriction — allow only
https:(http:if needed). Excludefile:,gopher:,data:. - Host allowlist — enumerate the hosts you may go to fetch from. This is the core of the design judgment.
- Blocking the resolved IP — reject if the resolved actual IP is private/reserved.
- Prohibiting redirect-following —
redirect: "manual". Sever the internal "re-entry" via a 302. - Timeout / size limit — sever infinite waits and giant responses.
- A separate route (network side) — isolate it to a send-only route and don't let it reach the metadata network.
5-1. Why "allowlist" rather than "deny list"
First, confirm the principle. A deny list (block list) of "reject URLs containing 169.254 or localhost" is structurally broken. That's because the attacker can transform the URL string however they like.
http://2130706433/ ← 127.0.0.1 in decimal notation
http://0x7f.0x0.0x0.0x1/ ← hex notation
http://127.1/ ← abbreviated notation
http://[::ffff:169.254.169.254] ← IPv4-mapped IPv6
http://metadata.example.com ← resolve to an internal IP via DNS (rebinding)
Crushing all patterns with string matching is a losing battle. So make the defense a two-part of "go only to allowed hosts (allowlist)" + "look at the actually-resolved IP and reject if it's internal-facing." The former narrows the destination, the latter plugs the loophole where "an allowed host resolves to an internal IP."
5-2. Make one gate of safe retrieval in one place
Don't scatter the verification logic; consolidate it into the single gate. From the ETC (ease of change) viewpoint too, a change of the allowed destination completes in one file. Since IP classification, hand-written, easily misses IPv4-mapped IPv6, etc., delegate to the proven ipaddr.js (a self-implementation is the typical "looks easy but gets it wrong").
// lib/safe-fetch.ts — サーバー専用。リクエスト由来URLを安全側で検証する関門
import "server-only";
import dns from "node:dns/promises";
import ipaddr from "ipaddr.js";
// 取りに行ってよいホスト。完全一致で照合する(部分一致や正規表現は抜けやすい)
// ここに何を入れるかは「設計判断」——アプリの意味を知る人間が決める
const ALLOWED_HOSTS = new Set<string>(["images.example.com", "api.example.com"]);
// 公開ユニキャスト以外(loopback/private/linkLocal/uniqueLocal/reserved 等)は一括拒否
function isForbiddenAddress(ip: string): boolean {
const range = ipaddr.parse(ip).range();
return range !== "unicast"; // fail-closed:未知・特殊レンジは全部「危険側」に倒す
}
export async function assertSafeUrl(raw: string): Promise<URL> {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new Error("invalid url");
}
// (1) スキーム制限:https だけ。file: gopher: data: を排除
if (url.protocol !== "https:") throw new Error("scheme not allowed");
// (2) ホスト allowlist:列挙したホスト以外には行かない
if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error("host not allowed");
// (3) 解決後IPの検査:1つでも内部向きなら拒否(許可ホストの“なりすまし解決”を塞ぐ)
const records = await dns.lookup(url.hostname, { all: true });
if (records.length === 0) throw new Error("dns failed");
for (const { address } of records) {
if (isForbiddenAddress(address)) throw new Error(`blocked address: ${address}`);
}
return url;
}
Writing isForbiddenAddress as range() !== "unicast" is intentional fail-closed. Rather than individually enumerating private, loopback, linkLocal, uniqueLocal, reserved, etc., and "forgetting to add one," it's safer to fall to "everything but normal public unicast is dangerous."
5-3. Attach redirect prohibition and timeout at retrieval time
Even for a URL that passed the gate, bind fetch's behavior. The default fetch auto-follows redirects, so if an allowed host returns 302 Location: http://169.254.169.254/, it slips past verification and flies internal. Sever this.
// lib/safe-fetch.ts(続き)
export async function safeFetch(raw: string): Promise<Response> {
const url = await assertSafeUrl(raw);
const res = await fetch(url, {
redirect: "manual", // 302で内部へ「再入場」させない(追従しない)
signal: AbortSignal.timeout(5_000), // 無限待ち・スローロリスを断つ
headers: { accept: "image/*" }, // 期待する種類だけ受け取る
});
// 3xx は「別ホストへの入り直し」。追わず、必要なら Location を再び assertSafeUrl に通す
if (res.status >= 300 && res.status < 400) {
throw new Error("redirect blocked");
}
return res;
}
Three points. Don't follow redirects (if you follow, pass the Location through this gate again). Suppress internal-port-scan-like abuse and resource exhaustion with a timeout. Lean toward the expected response type with accept. Furthermore, in production, also adding an upper limit on the response size (interrupt at a threshold while reading the stream) is even better.
5-4. Pass the vulnerable code through the gate
The two examples of section 4 are plugged just by passing through the gate in one line. Since the verification logic is consolidated in the gate, the caller side becomes thin.
// app/api/image/route.ts — 修正。safeFetch を必ず通す
import { safeFetch } from "@/lib/safe-fetch";
export async function GET(req: Request) {
const target = new URL(req.url).searchParams.get("url");
if (!target) return new Response("missing url", { status: 400 });
try {
const upstream = await safeFetch(target); // allowlist + IP検査 + リダイレクト禁止 + timeout
return new Response(upstream.body, {
headers: { "content-type": upstream.headers.get("content-type") ?? "application/octet-stream" },
});
} catch {
return new Response("forbidden", { status: 400 }); // 理由を詳細に返さない(情報漏れ防止)
}
}
// app/actions/preview.ts — 修正。Server Action も同じ関門を通す
"use server";
import { safeFetch } from "@/lib/safe-fetch";
export async function fetchLinkPreview(formData: FormData) {
const res = await safeFetch(String(formData.get("url")));
return extractOgTags(await res.text());
}
Swallowing the error message on the caller side is also intentional. Returning details like blocked address: 10.0.0.5 as-is lets the attacker draw a map of the internal network (visualizing blind SSRF).
5-5. A separate route — bind on the network side too (defense-in-depth)
The application-layer gate is the most important, but network-side control is an independent last wall. Isolate the egress to a dedicated route and lay firewall rules that can't reach the metadata network (169.254.169.254) or internal segments. For the cloud's metadata, just switching to a token-required method (IMDSv2) and attaching a hop-count limit can neutralize many simple SSRF GETs.
This isn't "unnecessary if there's an application gate." Even if the gate has a bug, the network stops it doubly — it has meaning precisely because the layers are independent. The application layer and the network layer are complements, not substitutes for each other.
6. The pitfall called DNS rebinding — it's rewritten "after verification"
Section 5's gate still has one hole remaining. After assertSafeUrl resolves the name and inspects the IP, fetch resolves the name once more at the moment it connects. What if, between these two resolutions, the attacker switches their own DNS's response to an internal IP? Verification passes with the public IP, and the connection holds with the internal IP. This is DNS rebinding — a typical TOCTOU (the difference between check time and use time).
1) assertSafeUrl: metadata.example.com → 203.0.113.10 (public IP) …check OK
2) fetch connects: metadata.example.com → 169.254.169.254 (internal) …slips past
↑ the attacker can switch the response with a short TTL on their own DNS
To plug it, you have no choice but to make "the inspected IP" and "the connecting IP" match (pin the IP). In Node (undici), you can insert lookup, the hook at connection time, and inspect the exact IP at the moment of connection. With this, the resolution of inspection and connection becomes identical, and the time difference disappears.
// lib/ssrf-agent.ts — 接続する瞬間のIPを検査し、リバインディングのTOCTOUを閉じる
import "server-only";
import dns from "node:dns";
import { Agent } from "undici";
import ipaddr from "ipaddr.js";
function guardLookup(
hostname: string,
options: dns.LookupOptions,
cb: (err: NodeJS.ErrnoException | null, address: string, family: number) => void,
) {
dns.lookup(hostname, options, (err, address, family) => {
if (err) return cb(err, address as string, family);
// 接続に使われるまさにこのアドレスを検査する(検査=接続で同じ解決)
if (ipaddr.parse(address as string).range() !== "unicast") {
return cb(new Error(`blocked address: ${address}`), address as string, family);
}
cb(null, address as string, family);
});
}
// この dispatcher 経由の fetch は、解決結果そのままで接続する
export const ssrfSafeAgent = new Agent({ connect: { lookup: guardLookup } });
When using it, explicitly use undici's fetch. Node's global fetch has undici inside, but the Web-standard type has no dispatcher, and attaching it causes a type error (I want to avoid swallowing it with any).
// lib/safe-fetch.ts(リバインディングまで閉じる版)
import { fetch } from "undici"; // dispatcher を型安全に渡すため undici の fetch を使う
import { ssrfSafeAgent } from "./ssrf-agent";
const res = await fetch(url, {
dispatcher: ssrfSafeAgent, // 接続時にIPを再検査(リバインディング対策)
redirect: "manual", // 追従しない
signal: AbortSignal.timeout(5_000), // タイムアウト
});
Having gone this far, you finally reach the state where the major loopholes of SSRF — encoding, redirect, rebinding — are bound in layers. Even so, I won't say "this is absolutely safe." The attack surface also depends on the network configuration and library implementation. That's exactly why you use the next detection, and the network-side separate route (5-5), together.
7. Detect with taint analysis — source = request, sink = fetch
Once you've decided to plug it with design, continuously confirm "is it plugged." Since SSRF's structure is clear, you can follow it mechanically with data-flow (taint) analysis, not regex. The idea is this.
- source (the tainted source) = a request-derived value.
searchParams/req.json()/params/headers/formData/cookies. - sink (the dangerous sink) =
fetch(...)(andundici.request, the image loader, etc., APIs that make a request to external). - Detection condition = a value flows from source to sink, and there's no valid sanitizer on the way.
| source (tainted input) | sink | The problem detected |
|---|---|---|
searchParams.get("url") | fetch(url) | SSRF (image proxy) |
(await req.json()).webhookUrl | fetch(webhookUrl) | SSRF (webhook) |
params.target | fetch("https://" + target) | SSRF (host insertion) |
formData.get("url") | fetch(url) (OG retrieval) | SSRF (link preview) |
The sanitizer's "validity" is the crux
The accuracy of taint analysis is decided by the design of "what, if passed, is considered to have erased the taint (untaint)." This is fundamentally hard.
- A valid sanitizer (may erase the taint): an allowlist collation like
ALLOWED_HOSTS.has(url.hostname). A value that passed section 5'sassertSafeUrl. - An invalid sanitizer (must not erase the taint): a string match of a deny list like
if (url.includes("169.254")) reject(). Since it's slipped past by encoding or rebinding, even passing this should be considered to leave the taint.
In other words, detection has meaning only once the analyzer can distinguish "an allowlist sanitizes, a blocklist isn't recognized as sanitizing." The OSS Aegis I publish implements this source→sink path tracing and sanitizer judgment, and runs with no installation.
# インストール不要・設定不要。汚染入力→fetchシンクの経路を可視化する
npx @aegiskit/cli scan
Note that the same "trusting a URL" structure as SSRF also appears in open redirect, which trusts the return-destination URL, and in SQL injection, which flows to a different sink. The verification pattern of the former is carved out in open-redirect / callback-URL prevention, and the latter in Supabase SQL-injection / RPC prevention. The sinks differ, but the idea of "following tainted input" is identical.
The honest scope — a tool exposes the path but doesn't decide the allowed destination
Let me emphasize here. What taint analysis can confirm is only up to "whether tainted input reaches fetch" and "whether there's a sanitizer on the way." Beyond that — "which hosts may be put in ALLOWED_HOSTS," "should this app have arbitrary-URL retrieval as a feature at all" — can only be judged by a human who knows the business requirements and the data's meaning. A clean scan result is "you haven't stepped on the common traps," not "it became safe." Data-flow analysis is intra-function (intraprocedural) in principle and misses flows crossing modules or frameworks. These verifications aren't a replacement for human review and threat modeling but a complement.
8. Pre-production checklist
Whether outsourced or AI-made, if there's a route that fetches a request-derived URL, confirm at least this before going to production. Let me list the viewpoint and the danger signal together.
- A request-derived value doesn't pass straight to
fetch's argument (tainted-input→sink goes through the allowlist gate) - The destination is bound with a host allowlist (exact match) (not depending on a deny-list string match)
- The scheme is limited to
https:(onlyhttp:when needed), andfile:,gopher:,data:are excluded - You inspect the resolved actual IP and reject private/loopback/link-local/reserved ranges
- You don't auto-follow redirects (
redirect: "manual". Re-verify theLocationif you follow) - You set a timeout and size limit (
AbortSignal.timeout, etc.) - You consider DNS rebinding and go as far as IP pinning at connection time (
connect.lookup, etc.) - The error response doesn't leak internal details (reached IP, port)
- You block sending to the metadata network / internal segments with a network-side separate route (IMDSv2, etc.)
- You permanently station taint analysis (SAST) in CI and continuously detect the mixing-in of a new
fetchroute
What's most effective from the client's viewpoint is the three questions "Is there processing where the server goes to fetch a URL the user pointed to?" "How do you restrict that destination?" "What happens if I pass 169.254.169.254?" A good developer can answer immediately.
9. How far yourself, and where audit begins
Finally, let me draw the line honestly.
The "detection" of SSRF can be mechanically crushed by automation. The path where tainted input reaches fetch, the presence of redirect-following, the validity of the sanitizer — these are an area where static analysis can stand guard in CI. First visualizing where in your code "tainted-input→fetch-sink" goes through, with Aegis (free OSS, npx @aegiskit/cli scan), is the most cost-effective first step.
On the other hand, "the design of the allowed destination" is the human domain. Which hosts may be put in ALLOWED_HOSTS, should there be a feature of arbitrary-URL retrieval at all, how to lay IMDSv2 and egress control — these can only be judged by a human who understands your app's meaning and infrastructure configuration. A product that asserts "introduce it and it's safe" here is rather dangerous. Aegis detects/warns about the path and the presence of a sanitizer, but doesn't prove that the allowed destination is correct.
That's exactly why a line is needed. How far to make your own gate, and where an expert's review is needed — if you need the design of the allowed destination, validity confirmation of rebinding countermeasures, or network-side separate-route design, I take it on with a security audit. I myself, in the lumber-distribution-industry DX project, did the boundary design and verification of server-to-server communication including external-service integration in actual operation.
Frequently asked questions (FAQ)
Q. There's no allowlist, and the requirement is a feature to retrieve an arbitrary URL (a general-purpose proxy). What do I do? A. In that case, since application-layer host restriction can't be used, network-side isolation is the protagonist. Run it on a send-only route (a segment that can't reach the metadata network / internal segments), and layer all of resolved-IP blocking, redirect prohibition, timeout, scheme restriction, and rebinding countermeasures. Even so, the residual risk is large, so re-questioning "is an arbitrary URL really needed" from the requirements comes first.
Q. With redirect: "manual", I can't follow legitimate redirects either. Won't that be a problem?
A. That's the correct behavior. A redirect is "re-entry to a different host," so if you follow, you should pass the Location through assertSafeUrl again (with a count limit). Auto-following becomes a loophole allowing lateral movement from an allowed host to the internal.
Q. Isn't it enough to reject private IPs as a string?
A. It's insufficient. The string appearance transforms in countless ways, like http://2130706433/ (decimal) and IPv4-mapped IPv6. So judge not as a string but by classifying the resolved actual IP with ipaddr.js, etc. Furthermore, since there's DNS rebinding, you also need IP pinning at connection time.
Q. Can taint analysis guarantee that SSRF is "absent"? A. It can't. What the analysis can say is only "places where there's no valid sanitizer on the tainted-input→fetch path." It misses flows crossing functions and dynamic assembly, and above all, it doesn't judge whether the allowed destination is business-correct. Detection complements human review and doesn't replace it.
Q. Should I go this far even for personal development or small scale?
A. If there's even one route that fetches a request-derived URL, always have at least a gate of "host allowlist + resolved-IP blocking + redirect prohibition + timeout." On the cloud, reaching 169.254.169.254 can directly lead to credential leakage instantly, so the accidents you can prevent for the cost are orders of magnitude.
Summary: bind the destination not by "the string" but by "the IP and scheme it finally connects to"
Let me organize the key points.
- SSRF is the flaw of "the server goes to fetch a URL the attacker pointed to" (OWASP A10:2021). Since the server is inside the trust boundary, it reaches, on behalf, metadata, localhost, and internal APIs unreachable from outside.
- In Next.js, all routes that "fetch a request-derived URL" — the
fetchof a Server Action / Route Handler, an image proxy, a webhook, OG retrieval — are entrances. A Server Action is also a POST endpoint, hittable even if not shown in the UI. - The countermeasure is multi-layered — scheme restriction, host allowlist, resolved-IP blocking, redirect-following prohibition, timeout, a separate route. Since a deny list is slipped past by encoding or rebinding, make the allowlist and actual-IP inspection the protagonists.
- DNS rebinding slips past with the time difference between inspection and use. It can't be fully closed unless you go as far as inspecting the IP at connection time (IP pinning).
- Detection can be mechanized with the taint analysis of "tainted-input→fetch-sink." But a tool only exposes the path and the presence of a sanitizer; the design of "which host may be allowed" can only be decided by a human.
Building fast with AI is itself correct. Binding the "destination" of what you built fast, without leaks — if you need to build that gate, or a review of an existing Next.js app's SSRF / external-communication boundary, please feel free to consult me.