サーバーサイドリクエストフォージェリ

サーバーサイドリクエストフォージェリ

説明

サーバーサイドリクエストフォージェリ(SSRF)は、サーバーから送るリクエストの宛先をユーザー入力で制御できる場合に発生します。攻撃者が内部アドレスやクラウドメタデータなどの保護されたサービスへ接続させ、リソースの探索、機密情報の取得、アクセス制限の回避を試みる可能性があります。リダイレクト、DNSリバインディング、オープンリダイレクトによって宛先の検証を回避される場合もあります。

想定される影響

  • サーバーを経由し、ネットワークで保護された内部ホストへアクセスされるおそれがあります。
  • 内部API、データベース管理サービス、メタデータエンドポイントから認証情報やトークンが漏えいする可能性があります。
  • 内部管理サービスを呼び出され、想定したアクセス制限を回避されるおそれがあります。
  • 大量のリクエストでアプリケーションやバックエンドのリソースを消費される可能性があります。
  • サーバーを内部ホストやポートの探索に悪用されるおそれがあります。

対処方法

  • ホストは固定値や事前に定めた許可リストから選び、可能ならサービス識別子を固定URLへ対応付けてください。
  • パスを制限して .. などのディレクトリ移動を拒否し、new URL などの標準パーサーでURLを組み立ててください。
  • 接続させる必要のない宛先を遮断してください。ループバック、プライベート、リンクローカルの範囲(127.0.0.0/8、10.0.0.0/8、169.254.0.0/16、172.16.0.0/12、192.168.0.0/16 など)やメタデータエンドポイントも対象です。
  • 必要なHTTP・HTTPS宛先だけを許可し、file://、ftp://、gopher:// などを拒否してください。
  • 自動リダイレクトを無効にするか、リダイレクト先にも同じ宛先検証を適用してください。

例

変更前

javascript
// 入力URLへ直接リクエストし、SSRFの危険を生む
const express = require("express");
const axios = require("axios");
const app = express();

app.get("/proxy", async (req, res) => {
  try {
    const url = req.query.url; // 例: http://127.0.0.1:8080/admin
    if (!url) return res.status(400).send("url required");

    // ユーザーが渡したURLをそのまま使用
    const resp = await axios.get(url, { timeout: 5000 });
    res.send(resp.data);
  } catch (e) {
    res.status(502).send("bad gateway");
  }
});

app.listen(3000);

変更後

javascript
// 識別子を固定URLへ対応付け、パスを制限
const express = require("express");
const axios = require("axios");
const app = express();

// 1) 許可したサービス識別子を固定の基準URLへ対応付け
const SERVICE_BASE = new Map([
  ["status", new URL("https://status.example.com")],
  ["api", new URL("https://api.example.com")],
]);

// 2) 先頭を1つの'/'とし、英数字・/・-・_だけを許可
//    '//evil.example'などのプロトコル相対URLや'..'のパスを拒否。
const SAFE_PATH = /^\/(?!\/)[A-Za-z0-9_\/-]*$/;

app.get("/proxy-safe", async (req, res) => {
  try {
    const { service, path = "/" } = req.query;

    // サービス識別子を許可リストで検証
    if (typeof service !== "string" || typeof path !== "string") {
      return res.status(400).send("invalid parameters");
    }
    const base = SERVICE_BASE.get(service);
    if (!base) return res.status(400).send("invalid service");

    // パスの検証と正規化
    if (!SAFE_PATH.test(path) || path.includes("..")) {
      return res.status(400).send("invalid path");
    }

    // 標準URLパーサーで宛先を組み立てる
    const target = new URL(path, base);
    if (target.origin !== base.origin) {
      return res.status(400).send("invalid target");
    }

    // リダイレクトを制限し、タイムアウトを設定
    const resp = await axios.get(target.toString(), {
      timeout: 4000,
      maxRedirects: 0, // リダイレクトによる宛先変更を防止
      responseType: "arraybuffer",
      maxContentLength: 1024 * 1024,
      validateStatus: (s) => s < 400,
    });

    // 外部コンテンツをこのアプリケーションのoriginでHTMLとして実行しない
    res.status(resp.status);
    res.set("Content-Type", "application/octet-stream");
    res.set("X-Content-Type-Options", "nosniff");
    res.send(Buffer.from(resp.data));
  } catch (e) {
    // リダイレクトや検証の失敗時に詳細を公開しない
    res.status(502).send("bad gateway");
  }
});

app.listen(3000);

解説:

  • 変更前: 入力されたURLがサーバーの接続先になり、内部アドレスやメタデータへ到達するおそれがあります。リダイレクトで外部から内部の宛先へ移る可能性もあります。
  • 変更後: サーバー側の Map から固定ホストを選びます。パスは1つの / で始まる必要があり、//evil.example などのプロトコル相対URLや .. を許可しません。
  • パース結果のoriginが元のoriginと一致することを確認し、maxRedirects: 0 でリダイレクトによる宛先変更を防ぎます。
  • 応答を1 MiBに制限し、application/octet-stream と nosniff を使ってバイナリとして返します。アプリケーションのoriginで実行可能なHTMLとして返しません。
  • ユーザーによるホスト指定を制限し、SSRFの攻撃対象を減らします。許可したホストのDNS、実際の接続先、ネットワーク送信ポリシーも信頼できる状態に保ってください。

参考資料