本文へスキップ
CodeAuditAgent
すべての記事

Webhook・リンクプレビュー・URL取得機能のSSRF対策

SSRFがWebhookやリンクプレビュー、インポート機能をクラウドメタデータや内部サービスへの経路に変える仕組みと有効な防御をNode.jsの例で。

· 7分で読めます · Lina Source LLC

ユーザーからURLを受け取り、サーバーからそれを取得する機能はすべて、サーバーサイドリクエストフォージェリ(SSRF、CWE-918)の候補です。Webhook、リンクプレビュー、URLからのアバター取得、RSSやカレンダーのインポート、PDFレンダラー、「URLからインポート」ボタンは、いずれも同じ形をしています。あなたのサーバーが、他人の選んだ宛先にリクエストを送るのです。

問題はサーバーの置かれている位置です。サーバーは攻撃者が到達できないものに到達できます。クラウドのメタデータサービス、内部の管理画面、プライベートネットワーク上のパスワードなしのデータベース、localhostで動くサービスなどです。SSRFは、あなたのサーバーをそのネットワークへの攻撃者のプロキシに変えます。小さなチームほど混入しやすいのは、原因となる機能が無害に見えるからです。設定ページのURL入力欄、チャットのプレビューカード、プロフィール画像をダウンロードするヘルパー。どれもネットワークセキュリティのコードには見えないため、そのような観点でレビューされることがめったにありません。

攻撃者が狙うもの

  • クラウドのメタデータエンドポイント。最も有名なのはAWS・GCP・Azureの169.254.169.254で、インスタンス情報や、構成によってはマシンのロールの一時的な認証情報を返します。
  • localhostにバインドされたサービス。ローカルからの呼び出ししかないと想定した管理インターフェース、デバッグサーバー、メトリクスのエンドポイントなど。
  • プライベートレンジ(10.0.0.0/8、172.16.0.0/12、192.168.0.0/16)上の内部サービス。外部から到達される想定がなかったため、認証がありません。
  • ポートスキャンとサービス探索。応答時間やエラーメッセージを手がかりに内部ネットワークを把握されます。
  • HTTP以外のプロトコル。取得に使うライブラリがfile:、gopher:、ftp:などのスキームに対応している場合です。

レスポンスが攻撃者に返らない場合でも、ブラインドSSRFによって内部エンドポイントに対する状態変更リクエストを発生させられます。Webhookはしばしばブラインドですが、だからといって安全にはなりません。多くの内部サービスは、キャッシュのパージやURL越しの管理操作のように、単純なGETリクエストで状態を変えます。外部の誰も到達できないという前提が、まさにその理由です。

単純なチェックが破られる理由

まず思いつくのは、URLをパースしてlocalhostのようなホスト名や10.、192.168.で始まる文字列を拒否することです。これはいくつもの理由で失敗します。

  • ホスト名はIPに解決されます。攻撃者はAレコードが127.0.0.1や169.254.169.254を指すドメインを登録でき、ホスト名のチェックは何も異常を見つけられません。
  • IPアドレスには多くの表記があります。10進数(2130706433)、16進数、127.1のような短縮形、::ffff:127.0.0.1のようなIPv4射影IPv6などです。文字列照合では取りこぼします。
  • DNSリバインディング:検証した時点では公開IPに解決され、その直後にHTTPクライアントが接続する時点ではプライベートIPに解決されます。検証と接続は別々の名前解決です。
  • リダイレクト:検証済みのURLがhttp://169.254.169.254/への302を返し、HTTPクライアントが断りなくそれに従います。

共通しているのは、実際にソケットが接続するアドレス以外のものを検証しているという点です。修正の要点は、リダイレクト後も含め、すべての接続について接続時点で解決済みのIPを確認することです。

防御策、強い順に

可能なら宛先を許可リストにする

その機能が既知のホスト群、たとえば数社のプロバイダーとの連携としか通信しないのであれば、それらのホスト名を許可リストにして、他はすべて拒否してください。これが最も強力で、最も理解しやすい対策です。パースしたホスト名は完全一致で比較し、startsWithやendsWithによる判定は使わないでください。api.example.com.attacker.netやevilexample.comのような紛らわしいホストを通してしまいます。以降の内容は、任意の公開URLを受け付けなければならない機能のためのものです。

スキームとポートを制限する

https:のみを受け付けます(やむを得ない場合のみhttp:も)。URL内の認証情報は拒否し、明確な必要がない限りポートは443と80に限定します。パースには正規表現ではなく標準のURLパーサーを使ってください。WHATWGのパーサーは変わったIP表記も正規化するため、後続のチェックにも役立ちます。

名前解決し、接続時にIPを確認する

ループバック、プライベート、リンクローカル、キャリアグレードNAT、未指定のアドレス、およびIPv6のユニークローカルとリンクローカルのレンジをブロックします。重要なのは、HTTPクライアントが使うDNSルックアップの内部でチェックを行い、検証したアドレスと接続するアドレスを一致させることです。これでDNSリバインディングの隙が塞がります。Nodeのhttpとhttpsのモジュールは、まさにこのためにカスタムのlookup関数を受け取れます。

下のブロックリストは、多くの環境で重要になるレンジを網羅しています。自社インフラに属する公開レンジがあれば追加してください。公開IPを持つロードバランサーや内部APIでも、ネットワーク内部からのリクエストを信頼していることがあるからです。ホスト名は、解決されたアドレスのいずれかがブロック対象なら拒否してください。最初の1つだけでは不十分です。クライアントがどの順序で試すかわからないからです。

// safe-lookup.js
import dns from "node:dns";
import net from "node:net";

const blocked = new net.BlockList();
blocked.addSubnet("0.0.0.0", 8, "ipv4");
blocked.addSubnet("10.0.0.0", 8, "ipv4");
blocked.addSubnet("100.64.0.0", 10, "ipv4");
blocked.addSubnet("127.0.0.0", 8, "ipv4");
blocked.addSubnet("169.254.0.0", 16, "ipv4");
blocked.addSubnet("172.16.0.0", 12, "ipv4");
blocked.addSubnet("192.168.0.0", 16, "ipv4");
blocked.addAddress("::", "ipv6");
blocked.addAddress("::1", "ipv6");
blocked.addSubnet("::ffff:0:0", 96, "ipv6"); // IPv4射影
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");

export function isBlockedIp(address, family) {
  return blocked.check(address, family === 6 ? "ipv6" : "ipv4");
}

// ソケットが接続する前に、解決されたすべてのアドレスを検証する
export function safeLookup(hostname, options, callback) {
  dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
    if (err) return callback(err);
    const denied =
      addresses.length === 0 ||
      addresses.some((a) => isBlockedIp(a.address, a.family));
    if (denied) {
      return callback(new Error("Destination not allowed: " + hostname));
    }
    if (options.all) return callback(null, addresses);
    callback(null, addresses[0].address, addresses[0].family);
  });
}

見落としやすい点が1つあります。URLにIPリテラルが含まれる場合、Nodeはlookupを呼ばずに直接接続します。そのため、リクエスト関数側で処理を渡す前にIPリテラルを自分で確認しなければなりません。

import https from "node:https";
import net from "node:net";
import { isBlockedIp, safeLookup } from "./safe-lookup.js";

export function postWebhook(rawUrl, payload) {
  const url = new URL(rawUrl);
  if (url.protocol !== "https:") throw new Error("Only https is allowed");
  if (url.port && url.port !== "443") throw new Error("Port not allowed");
  if (url.username || url.password) throw new Error("Credentials not allowed");

  const host = url.hostname.replace(/^\[|\]$/g, "");
  const family = net.isIP(host);
  if (family !== 0 && isBlockedIp(host, family)) {
    throw new Error("Destination not allowed");
  }

  return new Promise((resolve, reject) => {
    const req = https.request(
      url,
      {
        method: "POST",
        lookup: safeLookup,
        timeout: 5000,
        headers: { "content-type": "application/json" },
      },
      (res) => {
        // node:httpsはリダイレクトを追わない。3xxは失敗として扱う
        res.resume();
        resolve(res.statusCode);
      }
    );
    req.on("timeout", () => req.destroy(new Error("Request timed out")));
    req.on("error", reject);
    req.end(JSON.stringify(payload));
  });
}

Nodeのfetch(内部はundici)を使う場合も、カスタムのディスパッチャーを通じて同じ考え方が使えます。connect.lookupオプションを指定したundiciのAgentを作り、dispatcherとして渡します。原則は変わりません。チェックは接続が行われる場所に置くのです。なお、環境が送信トラフィックをHTTPプロキシ経由にしている場合、対象ホストの名前解決はプロキシ側で行われるため、プロキシ自身が同じルールを適用しなければなりません。

リダイレクトを無効にするか、ホップごとに再検証する

Webhookではリダイレクトを一切追わないでください。リダイレクトする受信側は設定が誤っているので、明確に失敗させることが顧客にエンドポイントの修正を促します。リダイレクトが当たり前のリンクプレビューやインポート機能では、回数の上限を設けて手動で追い、すべてのホップを同じスキーム・ポート・IPのチェックに通してください。fetchではredirect: "manual" を設定し、Locationヘッダーを自分で処理します。

リクエスト成功時にできることを制限する

  • 接続と全体のタイムアウトを設定し、レスポンスサイズに上限を設けます。接続を保持し続けたり、大きなファイルを引き出したりする用途に使われないようにするためです。
  • 生のレスポンスや詳細なエラーメッセージをユーザーに返さないでください。リンクプレビューなら、抽出したタイトル・説明・画像URLだけを返します。
  • 自前の認証ヘッダーやCookieを取り除きます。取得処理が、ユーザー指定のホストに内部の認証情報を送ることは決してあってはなりません。

外向きプロキシ経由にする

最も堅牢な構成は、ポリシーをアプリケーションコードの外に出すことです。ユーザー起点の外向きリクエストを専用の外向きプロキシ経由にするか、内部サービスやメタデータエンドポイントへの経路がそもそも存在しないネットワークセグメント上の隔離されたワーカーから実行します。そうすればURL検証のバグが侵害につながりません。ネットワーク自体が拒否するからです。オープンソースのフォワードプロキシは宛先のルールを強制できますし、ワーカーを分けておけば、遅いレスポンスや悪意あるレスポンスをメインのWebプロセスから隔離できます。

メタデータサービスを堅牢化する

AWSでは、PUTリクエストで取得したセッショントークンを必要とするIMDSv2を必須にし、ホスト経由でコンテナから到達できないようホップ制限を1のままにしてください。GCPとAzureはメタデータ呼び出しに特定のリクエストヘッダーを要求します。これらは単純なGETベースのSSRFに対する障壁を大きく引き上げますが、あくまで2層目であり、リンクローカルアドレスのブロックの代わりにはなりません。インスタンスやサービスのロールにはアプリケーションが必要とする権限だけを与え、メタデータサービス経由で盗まれた認証情報で解錠できる範囲をできるだけ小さくしましょう。

防御のテスト

  • http://127.0.0.1/、http://[::1]/、http://2130706433/、http://127.1/、http://169.254.169.254/latest/meta-data/ を試し、それぞれが拒否されることを確認します。
  • 自分が管理するホスト名を127.0.0.1に向け、ルックアップのチェックが拒否することを確認します。次に、公開とプライベートの2つのAレコードを持たせ、それでも拒否されることを確認します。
  • 公開ホストからプライベートアドレスへの302リダイレクトを返し、それが追われないことを確認します。
  • file:、ftp:、gopher:のURL、および認証情報付きや通常でないポートのURLを試します。

コードレビューでは、リクエストURLが入力から組み立てられているすべての箇所を探してください。fetch、axios、got、requests、http.Get、ヘッドレスブラウザのpage.goto呼び出し、URLを受け付ける画像処理ライブラリなどです。CodeAuditAgentは公開リポジトリと貼り付けられたスニペットでこのデータフローを追跡し、SSRFをCWE-918として、該当する呼び出し箇所の引用、攻撃シナリオ、修正パッチとともに報告します。