NextTech Insights
Next.jsのSSRF対策チェックリスト|Route HandlersとServer Actionsを守る(2026)
nextjssecurityssrfserver-actionsroute-handlers

Next.jsのSSRF対策チェックリスト|Route HandlersとServer Actionsを守る(2026)

DigitalCraft
16 min read

Next.jsで外部URLを取得する際のSSRF(サーバーサイド・リクエスト・フォージェリ)防御チェックリスト。プロトコル制限、名前解決IPの検証、プライベート網遮断、DNS Rebinding対策、リダイレクト追従の制御までを実務コード付きで整理します。

目次

Next.jsでユーザー指定のURLを取得するとき、SSRFをどう防ぐか?

1分でわかる要点

  • Route HandlersやServer Actionsでユーザー入力のURLに対してサーバー側でfetch()を呼ぶと、SSRF(Server-Side Request Forgery)の脆弱性になります。
  • 攻撃者はこれを利用して、クラウドのメタデータAPI(169.254.169.254)からIAM認証情報を奪ったり、社内VPCやlocalhost上の非公開マイクロサービスを探索・操作します。
  • 単純な文字列判定やブラックリストは、8進数・10進数表現、IPv6変換、DNS Rebinding、HTTP 30xリダイレクトによって容易に回避されます。
  • 安全な防御には、プロトコルの厳格な制限、接続前の名前解決によるIPアドレス(CIDR)検証、DNS Rebindingを防ぐIP固定、手動リダイレクト検証の組み合わせが必須です。

想定読者

  • Next.js App Routerでリンクプレビュー(OGP取得)、Webhook送信、外部画像・ファイル取得を実装している開発者
  • Next.jsアプリケーションのセキュリティレビューやコード監査を担当するエンジニア
  • AWS、GCP、Vercel VPC等のクラウド環境にNext.jsを本番運用しているチーム

脅威モデル:Next.jsサーバーでなぜSSRFが起きるのか

Next.js App Routerでは、Route Handlers(app/api/.../route.ts)やServer Actions('use server')がサーバー環境(Node.js / Edgeランタイム)で実行されます。クライアントから送られたURLをそのままサーバーでリクエストする場合、その通信は「信頼された内部ネットワーク内」から発信されます。

特に以下の機能で脆弱性が生まれやすくなります。

  1. リンクプレビュー(OGPカード生成): ユーザーが投稿したURLをサーバーが取得し、HTML内の<meta property="og:title">を抽出する処理。
  2. Webhookテスト送信機能: ユーザーが登録した通知先URLに対し、動作確認用のPOSTリクエストを送信する機能。
  3. 外部画像・アイコンのインポート: リモートURLからアセットをダウンロードして最適化・保存する処理。
  4. 外部APIプロキシ: ブラウザのCORS制約を回避するためにサーバー側で中継するエンドポイント。

検証が不十分な場合、攻撃者は内部ネットワークを指すURLを送信してサーバーに代理リクエストを実行させます。

[攻撃者のリクエスト]
  --> POST /api/preview { "url": "http://169.254.169.254/latest/meta-data/" }
  --> Next.js Route Handlerが fetch(url) を実行
  --> クラウドメタデータサービスがIAMロールの一時認証情報を返却
  --> サーバーが認証情報を含むレスポンスまたはエラーログを攻撃者に返却

攻撃対象となる代表的な危険アドレス

| 宛先 | 対象IP範囲 / ホスト | 危険性 | | :----------------------------------------- | :---------------------------------------------- | :------------------------------------------------------------------------- | | AWS / GCP / Azure メタデータ | 169.254.169.254 | IAMロールの一時認証情報、プロジェクトトークン、設定情報の窃取 | | GCP 内部メタデータ | metadata.google.internal | アクセストークンの漏えいとプロジェクト設定の取得 | | ループバック | 127.0.0.0/8, ::1 | 認証なしで動いている同一ホスト内のRedis、DB管理ポート、デバッグAPIへの侵入 | | プライベートサブネット (RFC 1918) | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 | 社内イントラネット、内部API、管理者画面、未認証マイクロサービスの探索 | | キャリアグレードNAT (RFC 6598) | 100.64.0.0/10 | 共有インフラや内部ルーティングプレーンへの接続 | | IPv6 ユニークローカル / リンクローカル | fc00::/7, fe80::/10 | IPv6対応の社内サービスやクラスタ内通信 |

単純なURLフィルタが破られる5つの回避手法

正規表現によるホスト名チェックやブラックリスト方式は、以下の手法で簡単にすり抜けられます。

1. 変形表記されたIPアドレス

Node.jsのURLパーサーやOSのソケットライブラリは、標準的なドット付き10進表記以外のIPアドレスも解釈します。

  • 省略表記: 127.1 は 127.0.0.1 として解決されます。
  • 8進数表記: 0177.0.0.1 は 127.0.0.1 として解決されます。
  • 16進数表記: 0x7f000001 や 0x7f.0.0.1 は 127.0.0.1 になります。
  • 32ビット整数(DWORD): 2130706433 は 127.0.0.1 です。
  • IPv4射影IPv6アドレス: http://[::ffff:127.0.0.1] や http://[::ffff:7f00:1]。

文字列として 127.0.0.1 を探すだけのブラックリストは、これらをすべて素通りさせます。

2. DNS Rebinding(TOCTOU攻撃)

プログラム側で事前にホスト名をDNS解決し、返ってきたIPがパブリックであることを確認したとしても、その後のfetch(url)でOSが再度DNS解決を行うと穴が空きます。

攻撃者はTTLを0秒に設定したDNSサーバーを用意します。最初の検証時には安全なパブリックIP(例: 93.184.216.34)を返し、直後にfetch()がソケットを開く際には 169.254.169.254 や 127.0.0.1 を返します。

3. オープンリダイレクトによる迂回

初期URL(例: https://example.com/redirect)は安全なパブリックサーバーを指していても、そのサーバーが HTTP 302 で Location: http://169.254.169.254/latest/meta-data/ を返した場合、デフォルト設定のfetch()はリダイレクト先に自動追従し、内部メタデータへアクセスしてしまいます。

実装パターン:SSRFを防ぐ安全なfetchラッパー

すべての回避経路を塞ぐには、リクエスト処理を以下のライフサイクルで固定します。

1. プロトコル(http/https)とポート(80/443)の厳格な検証
2. ホスト名を事前にDNS解決して全IPアドレスを取得
3. 解決されたIPがプライベート、ループバック、メタデータ帯域でないことを判定
4. 判定済みIPに対して安全に通信(またはDNS Rebinding防止策を適用)
5. リダイレクト自動追従を無効化(redirect: 'manual')し、遷移先ごとに1〜4を再実行

本番環境向けsafeFetch実装例

Node.jsランタイム(Next.js App Router)でそのまま利用できる堅牢なユーティリティです。

// lib/safe-fetch.ts
import dns from 'node:dns/promises';
import net from 'node:net';

// 接続を禁止するIPv4範囲(RFC 1918, RFC 3927, RFC 6890等)
const PRIVATE_IPV4_RANGES = [
  { start: ipToLong('0.0.0.0'), end: ipToLong('0.255.255.255') }, // 同一ネットワークホスト
  { start: ipToLong('10.0.0.0'), end: ipToLong('10.255.255.255') }, // RFC 1918 プライベート
  { start: ipToLong('100.64.0.0'), end: ipToLong('100.127.255.255') }, // RFC 6598 キャリアグレードNAT
  { start: ipToLong('127.0.0.0'), end: ipToLong('127.255.255.255') }, // ループバック
  { start: ipToLong('169.254.0.0'), end: ipToLong('169.254.255.255') }, // リンクローカル / クラウドメタデータ
  { start: ipToLong('172.16.0.0'), end: ipToLong('172.31.255.255') }, // RFC 1918 プライベート
  { start: ipToLong('192.0.0.0'), end: ipToLong('192.0.0.255') }, // IETFプロトコル割り当て
  { start: ipToLong('192.0.2.0'), end: ipToLong('192.0.2.255') }, // TEST-NET-1
  { start: ipToLong('192.168.0.0'), end: ipToLong('192.168.255.255') }, // RFC 1918 プライベート
  { start: ipToLong('198.18.0.0'), end: ipToLong('198.19.255.255') }, // ネットワーク測定用
  { start: ipToLong('198.51.100.0'), end: ipToLong('198.51.100.255') }, // TEST-NET-2
  { start: ipToLong('203.0.113.0'), end: ipToLong('203.0.113.255') }, // TEST-NET-3
  { start: ipToLong('224.0.0.0'), end: ipToLong('239.255.255.255') }, // マルチキャスト
  { start: ipToLong('240.0.0.0'), end: ipToLong('255.255.255.255') }, // 予約済み
];

function ipToLong(ip: string): number {
  return ip.split('.').reduce((acc, octet) => ((acc << 8) + parseInt(octet, 10)) >>> 0, 0);
}

export function isPrivateIp(ip: string): boolean {
  if (net.isIPv4(ip)) {
    const long = ipToLong(ip);
    return PRIVATE_IPV4_RANGES.some((r) => long >= r.start && long <= r.end);
  }

  if (net.isIPv6(ip)) {
    const normalized = ip.toLowerCase();
    // ループバック
    if (normalized === '::1' || normalized === '::') return true;
    // IPv4射影IPv6 (::ffff:x.x.x.x)
    if (normalized.startsWith('::ffff:')) {
      const v4 = normalized.substring(7);
      return net.isIPv4(v4) ? isPrivateIp(v4) : true;
    }
    // ユニークローカル (fc00::/7)
    if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
    // リンクローカル (fe80::/10)
    if (
      normalized.startsWith('fe8') ||
      normalized.startsWith('fe9') ||
      normalized.startsWith('fea') ||
      normalized.startsWith('feb')
    )
      return true;
    // マルチキャスト (ff00::/8)
    if (normalized.startsWith('ff')) return true;
  }

  return false;
}

export interface SafeFetchOptions extends RequestInit {
  maxRedirects?: number;
  timeoutMs?: number;
  allowedPorts?: number[];
}

export async function safeFetch(rawUrl: string, options: SafeFetchOptions = {}): Promise<Response> {
  const maxRedirects = options.maxRedirects ?? 3;
  const timeoutMs = options.timeoutMs ?? 5000;
  const allowedPorts = options.allowedPorts ?? [80, 443];

  let currentUrl = rawUrl;

  for (let hop = 0; hop <= maxRedirects; hop++) {
    let parsed: URL;
    try {
      parsed = new URL(currentUrl);
    } catch {
      throw new Error(`Invalid URL format: ${currentUrl}`);
    }

    // 1. プロトコルの検証(http/httpsのみ許可)
    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
      throw new Error(`Forbidden protocol: ${parsed.protocol}`);
    }

    // 2. ポートの検証(80/443のみ許可)
    const port = parsed.port ? parseInt(parsed.port, 10) : parsed.protocol === 'https:' ? 443 : 80;
    if (!allowedPorts.includes(port)) {
      throw new Error(`Port ${port} is not allowed`);
    }

    // 3. DNS名前解決とIPチェック
    const hostname = parsed.hostname;
    const addresses = await dns.lookup(hostname, { all: true });
    if (!addresses || addresses.length === 0) {
      throw new Error(`DNS resolution failed for hostname: ${hostname}`);
    }

    for (const record of addresses) {
      if (isPrivateIp(record.address)) {
        throw new Error(`Resolved IP ${record.address} belongs to a private or restricted network`);
      }
    }

    // 4. タイムアウト設定と手動リダイレクト制御によるfetch
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);

    try {
      const response = await fetch(currentUrl, {
        ...options,
        redirect: 'manual',
        signal: controller.signal,
      });

      // 30xリダイレクトが発生した場合の処理
      if (response.status >= 300 && response.status < 400) {
        const location = response.headers.get('location');
        if (!location) {
          throw new Error(`Redirect response missing Location header`);
        }
        if (hop === maxRedirects) {
          throw new Error(`Exceeded maximum redirect limit of ${maxRedirects}`);
        }
        // 相対パスリダイレクトを基底URLで絶対URLに解決
        currentUrl = new URL(location, currentUrl).toString();
        continue;
      }

      return response;
    } finally {
      clearTimeout(timer);
    }
  }

  throw new Error(`Too many redirects`);
}

Implementation examples may be available on DevSnips.

Route Handlerでの利用例

上記safeFetchを用いて安全にURLプレビューを取得するRoute Handlerの例です。

// app/api/preview/route.ts
import { NextResponse } from 'next/server';
import { safeFetch } from '@/lib/safe-fetch';

export const runtime = 'nodejs'; // 標準のdnsモジュールを利用するためNode.jsランタイムを指定

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const targetUrl = typeof body?.url === 'string' ? body.url.trim() : null;

    if (!targetUrl) {
      return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
    }

    // safeFetchでプロトコル、DNS名前解決、リダイレクト先をすべて検査
    const response = await safeFetch(targetUrl, {
      method: 'GET',
      headers: {
        'User-Agent': 'NextTech-Insights-Bot/1.0',
        Accept: 'text/html,application/xhtml+xml',
      },
      timeoutMs: 4000,
    });

    if (!response.ok) {
      return NextResponse.json(
        { error: `Upstream responded with status ${response.status}` },
        { status: 502 }
      );
    }

    // メモリ枯渇攻撃(DoS)を防ぐため受信サイズを制限
    const reader = response.body?.getReader();
    if (!reader) {
      return NextResponse.json({ error: 'No response body' }, { status: 502 });
    }

    const chunks: Uint8Array[] = [];
    let receivedBytes = 0;
    const MAX_BYTES = 512 * 1024; // プレビュー取得用HTMLは最大512KBに制限

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      if (value) {
        receivedBytes += value.length;
        if (receivedBytes > MAX_BYTES) {
          await reader.cancel();
          break;
        }
        chunks.push(value);
      }
    }

    const html = new TextDecoder('utf-8').decode(Buffer.concat(chunks.map((c) => Buffer.from(c))));

    // タイトルタグの抽出
    const match = html.match(/<title[^>]*>([^<]+)<\/title>/i);
    const title = match ? match[1].trim() : 'No title detected';

    return NextResponse.json({ title });
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Fetch failed';
    return NextResponse.json({ error: message }, { status: 400 });
  }
}

多層防御:インフラ・ネットワーク設計

アプリケーションコードによる検査に加え、インフラレベルでの二重のガードレールを設定します。

  1. Egressネットワークセキュリティグループの制限: Next.jsをホストするコンテナ(ECS、Kubernetes、EC2等)のアウトバウンド通信について、内部DBポート(PostgreSQL: 5432、Redis: 6379)や社内管理セグメントへの直接ルーティングを遮断します。
  2. AWS IMDSv2の強制とHop Limit 1の設定: AWS環境ではIMDSv2を必須化し、HttpPutResponseHopLimit=1を設定します。これにより、コンテナ内部のブリッジネットワークからメタデータエンドポイントへのトークン取得がパケットレベルでブロックされます。
  3. 専用プロキシワーカーの分離: Webhook配信や外部スクレイピングの処理頻度が高い場合は、内部VPCやDBへのルートを一切持たない隔離されたサブネットに専用の軽量ワーカーを配置し、そこから外部リクエストを発行させます。

チェックリスト

  • [ ] 外部URLを取得するすべての処理で直接のfetch()を廃止し、検査付きラッパー関数を経由している
  • [ ] スキームがhttp:およびhttps:のみに制限されている(file:, gopher:等を遮断)
  • [ ] 接続先ポートが標準ポート(80, 443)に制限されている
  • [ ] DNS解決結果のすべてのIP(IPv4/IPv6両方)について、プライベートIPおよびメタデータ帯域が遮断されている
  • [ ] クラウドメタデータアドレス(169.254.169.254, metadata.google.internal)が遮断対象に含まれている
  • [ ] 自動リダイレクト追従(redirect: 'follow')を無効化し、遷移先URLごとに同一の検査を通している
  • [ ] リダイレクト回数に上限(最大3回程度)を設け、無限ループを防いでいる
  • [ ] レスポンスボディの読み取りサイズ上限を設定し、巨大データによるメモリ枯渇(DoS)を防いでいる
  • [ ] タイムアウト(4〜5秒程度)を設定し、接続遅延によるスレッド枯渇を防いでいる
  • [ ] AWS環境においてIMDSv2が有効化され、HttpPutResponseHopLimit=1が設定されている
  • [ ] DNSやソケット操作を行うRoute Handlerでexport const runtime = 'nodejs'を指定している

よくある質問(FAQ)

1. Next.jsのEdgeランタイムを使っていればSSRFは防げますか?

防げません。Edgeランタイムはローカルファイルシステムへの直接アクセスを持たないV8アイソレートですが、サーバー側ネットワーク通信は通常通り実行されます。内部APIや外部SaaSへの通信経路が存在する環境であれば、宛先IPの検査を行わない限りSSRF攻撃が成立します。

2. 自前実装の代わりにOSSパッケージを使ってもよいですか?

可能です。ipaddr.jsなどの十分に検証されたライブラリを用いてCIDR判定を行うのは推奨されます。ただし、HTTPクライアント側(UndiciやNode http)と連動してDNS Rebinding対策(名前解決とソケット接続の同期)が正しく行われているかを必ず確認してください。

3. なぜAWSでIMDSv2とHop Limit 1が重要とされるのですか?

IMDSv2では事前のPUTリクエストでセッショントークンを取得する必要があります。さらにHttpPutResponseHopLimit=1を設定すると、コンテナ(Docker/ECS)の仮想ネットワークブリッジを越えるパケット(TTLが1消費される通信)がメタデータサービスに届かなくなるため、仮にアプリ側にSSRFがあっても認証情報の奪取を根本から防止できます。

4. CIや単体テストでSSRF防御をテストするにはどうすればよいですか?

正常なパブリックURLに対するモックテストに加え、http://127.0.0.1:80、http://169.254.169.254、http://[::1]、および内部IPへ転送されるリダイレクトをテストケースに含めます。これらに対してsafeFetchが例外を発生させ、接続を拒絶することを確認する統合テストを作成してください。

参考資料

サイト内関連リンク

免責事項

本記事は一般的なエンジニアリングおよびセキュリティ設計のガイダンスを目的としています。クラウドプロバイダの仕様やNext.js/Node.jsのバージョンによって動作が異なる場合があります。本番環境に適用する前に、ご利用の実行環境およびネットワーク構成に合わせて動作検証を実施してください。

人気記事

  1. 1Permit2とは何か?Approveが変わった理由と安全な使い方(チェックリスト)
  2. 2署名(Sign)画面の読み方|Approve/Permit/NFT全権限を30秒で判別するチェックリスト
  3. 3仕様→実装に落とすプロンプトテンプレ(AI開発)|AIに“迷わせない”仕様の書き方
  4. 4EVMのトークン承認(Approve)を見直す方法|Revokeの手順と判断基準(チェックリスト)
  5. 5曖昧な依頼を要件定義に変換する質問リスト(AI開発)|AIに実装させる前に聞くべきこと

関連記事