このブログの記事内に画像を埋め込むための API(/api/embed-image)は、サーバーから外部の URL を取りに行きます。こういう「サーバーが代わりに URL を取得する」処理は、内部のアドレスに向けられると SSRF(サーバーサイドリクエストフォージェリ)になります。2026年11月5日に、この取得処理の宛先チェックを見直しました。修正は Claude Code(AI エージェント)と進めたもので、この記事では修正前と修正後のコードを同じ入力で実行して、結果がどう変わったかを書きます。
検証環境
- OS: macOS 26.7.2(ビルド 25G332) / Apple Silicon(darwin arm64)
- Node.js v26.12.1
- Next.js 16.4.2 / React 19.2.8
- 比較対象: 修正前
56254b5^、修正後origin/mainのlib/safe-fetch.ts - 検証日: 2026年11月7日
何を直したか
取得処理は lib/safe-fetch.ts にまとめてあります。http / https 以外のスキームを拒否し、内部向けのホストを拒否し、リダイレクトは自動で追わず、遷移先を1ホップずつ検証してから取得します。直したのは、このうち「内部向けのホストか」を判定する部分です。
修正前は、ホスト名の文字列を見て判定していました。10. や 127. で始まるか、192.168. か、fe80:・fc・fd で始まるか、といった具合です。修正後は Node 標準の net.BlockList にレンジを登録し、IP アドレスとして判定するようにしました。さらに、ホスト名は DNS で解決してから、返ってきた全ての IP を判定します。
修正前後を同じ入力で比べた
13個の URL を、修正前と修正後の rejectTarget() に渡して結果を並べました。使ったスクリプトの要点は次のとおりです。
import * as before from "./before.ts"; // 56254b5^ の lib/safe-fetch.ts
import * as after from "./after.ts"; // origin/main の lib/safe-fetch.ts
const row = (r: string | null) => (r === null ? "許可" : `拒否(${r})`);
for (const t of targets) {
const b = row(before.rejectTarget(new URL(t)));
const a = row(after.rejectTarget(new URL(t)));
console.log(t.padEnd(44), b.padEnd(24), a, b !== a ? " <- 変化" : "");
}結果は次の表のとおりです。13件中、結果が変わったのは4件でした(Node v26.12.1 / darwin arm64 で実行)。
対象 | 修正前 | 修正後 |
|---|---|---|
| 許可 | 拒否 |
| 許可 | 拒否 |
| 許可 | 拒否 |
| 許可 | 拒否 |
127.0.0.1 / localhost / 10.0.0.1 / 192.168.1.1 / 169.254.169.254 / [::1] / 0.0.0.0 / ftp | 拒否 | 拒否(変化なし) |
| 許可 | 許可 |
127.0.0.1 や 10.x のような「よくある内部アドレス」は、修正前から拒否できていました。抜けていたのは、書き方を変えた内部アドレスと、あまり意識しないレンジです。
なぜ抜けたのか
理由は、判定が文字列のプレフィックス比較だったことです。たとえば http://[::ffff:127.0.0.1]/ は、new URL() を通すと hostname が [::ffff:7f00:1] に正規化されます。127. で始まらないので、IPv4 の判定をすり抜けます。IPv6 のルールも fe80:・fc・fd で始まるものだけでした。メタデータ宛の [::ffff:169.254.169.254] も同じ理由で通りました。
CGNAT(100.64.0.0/10、RFC 6598)と、NAT64 用のレンジ(64:ff9b::/96、RFC 6052)は、そもそもルールがありませんでした。
逆に、塞ぎすぎてもいた
修正前の fc・fd の判定は「ホスト名がその文字列で始まるか」でした。IPv6 のアドレスだけでなく、ドメイン名にも当たります。同じスクリプトで流すと、次のとおりでした。
対象 | 修正前 | 修正後 |
|---|---|---|
| 拒否(Blocked host) | 許可 |
| 拒否(Blocked host) | 許可 |
| 許可 | 許可 |
内部アドレスではない公開サイトが、名前の先頭が fc や fd というだけで拒否されていました。セキュリティの判定は「通しすぎ」だけでなく「塞ぎすぎ」も問題になります。
どう直したか
修正後は、レンジを BlockList に登録し、isIP() で IPv4 か IPv6 かを判定してからチェックします(Node.js のドキュメント)。
import { BlockList, isIP } from "node:net";
const blockedRanges = new BlockList();
blockedRanges.addSubnet("100.64.0.0", 10, "ipv4"); // CGNAT
blockedRanges.addSubnet("169.254.0.0", 16, "ipv4"); // link-local(クラウドメタデータ含む)
blockedRanges.addSubnet("64:ff9b::", 96, "ipv6"); // NAT64
export function isBlockedIp(ip: string): boolean {
const family = isIP(ip);
if (family === 0) return true; // 解釈できないものは拒否側に倒す
return blockedRanges.check(ip, family === 6 ? "ipv6" : "ipv4");
}ポイントは2つあります。1つ目は、BlockList が IPv4-mapped IPv6 を IPv4 のルールで判定してくれることです。コードのコメントにもそう書いてあります。2つ目は、判定できない文字列は拒否側に倒していることです。
もう1つの変更が、ホスト名の DNS 解決です。localtest.me のように、公開ドメインなのに内部の IP へ解決されるホストがあるためです。取得前に全ての A / AAAA レコードを調べ、1つでも内部レンジなら拒否します。この DNS 解決にも、取得と同じ制限時間を付けました(0135635)。応答しない DNS にリクエストを占有されないようにするためです。
つまずいたこと:比較の基準を間違えた
最初に比較したとき、「修正前」に ef1d5f4 を指定して、結果が13件中0件の変化になりました。ef1d5f4 は修正を取り込んだ後のコミットだったためです。修正前は、修正コミット 56254b5 の1つ前(56254b5^)です。「差が出ない」ときは、基準にしたコミットがどちらの側なのかをまず確認する、と覚えておきます。
まだ残っていること
- DNS リバインディングは、理論上残ります。検証のあとに
fetchが改めて名前解決するため、解決結果が切り替わると防げません。コードのコメントにも「Node 標準 fetch では接続先 IP を固定できない」と書いてあります。 - この記事で動かして確かめたのは、
rejectTarget()の IP・ホスト判定です。DNS 解決後の判定と、画像 API のレスポンス側の対策(SVG を返さない、サンドボックス用の CSP)は、今回は動かして確かめていません。
まとめ
文字列のプレフィックス比較による判定は、よくある内部アドレスは防げても、書き方の違いやレンジの抜けに弱いことが、修正前後の比較で確認できました。13件中4件が「許可」から「拒否」に変わり、判定を BlockList に置き換えるだけで、抜けていた CGNAT・IPv4-mapped IPv6・NAT64 を塞げます。自分のサーバーで外部 URL を取得する処理がある場合は、同じ入力で修正前後を比べるスクリプトを書くと、抜けが目に見えます。



