jaryn

ユーザーが https://example.invalid/report を送信します。最初のDNSルックアップはパブリックアドレスを返すため、アプリケーションはこれを承認します。サーバーは http://169.254.169.254/latest/meta-data/ へのリダイレクトで応答します。フェッチクライアントは自動的に追従し、元の許可決定は暗黙的に別のホストへの接続権限に変わります。

URLプレビューやインポートサービスが見落としがちなSSRFの境界はこれです:URLは単一のネットワーク宛先ではありません。DNS応答は変わる可能性があり、リダイレクトは新しい宛先をもたらし、一見パブリックなホスト名がプライベート空間に解決されることもあります。

不変条件はより厳格であるべきです:

すべての接続試行はポリシーで許可されたアドレスを対象とし、すべてのリダイレクトは新たに認可判断を受けなければなりません。

本記事はその判断ポイントを構築します。これは防衛パターンであり、特定のフレームワーク脆弱性についての主張ではありません。

信頼境界

次の値を信頼できない別個の値として扱います:

user URL
  -> parsed URL
  -> normalized hostname
  -> DNS answer set
  -> selected socket address
  -> HTTP redirect target
  -> next DNS answer set

Enter fullscreen mode Exit fullscreen mode

パースされたホスト名のみをチェックするとDNSを見逃します。最初のDNS応答のみをチェックすると複数レコードを見逃します。初期リクエストのみをチェックするとリダイレクトを見逃します。HostヘッダーをチェックしつつHTTPライブラリに独立した解決を許すと、チェック時と使用時のギャップが生じます。

有用なポリシーは4つのレイヤーを持ちます:

レイヤー 拒否対象
スキーム 明示的にサポートされた https:/http: 以外
URL構文 認証情報、不正なポート、曖昧なホスト名
解決 ループバック、プライベート、リンクローカル、マルチキャスト、未指定アドレス
ナビゲーション ホップ制限を超える、またはポリシーの全体に失敗するリダイレクト

最小限のアドレスゲート

Nodeの net.isIP() は構文を識別しますが、ポリシーにはCIDR分類も必要です。以下の例はリスクの高いIPv4範囲とIPv4-mapped IPv6をカバーします。本番コードではメンテナンスされたIP/CIDRライブラリを使用し、配置環境に合わせた完全なIPv6ポリシーを含めるべきです。

import dns from "node:dns/promises";
import net from "node:net";

function blockedIPv4(address) {
  const parts = address.split(".").map(Number);
  if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
    return true;
  }

  const [a, b] = parts;
  return (
    a === 0 ||
    a === 10 ||
    a === 127 ||
    (a === 169 && b === 254) ||
    (a === 172 && b >= 16 && b <= 31) ||
    (a === 192 && b === 168) ||
    (a === 100 && b >= 64 && b <= 127) ||
    a >= 224
  );
}

function addressAllowed(address) {
  if (net.isIPv4(address)) return !blockedIPv4(address);

  const normalized = address.toLowerCase();
  if (normalized.startsWith("::ffff:")) {
    return !blockedIPv4(normalized.slice(7));
  }

  // Fail closed here until the service has an explicit IPv6 CIDR policy.
  return false;
}

async function authorize(urlText) {
  const url = new URL(urlText);
  if (!["https:", "http:"].includes(url.protocol)) throw new Error("scheme denied");
  if (url.username || url.password) throw new Error("credentials denied");

  const answers = await dns.lookup(url.hostname, { all: true, verbatim: true });
  if (answers.length === 0) throw new Error("no addresses");
  if (answers.some(({ address }) => !addressAllowed(address))) {
    throw new Error("destination denied");
  }

  return { url, answers };
}

Enter fullscreen mode Exit fullscreen mode

なぜいずれかの応答が禁止されている場合にホスト名を拒否するのか? そうしないとアドレス選択が暗黙的なポリシーの抽選になるからです。攻撃者はレコード順序に影響を与えられる一方で、ランタイムやプロキシは検証ツールとは異なる選択をする可能性があります。

自動リダイレクトの無効化

認可はナビゲーションを所有する必要があります。手動リダイレクト処理を使用し、各ターゲットを解決し、小さなホップ予算を設定します。

async function fetchAuthorized(start, maxRedirects = 3) {
  let current = start;

  for (let hop = 0; hop <= maxRedirects; hop++) {
    await authorize(current);

    const response = await fetch(current, { redirect: "manual" });
    if (response.status < 300 || response.status >= 400) return response;

    const location = response.headers.get("location");
    if (!location) throw new Error("redirect without location");
    if (hop === maxRedirects) throw new Error("redirect limit exceeded");

    current = new URL(location, current).href;
  }
}

Enter fullscreen mode Exit fullscreen mode

これで明らかなリダイレクトバイパスは塞がりますが、検証をソケットに完全に結び付けるわけではありません。HTTPクライアントは authorize() の後に独自のDNSルックアップを実行します。DNSリバインディング攻撃者はこれらの操作の間で異なる応答を返す可能性があります。

承認済みアドレスを接続にバインドする

堅牢な設計では、各ホップで一度解決し、承認済みアドレスを選択し、その正確なアドレスをコネクタに供給しつつ、TLSのServer Name Indicationと証明書検証のために元のホスト名を保持します。

実装はHTTPスタックに依存します。Nodeでは、承認済みアドレスを受け取るカスタム lookup/dispatcher またはエージェントを使用します。URLのホスト名をIPに置き換えてリバインディングを解決しようとしないでください:それはTLS検証やバーチャルホスティングを危険な方法で壊す可能性があります。

接続レコードは証拠を保持する必要があります:

{
  "requestHost": "files.example.com",
  "resolvedAddress": "203.0.113.18",
  "family": 4,
  "redirectHop": 1,
  "policyVersion": "url-fetch-v3",
  "outcome": "allowed"
}

Enter fullscreen mode Exit fullscreen mode

URLの認証情報や機密クエリ文字列をログに記録しないでください。

回帰テスト用フィクスチャ

セキュリティゲートには成功するパブリックURLだけでなく、否定用フィクスチャが必要です。

フィクスチャ 期待結果
パブリックホスト、パブリックアドレス 許可
リテラルの 127.0.0.1 拒否
10.0.0.8 に解決されるホスト 拒否
リンクローカルへのリダイレクトを含むパブリックURL ホップ1で拒否
パブリック/プライベート混在のDNS応答 拒否
制限3回の4回リダイレクト 拒否
ホスト名が変わるがパブリックを維持 再認可後に許可
DNS応答が接続前に変化 コネクタは承認済みアドレスを使用しなければならない

テストではローカルの偽リゾルバとHTTPサーバーを使用します。ポリシーを証明するためにクラウドメタデータエンドポイントを調査しないでください。

防止・検知・回復

フェーズ コントロール
防止 出力ファイアウォール+ホップごとのアドレス認可
検知 ポリシーバージョン、ホップ、ホスト名ハッシュ、選択されたアドレスクラス、結果をログ
回復 フェッチをキャンセル、キャッシュされたプレビューを無効化、露出の可能性がある認証情報をローテーション

アプリケーションバリデーションが唯一の障壁であってはなりません。URLフェッチワーカーを、コントロールプレーン、メタデータサービス、データベース、内部管理インターフェースに到達できないネットワークセグメントで実行してください。そうすればパーサーのミスは独立した出力境界に阻まれます。

制限

サンプルはIPv6 CIDRを3つの文字列チェックでカバーできるふりをせず、意図的にIPv6でクローズドフェイルとしています。また、プロキシ動作、圧縮応答制限、Content-Type検証、応答サイズ予算、コネクタ固有のDNSピニングも省略しています。これらは別個のコントロールであり、宛先認可を弱める理由ではありません。

核心的なテストは単純です:リダイレクトホップやDNS遷移によって、ポリシーが明示的に承認しなかったアドレスにソケットが到達する可能性はありますか? もしあるなら、そのURLバリデータは助言的なものであり、セキュリティ境界ではありません。