はじめに — 「URLを入力したらフェッチする」の危険性
ZERONOVA LAB の無料ツールには、ユーザーが入力したURLのコンテンツを取得するツールがいくつかあります。OGPチェッカー、リンク切れチェッカー、ページ速度チェッカー、Xカードプレビュー。
「URLを受け取って、そのURLにリクエストを送って、結果を返す」。シンプルな仕組みですが、ここにSSRF(Server-Side Request Forgery)という深刻なセキュリティリスクが潜んでいます。
2026年2月11日、Phase 15のSEO・マーケティングツール開発中に、docs/tools-dev-guidelines.md のセキュリティチェックリストを監査した結果、自分たちのAPIルートにSSRF脆弱性があることが判明しました。この記事では、問題の発見から対策の実装までを解説します。
SSRFとは何か
SSRF は、サーバーサイドで外部URLへのリクエストを行うAPIに対して、攻撃者が内部ネットワークのURLを指定することで、本来アクセスできないリソースにアクセスする攻撃手法です。
たとえば、OGPチェッカーのAPIに http://169.254.169.254/latest/meta-data/ (AWSのメタデータエンドポイント)を入力されると、サーバーからAWSのインスタンスメタデータ(IAMロールの認証情報を含む)を取得できてしまいます。
http://127.0.0.1:3000/api/internal のような内部APIへのアクセスも同様です。
問題の発見
Phase 15で5つの新ツール(サイトマップXMLジェネレーター、.htaccessジェネレーター、リンク切れチェッカー、ページ速度チェッカー、X カードプレビュー)をClaude Codeで実装した後、開発ガイドラインのチェックリストに沿ってセキュリティレビューを実施しました。ZERONOVA LAB では docs/tools-dev-guidelines.md に全ツール共通のセキュリティチェックリスト(2-A: 入力バリデーション、2-B: APIルート固有)を定めており、ツール実装後に必ず準拠確認を行うプロセスを運用しています。
tools-dev-guidelines.md の監査でSSRF脆弱性を発見。初期実装は redirect: "follow" だった。リダイレクトチェーンで内部IP(127.0.0.1)にアクセスされる可能性があった
初期実装では fetch(url, { redirect: "follow" }) としていました。これはリダイレクトを自動的に追従する設定です。URL入力時のバリデーション(プロトコル制限、内部IPブロック)は実装済みでしたが、リダイレクトチェーンでバイパスされるケースが考慮されていませんでした。
問題は、https://example.com/redirect → http://127.0.0.1:3000/api/internal のようなリダイレクトチェーンが成立した場合です。最初のURLの検証を通過した後、リダイレクト先が内部IPであっても自動的にアクセスしてしまいます。
対策の設計
http:// / https:// のみ許可、内部IPブロック)はすでに入れている。問題はリダイレクトでバリデーションがバイパスされること。redirect: "manual" に変更し、リダイレクトを手動で処理するアプローチが有効です。各ホップで Location ヘッダーからリダイレクト先URLを取得し、そのURLに対して再度バリデーションを行います。最大ホップ数を制限すれば、無限リダイレクトループも防げます。対策のポイントは以下の3つです。
redirect: "manual"でリダイレクトの自動追従を無効化- 各ホップで
isValidUrl()を呼び出してリダイレクト先を検証 - 最大5ホップでリクエストを打ち切り
実装パターン
核心部分の実装は以下のようになります。
function isValidUrl(url: string): boolean {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) return false;
const hostname = parsed.hostname;
if (hostname === "localhost" || hostname === "127.0.0.1") return false;
if (hostname.startsWith("10.") || hostname.startsWith("192.168.")) return false;
if (hostname.startsWith("169.254.")) return false;
// ... 他の内部IP範囲もブロック
return true;
}
async function safeFetch(url: string, maxHops = 5) {
let currentUrl = url;
for (let i = 0; i < maxHops; i++) {
if (!isValidUrl(currentUrl)) {
throw new Error("Invalid URL detected in redirect chain");
}
const res = await fetch(currentUrl, { redirect: "manual" });
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get("location");
if (!location) throw new Error("Redirect without Location header");
currentUrl = new URL(location, currentUrl).toString();
continue;
}
return res;
}
throw new Error("Too many redirects");
}
redirect: "manual" を指定すると、3xx レスポンスが返ってきてもリダイレクトを追従せず、そのままレスポンスオブジェクトが返ります。Location ヘッダーからリダイレクト先URLを取得し、isValidUrl() で検証してから次のリクエストを送信します。
既知のブロックドメインへの対応
SSRF対策とは別に、リンク切れチェッカーの実装中に「正常なのにエラーになるドメイン」の問題にも遭遇しました。
X/Twitter、Instagram、Facebook、LinkedIn、TikTok はサーバーサイドからのリクエストに 403 を返す。KNOWN_BLOCKED_DOMAINS 定数で「要確認」として区別表示
X(Twitter)、Instagram、Facebook などの主要SNSは、サーバーサイドからの fetch リクエストに対して403を返します。ユーザーがブラウザでアクセスすれば正常に表示されるのに、APIからアクセスするとブロックされるのです。
これをリンク切れ(赤色エラー)として表示すると、ユーザーを混乱させます。KNOWN_BLOCKED_DOMAINS という定数に主要SNSドメインを列挙し、UIでは「要確認」(アンバー色)として表示する処理をClaude Codeで実装しました。API側では、これらのドメインで403/401が返った場合にレスポンスに warning フィールドを付与し、UI側ではサマリーに「要確認」カウントをエラーとは別枠で表示します。フィルタタブにも「要確認」を追加し、ユーザーが本当のリンク切れと区別して確認できるようにしました。
リストに含めるドメインの範囲設定も重要な判断でした。リストに入れすぎると本当のエラーを見逃すリスクがあるため、実際にサーバーサイドブロックが確認されている主要SNS(X/Twitter、Instagram、Facebook、LinkedIn、TikTok)に限定しています。
ドッグフーディングで発見した本物のバグ
完成したリンク切れチェッカーを自分のサイト(zeronova-lab.com)でテストしたところ、予想外の問題が見つかりました。
ドッグフーディングで発見: x.com/zeronova_pm(旧アカウント)へのリンクが3ファイルに残っていた。Footer、licenses/page、JSON-LD の sameAs を修正。twitter.com/intent/tweet → x.com/intent/tweet にも更新
旧Xアカウント(zeronova_pm)へのリンクが Footer、ライセンスページ、JSON-LD の sameAs に残っていました。403が返る原因を調べた結果、Xがサーバーサイドリクエストをブロックしているだけでなく、zeronova_pm 自体が存在しないアカウントだと判明しました。誤検知と実際のバグが混在していたのです。自分で作ったツールを自分のサイトに使ってみることで、手動チェックでは見落としていた問題が発見できました。
x.com/zeronova_lab に修正します。合わせて、シェアボタンの twitter.com/intent/tweet も x.com/intent/tweet に更新します。PageSpeed API の設定
ページ速度チェッカーでは Google PageSpeed Insights API を使っています。APIキーなしでも動作しますが、レート制限が厳しい(約100リクエスト/日)ため、本番環境ではAPIキーの設定が必要でした。
Google Cloud Console で PageSpeed Insights API を有効化、APIキーを発行。キーなし: ~100リクエスト/日 → キーあり: 25,000リクエスト/日。Vercel Dashboard に PAGESPEED_API_KEY として設定
Google Cloud Console でAPIを有効化し、キーを発行。Vercel Dashboard の環境変数に PAGESPEED_API_KEY として設定しました(Production / Preview / Development の全環境)。これにより1日25,000リクエストまで対応できるようになり、複数ユーザーが同時にツールを使っても安定して動作します。
PageSpeed API のレスポンスは通常10-25秒と遅いため、ユーザー体験としてはローディングアニメーションとプログレス表示で離脱を防止する工夫もClaude Codeで実装しています。
まとめ — 外部URLフェッチ系APIのセキュリティチェックリスト
外部URLをフェッチするAPIルートを実装する際のチェックリストをまとめます。
- プロトコル制限:
http:/https:のみ許可。file:、data:、javascript:はブロック - 内部IPブロック:
127.0.0.1、10.x.x.x、192.168.x.x、169.254.x.x等を拒否 redirect: "manual"を使う: リダイレクトの自動追従を無効化し、各ホップでURLを再検証- 最大ホップ数を制限: 5回を超えるリダイレクトチェーンは打ち切り
- レスポンスサイズの制限: 巨大なレスポンスによるメモリ枯渇を防ぐ
- タイムアウトの設定: 応答しないサーバーへのリクエストを打ち切る
- 既知のブロックドメインの区別: SNS等のサーバーサイドブロックは「要確認」として表示
- レート制限: IPベースのスライディングウィンドウで外部フェッチ系は10回/分に制限
SSRF はOWASP Top 10の2021年版でも取り上げられている脆弱性です。「URLを入力してフェッチする」という一見シンプルな機能に、これだけのセキュリティ考慮が必要だということを、この経験で学びました。
関連記事:
- 個人開発でもやるべきセキュリティ監査チェックリスト — セキュリティ監査の全体像
- Cloudflare Turnstile で認証なしフォームをボットから守る — ボット対策の別アプローチ
- 個人開発サイトに74個の無料ツールを作ってSEO流入を増やした話 — ツール戦略と品質管理
Zeronova(ゼロノバ)
Product Manager / AI-Native Builder
Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。