最初に結論を述べます。SSRF(Server-Side Request Forgery)は、「ユーザーが指したURLを、サーバーがそのまま取りに行ってしまう」ときに生まれます。 そして危険なのは、サーバーが信頼境界の内側にいることです。攻撃者のブラウザからは決して届かないクラウドのメタデータ(169.254.169.254)、localhost の管理画面、社内ネットワークの内部APIに、あなたのサーバーが「攻撃者の代わりに」到達してしまう——これがSSRFの本質です。
この記事を貫く線引きは1つです。「汚染された入力が fetch というシンクに流れ込んでいる」ことは、データフロー(taint)解析で機械的に検出できます。 けれど、「では、どのホストになら取りに行ってよいのか」という許可先(allowlist)の判断は、あなたのアプリの意味を知る人間にしかできません。 ツールは経路を暴くが、設計は人が決める。本記事は、Next.js App Router の Server Actions と Route Handlers を題材に、脆弱なコードと修正コード、そして検出の考え方を、この線引きに沿って具体化します。
1. SSRFとは何か — サーバーが「内部」へ手を伸ばす混乱した代理人
SSRFは、OWASP Top 10(2021)で独立カテゴリ A10:2021 — Server-Side Request Forgery として追加された、比較的新しい注目株です(OWASP Top 10)。仕組みは拍子抜けするほど単純で、こう要約できます——サーバーが、外部から渡されたURLを検証せずに取りに行く。
なぜそれが致命傷になるのか。鍵は「誰がそのリクエストを出すか」です。
[攻撃者のブラウザ] --(直接は届かない)--X--> 169.254.169.254 / 10.0.0.0/8 / localhost:6379
^
| (fetch)
[攻撃者] --?url=...--> [あなたのNext.jsサーバー] --+--> ここに「代わりに」到達してしまう
攻撃者のブラウザは、あなたのVPCの中にも、クラウドのメタデータ網にも入れません。ところがサーバーはそこにいます。 だから攻撃者は、URLという形でサーバーに命令を出し、サーバーの権限と位置を借りて内部へ手を伸ばす。セキュリティの言葉で言えば、サーバーが confused deputy(混乱した代理人) になる、ということです。
SSRFは「注入クラス」の一種です。クライアントが操作できる入力(source)が、検証されないまま危険な処理(sink)に到達する——という構造を、SQLインジェクションやパストラバーサルと共有しています。注入クラス全体の地図と、それが認可(IDOR)のような設計リスクとどう違うかは、Next.js × Supabase アプリケーションセキュリティ完全ガイドに整理しました。本記事はその地図の中の「SSRF」を一段深く掘ります。
2. Next.jsでSSRFが生まれる箇所 — どこに fetch があるか
「うちはユーザーにURLなんて入れさせない」と思った方こそ要注意です。SSRFの入口は、明示的なURL入力欄だけではありません。リクエスト由来の値が fetch のURL(の一部)になる経路は、すべて候補です。Next.js App Router で代表的なものを挙げます。
| 機能 | source(汚染入力) | sink |
|---|---|---|
| 画像プロキシ / リサイズ | searchParams.get("url") | fetch(url) |
| リンクプレビュー / OG取得 | formData.get("url") | fetch(url)(HTML取得) |
| Webhook登録・送信 | (await req.json()).webhookUrl | fetch(webhookUrl) |
| 外部API中継(BFF) | params.target | fetch("https://" + target) |
| インポート(URL指定) | searchParams.get("src") | fetch(src) |
| コールバック検証 | headers.get("x-callback") | fetch(callback) |
共通しているのは、fetch に渡すURLの全体または一部が、リクエストから来ていることです。Server Action("use server")でも Route Handler(route.ts)でも同じで、両者はSSRFの観点では区別がありません。どちらもHTTPで叩ける外部入口だからです。
「画面にURL入力欄が無いから安全」は誤りです。 Server Action は実質POSTエンドポイントで、引数や
FormDataは誰でも任意の値で叩けます。隠しフィールドやハードコードした値も、ネットワーク経由では改ざんできます。「UIに出していない」は防御になりません。
3. 攻撃例 — 1本の fetch から何が漏れるか
汚染URLを取りに行けるとき、攻撃者が狙う行き先を具体的に見ます。すべてプレースホルダ(169.254.169.254 と example.com)で示します。
- クラウドのメタデータ(最重要):
http://169.254.169.254/latest/meta-data/...はクラウドのインスタンスメタデータ。設定次第では一時的なIAM認証情報を返し、そのままクラウド権限の奪取につながります。リンクローカルアドレス(169.254.0.0/16)なので、外からは届かないがサーバーからは届く、SSRFの典型的標的です。 - localhost / ループバック:
http://127.0.0.1:6379/(Redis)、http://localhost:9200/(検索エンジン)など、認証なしで起動しがちな内部サービスへ到達されます。 - 内部ネットワーク:
http://10.0.0.5/adminのようなプライベートIP帯(10.0.0.0/8/172.16.0.0/12/192.168.0.0/16)の管理画面・内部API。 - 危険なスキーム:
file:///etc/passwdでローカルファイル、gopher://やdict://で別プロトコルへの密輸。HTTP以外を許すと攻撃面が一気に広がります。 - DNSリバインディング: ホスト名は無害な公開IPに見せておき、
fetchが実際に接続する瞬間だけ内部IPへ解決を切り替える時間差攻撃。これは独立した落とし穴なので、第6節で詳述します。
ここで覚えておくべき教訓は1つです——行き先は「文字列」ではなく「最終的に接続するIPとスキーム」で考える。 攻撃者はURL文字列をいくらでも化けさせられます(後述の符号化)。守る側は、文字列の見た目ではなく、解決後の実体を見る必要があります。
4. 脆弱なコード — ユーザー指定URLをそのまま fetch
まず、典型的にAIが書きがちな(そしてデモでは完璧に動く)脆弱コードを2つ。画像プロキシ(Route Handler) と リンクプレビュー(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",
},
});
}
このルートは、/api/image?url=https://images.example.com/a.png では正しく動きます。だからレビューを通ってしまう。しかし攻撃者は ?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ を送るだけで、クラウドの認証情報をHTTPレスポンスとして受け取れるかもしれません。?url=http://127.0.0.1:6379/ なら内部サービスへの到達確認に使えます。
Server Action も同じ穴に落ちます。
// 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 等を抜き出して返す(=内部レスポンスが漏れうる)
}
リンクプレビューは「URLを渡してメタ情報を返す」機能そのものがSSRFの形をしています。og:image のURLをさらに fetch して画像を保存する実装なら、取得→再取得の二段で攻撃面が広がります。
問題の構造は両者で同一です。汚染された target が、行き先を縛られないまま fetch というシンクに到達している。 次節で、これを多層で塞ぎます。
5. 対策 — 多層で「行き先」を縛る
SSRFに「これ1つで解決」という銀の弾丸はありません。攻撃者が回避手段(符号化、リダイレクト、リバインディング)を多数持つため、多層で行き先を縛り、被害を縮小するのが正解です。OWASPのWeb Security Testing Guideも、SSRFは入力の検証だけでなくネットワーク側の制御まで含めて評価すべきと位置づけています。層は次の6つです。
- スキーム制限 —
https:(必要ならhttp:)のみ許可。file:gopher:data:を排除。 - ホストのallowlist — 取りに行ってよいホストを列挙。ここが設計判断の核心。
- 解決後IPの遮断 — 名前解決した実IPがプライベート/予約帯なら拒否。
- リダイレクト追従の禁止 —
redirect: "manual"。302での内部「再入場」を断つ。 - タイムアウト/サイズ上限 — 無限待ち・巨大レスポンスを断つ。
- 別経路(ネットワーク側) — 送信専用の経路に隔離し、メタデータ網へ到達させない。
5-1. なぜ「拒否リスト」ではなく「許可リスト」なのか
最初に原則を確認します。「169.254 や localhost を含むURLを弾く」という拒否リスト(ブロックリスト)は、構造的に破られます。 攻撃者はURL文字列をいくらでも化けさせられるからです。
http://2130706433/ ← 10進数表記の 127.0.0.1
http://0x7f.0x0.0x0.0x1/ ← 16進表記
http://127.1/ ← 省略表記
http://[::ffff:169.254.169.254] ← IPv4-mapped IPv6
http://metadata.example.com ← DNSで内部IPに解決させる(リバインディング)
文字列マッチで全パターンを潰すのは負け戦です。だから守りは**「許可したホストだけに行く(allowlist)」+「実際に解決されたIPを見て内部向きなら拒否する」**の二本立てにします。前者で行き先を絞り、後者で「許可ホストが内部IPに解決される」抜け道を塞ぐ。
5-2. 安全な取得の関門を1か所に作る
検証ロジックは散らさず、唯一の関門に集約します。ETC(変更容易性)の観点でも、許可先の変更が1ファイルで完結します。IP分類は手書きすると IPv4-mapped IPv6 などを取りこぼしやすいため、実績ある ipaddr.js に委ねます(自前実装は「易しそうで間違える」典型です)。
// 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;
}
isForbiddenAddress を range() !== "unicast" と書いているのは意図的な fail-closed です。private loopback linkLocal uniqueLocal reserved などを個別に列挙して「足し忘れる」より、**「通常の公開ユニキャスト以外は全部危険」**と倒すほうが安全です。
5-3. 取得時にリダイレクト禁止・タイムアウトを付ける
関門を通したURLでも、fetch の挙動を縛ります。デフォルトの fetch はリダイレクトを自動追従するため、許可ホストが 302 Location: http://169.254.169.254/ を返せば、検証をすり抜けて内部へ飛びます。これを断ちます。
// 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;
}
ポイントは3つ。リダイレクトは追わない(追うなら Location をもう一度この関門に通す)。タイムアウトで内部ポートスキャン的な悪用と資源枯渇を抑える。accept で期待する応答型に寄せる。さらに本番では、レスポンスサイズの上限(ストリームを読みながら閾値で中断)も足すとなお良い。
5-4. 脆弱コードを関門に通す
第4節の2例は、関門を1行通すだけで塞がります。検証ロジックが関門に集約されているので、呼び出し側は薄くなります。
// 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());
}
エラーメッセージを呼び出し側で握りつぶしているのも意図的です。blocked address: 10.0.0.5 のような詳細をそのまま返すと、内部ネットワークの地図を攻撃者に描かせてしまう(盲目的SSRFを可視化させる)からです。
5-5. 別経路 — ネットワーク側でも縛る(多層防御)
アプリ層の関門が最重要ですが、ネットワーク側の制御は独立した最後の壁になります。送信(egress)を専用の経路に隔離し、メタデータ網(169.254.169.254)や内部セグメントへ到達できないファイアウォール規則を敷く。クラウドのメタデータは、トークン必須方式(IMDSv2)に切り替え、ホップ数制限を付けるだけでも、単純なSSRF GETの多くを無力化できます。
これは「アプリの関門があれば不要」ではありません。関門にバグがあっても、ネットワークが二重に止める——層が独立しているからこそ意味があります。アプリ層とネットワーク層は、互いの代替ではなく補完です。
6. DNSリバインディングという落とし穴 — 「検証した後」に書き換わる
第5節の関門には、まだ穴が1つ残っています。assertSafeUrl で名前解決してIPを検査した後、fetch が接続する瞬間にもう一度名前解決します。この2回の解決のあいだに、攻撃者が自分のDNSの応答を内部IPへ切り替えたら? 検証は公開IPで通り、接続は内部IPで成立する。これが DNSリバインディング——典型的な TOCTOU(検査時と使用時の差) です。
1) assertSafeUrl: metadata.example.com → 203.0.113.10(公開IP) …検査OK
2) fetch が接続: metadata.example.com → 169.254.169.254(内部) …すり抜け
↑ 攻撃者は自分のDNSで、短いTTLで応答を切り替えられる
塞ぐには、**「検査したIP」と「接続するIP」を一致させる(IPを固定する)**しかありません。Node(undici)では、接続時のフックである lookup を差し込み、接続する瞬間のIPをそのまま検査できます。これで検査と接続の解決が同一になり、時間差が消えます。
// 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 } });
使うときは、undici の fetch を明示的に使います。Node のグローバル fetch は中身が undici ですが、Web標準の型に dispatcher が無く、付けると型エラーになる(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), // タイムアウト
});
ここまでやって、ようやくSSRFの主要な抜け道——符号化・リダイレクト・リバインディング——を多層で縛り込めた状態になります。それでも「これで絶対に安全」とは言いません。攻撃面はネットワーク構成やライブラリの実装にも依存します。だからこそ、次の検出と、ネットワーク側の別経路(5-5)を併用するのです。
7. taint解析で検出する — source=リクエスト, sink=fetch
設計で塞ぐと決めたら、「塞げているか」を継続的に確かめます。SSRFは構造が明快なので、正規表現ではなくデータフロー(taint)解析で機械的に追えます。考え方はこうです。
- source(汚染源) = リクエスト由来の値。
searchParams/req.json()/params/headers/formData/cookies。 - sink(危険シンク) =
fetch(...)(およびundici.request、画像ローダ等、外部へ要求を出すAPI)。 - 検出条件 = source から sink へ値が流れ、その途中に妥当なサニタイザが無い。
| source(汚染入力) | sink | 検出される問題 |
|---|---|---|
searchParams.get("url") | fetch(url) | SSRF(画像プロキシ) |
(await req.json()).webhookUrl | fetch(webhookUrl) | SSRF(Webhook) |
params.target | fetch("https://" + target) | SSRF(ホスト差し込み) |
formData.get("url") | fetch(url)(OG取得) | SSRF(リンクプレビュー) |
サニタイザの「妥当性」が肝
taint解析の精度は、**「何を通せば汚染が消えた(untaint)とみなすか」**の設計で決まります。ここが本質的に難しい。
- 妥当なサニタイザ(汚染を消してよい):
ALLOWED_HOSTS.has(url.hostname)のような許可リスト照合。第5節のassertSafeUrlを通った値。 - 妥当でないサニタイザ(汚染を消してはいけない):
if (url.includes("169.254")) reject()のような拒否リストの文字列マッチ。符号化やリバインディングで抜けるため、これを通っても汚染は残っているとみなすべきです。
つまり、解析器が「allowlistは安全化、blocklistは安全化と認めない」と区別できて初めて、検出は意味を持ちます。私が公開しているOSSの Aegis は、この source→sink の経路追跡とサニタイザ判定を実装しており、インストール不要で走ります。
# インストール不要・設定不要。汚染入力→fetchシンクの経路を可視化する
npx @aegiskit/cli scan
なお、SSRFと同じ「URLを信じてしまう」構造は、戻り先URLを信じるオープンリダイレクトにも、別シンクに流れるSQLインジェクションにも現れます。前者の検証パターンはオープンリダイレクト/コールバックURL対策、後者はSupabaseのSQLインジェクション/RPC対策に切り出しました。シンクは違えど「汚染入力を追う」発想は同一です。
正直なスコープ — ツールは経路を暴くが、許可先は決めない
ここは強調させてください。taint解析が確認できるのは「汚染入力が fetch に届いているか」「途中にサニタイザがあるか」までです。 その先——「ALLOWED_HOSTS に入れてよいホストはどれか」「このアプリはそもそも任意URL取得を機能として持つべきか」——は、事業要件とデータの意味を知る人間にしか判断できません。クリーンなスキャン結果は「よくある罠は踏んでいない」であって「安全になった」ではない。データフロー解析は関数内(intraprocedural)が基本で、モジュールやフレームワークを跨ぐ流れは取りこぼします。これらの検証は、人間のレビューと脅威モデリングを置き換えるものではなく、補完するものです。
8. 本番前チェックリスト
外注でもAI製でも、リクエスト由来のURLを fetch する経路があるなら、本番投入の前に最低限これだけは確認してください。観点と危険信号を併記します。
-
fetchの引数にリクエスト由来の値が素通りしていない(汚染入力→sinkが allowlist 関門を通っている) - 行き先は**ホストの許可リスト(完全一致)**で縛っている(拒否リストの文字列マッチに依存していない)
- スキームは
https:(必要時のみhttp:)に限定し、file:gopher:data:を排除している - 名前解決した実IPを検査し、プライベート/ループバック/リンクローカル/予約帯を拒否している
- リダイレクトを自動追従していない(
redirect: "manual"。追うならLocationを再検証) - タイムアウトとサイズ上限を設定している(
AbortSignal.timeout等) - DNSリバインディングを考慮し、接続時のIP固定(
connect.lookup等)まで踏み込んでいる - エラー応答が内部の詳細(到達IP・ポート)を漏らしていない
- ネットワーク側の別経路でメタデータ網/内部セグメントへの送信を遮断している(IMDSv2等)
- taint解析(SAST)をCIに常設し、新しい
fetch経路の混入を継続検出している
発注者の視点で最も効くのは、**「ユーザーが指したURLを、サーバーが取りに行く処理はありますか?」「その行き先はどうやって制限していますか?」「169.254.169.254 を渡したらどうなりますか?」**の3問です。良い開発者は即答できます。
9. どこまで自分で、どこから監査か
最後に、正直に線を引きます。
SSRFの「検出」は、自動化で機械的に潰せます。 汚染入力が fetch に届く経路、リダイレクト追従の有無、サニタイザの妥当性——これらは静的解析がCIで番をできる領域です。まずは Aegis(無料OSS、npx @aegiskit/cli scan)で、自分のコードのどこに「汚染入力→fetchシンク」が通っているかを可視化するのが、最もコスパの良い第一歩です。
一方で、「許可先の設計」は人間の領域です。 どのホストを ALLOWED_HOSTS に入れてよいか、そもそも任意URL取得という機能を持つべきか、IMDSv2やegress制御をどう敷くか——これは、あなたのアプリの意味とインフラ構成を理解した人間にしか判断できません。ここで「導入すれば安全」と言い切る製品は、むしろ危険です。Aegisは経路とサニタイザの有無を検出・警告しますが、許可先が正しいことは証明しません。
だからこそ線引きが要ります。どこまで自分で関門を作り、どこから専門家のレビューが要るか——許可先の設計、リバインディング対策の妥当性確認、ネットワーク側の別経路設計が必要なら、セキュリティ監査で承ります。私自身、木材流通業界のDX案件で、外部サービス連携を含むサーバー間通信の境界設計と検証を実運用で行ってきました。
よくある質問(FAQ)
Q. allowlistが無く、任意のURLを取得する機能(汎用プロキシ)が要件です。どうすれば? A. その場合、アプリ層のホスト制限は使えないので、ネットワーク側の隔離が主役になります。送信専用の経路(メタデータ網・内部セグメントへ到達できないセグメント)で実行し、解決後IPの遮断・リダイレクト禁止・タイムアウト・スキーム制限・リバインディング対策を全部重ねます。それでも残留リスクは大きいので、「本当に任意URLが必要か」を要件から問い直すのが先です。
Q. redirect: "manual" にすると正規のリダイレクトも追えません。困りませんか?
A. それが正しい挙動です。リダイレクトは「別ホストへの入り直し」なので、追うなら Location をもう一度 assertSafeUrl に通す(回数上限付きで)べきです。自動追従は、許可ホストから内部への横移動を許す抜け道になります。
Q. プライベートIPを文字列で弾けば十分では?
A. 不十分です。http://2130706433/(10進)や IPv4-mapped IPv6 など、文字列の見た目は無数に化けます。だから文字列ではなく、名前解決した実IPを ipaddr.js 等で分類して判定します。さらにDNSリバインディングがあるため、接続時のIP固定まで必要です。
Q. taint解析でSSRFは「無い」と保証できますか? A. できません。解析が言えるのは「汚染入力→fetchの経路に妥当なサニタイザが無い箇所」までです。関数を跨ぐ流れや動的な組み立ては取りこぼしますし、何より許可先が業務的に正しいかは判断しません。検出は人間のレビューを補完するもので、置き換えるものではありません。
Q. 個人開発や小規模でも、ここまでやるべきですか?
A. リクエスト由来URLを fetch する経路が1本でもあるなら、最小でも「ホストallowlist+解決後IP遮断+リダイレクト禁止+タイムアウト」の関門は必ず。クラウド上なら 169.254.169.254 への到達は即座に認証情報漏洩に直結しうるため、コストの割に防げる事故が桁違いです。
まとめ:行き先は「文字列」ではなく「最終的に接続するIPとスキーム」で縛る
要点を整理します。
- SSRFは「サーバーが攻撃者の指したURLを取りに行く」欠陥(OWASP A10:2021)。サーバーが信頼境界の内側にいるため、外からは届かないメタデータ・localhost・内部APIに代わりに到達してしまう。
- Next.jsでは Server Action / Route Handler の
fetch、画像プロキシ、Webhook、OG取得など「リクエスト由来URLをfetchする」全経路が入口。Server Action もPOSTエンドポイントで、UIに出していなくても叩ける。 - 対策は多層——スキーム制限・ホストallowlist・解決後IPの遮断・リダイレクト追従禁止・タイムアウト・別経路。拒否リストは符号化やリバインディングで抜けるため、許可リストと実IP検査を主役にする。
- DNSリバインディングは検査と使用の時間差で抜ける。接続時のIPを検査する(IP固定)まで踏み込まないと閉じきらない。
- 検出は**「汚染入力→fetchシンク」のtaint解析で機械化できる。ただしツールは経路とサニタイザの有無を暴くだけで、「どのホストを許可してよいか」という設計は人間にしか決められない**。
AIで速く作ること自体は正しい。速く作ったものの「行き先」を、漏らさず縛る——その関門づくりや、既存のNext.jsアプリのSSRF・外部通信境界のレビューが必要であれば、お気軽にご相談ください。