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

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

説明

SSRFは、サーバーが送るリクエストのURLやホストをユーザー入力で直接決める場合に発生します。攻撃者は、127.0.0.1などのループバック、メタデータエンドポイント、プライベートIP、外部からアクセスさせるべきでないサービスへサーバー経由で要求を送れる可能性があります。接続先によってはアクセス制御の回避、内部情報の取得、ポートスキャンなどにつながります。

想定される影響

  • サーバーを経由し、内部APIや管理画面などのプライベートなサービスへ要求を送られ、別の脆弱性を悪用されるおそれがあります。
  • メタデータや設定サービスへの要求から、トークン、認証情報、設定値が漏れる可能性があります。
  • アプリケーションサーバーをプロキシとして使われ、直接の外部アクセスを遮断するネットワーク制御を回避される場合があります。
  • 応答の遅い接続先や大量の応答への要求を繰り返され、リソース枯渇やサービス停止につながるおそれがあります。

対処方法

  • 外部リクエストの接続先は、コードで定めたドメイン・ホストの許可リストに限定してください。
  • 必要な http/https スキームとポートだけを許可し、ほかのスキームは拒否してください。
  • 実際に接続するIPv4・IPv6アドレスで、許可しないループバック、プライベート、リンクローカル、メタデータなどの範囲を制限してください。DNSリバインディング対策では検証したアドレスへ接続する必要があります。
  • URL全体ではなく、サーバーで決めた固定接続先の識別子を受け取ってください。パスやクエリが必要なら、許可する形式に制限して適切にエンコードしてください。
  • タイムアウト、リダイレクト方針、応答サイズの上限を設定してください。
  • SSRF対策を回避する入力を試験し、不審な外向きリクエストを監視してください。

例

変更前

go
package main

import (
    "io"
    "log"
    "net/http"
)

// 変更前: SSRFを許す例
func proxyHandler(w http.ResponseWriter, r *http.Request) {
    // ユーザー入力からURL全体をそのまま受け取る
    target := r.URL.Query().Get("url")
    if target == "" {
        http.Error(w, "missing url", http.StatusBadRequest)
        return
    }

    // 危険: ユーザー指定のURLへサーバーから要求を送信
    // 例: http://myserver/proxy?url=http://169.254.169.254/latest/meta-data/
    resp, err := http.Get(target)
    if err != nil {
        http.Error(w, "request failed", http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()

    // 応答をそのまま返す
    w.WriteHeader(resp.StatusCode)
    if _, err := io.Copy(w, resp.Body); err != nil {
        log.Println("write response error:", err)
    }
}

func main() {
    http.HandleFunc("/proxy", proxyHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

変更後

go
package main

import (
    "io"
    "log"
    "net/http"
    "time"
)

var outboundClient = &http.Client{
    Timeout: 5 * time.Second,
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        // 許可したホストから内部アドレスや別のホストへのリダイレクトを追跡しない。
        return http.ErrUseLastResponse
    },
}

func safeProxyHandler(w http.ResponseWriter, r *http.Request) {
    endpointID := r.URL.Query().Get("endpoint")
    var target string
    // 入力では任意のURLではなく、サーバー管理の固定エンドポイントだけを選ぶ。
    switch endpointID {
    case "users":
        target = "https://api.example.com/v1/users"
    case "status":
        target = "https://status.example.com/api/status"
    default:
        http.Error(w, "unknown endpoint", http.StatusBadRequest)
        return
    }

    req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
    if err != nil {
        http.Error(w, "request build failed", http.StatusInternalServerError)
        return
    }

    resp, err := outboundClient.Do(req)
    if err != nil {
        http.Error(w, "request failed", http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()

    w.Header().Set("Content-Type", "application/octet-stream")
    w.Header().Set("X-Content-Type-Options", "nosniff")
    w.WriteHeader(resp.StatusCode)
    const maxResponseBytes = 1024 * 1024
    if _, err := io.Copy(w, io.LimitReader(resp.Body, maxResponseBytes)); err != nil {
        log.Println("write response error:", err)
    }
}

func main() {
    http.HandleFunc("/proxy", safeProxyHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

解説:

  • 変更前: url クエリパラメーターをそのまま http.Get に渡します。攻撃者が内部やメタデータの接続先を指定し、直接到達できないシステムの応答をサーバー経由で取得するおそれがあります。
  • 変更後: 入力は固定エンドポイントのIDだけを選びます。コードでURLを固定し、スキーム、ホスト、ポート、パスを変更させません。CheckRedirect でリダイレクトを拒否し、タイムアウトと応答サイズ上限でリソース使用を抑えます。バイナリのコンテンツタイプと nosniff で、上流の内容をアプリケーションと同じオリジンのHTMLとして提供しないようにします。

固定した接続先のDNSとサービスも信頼できる必要があり、必要な外向きネットワーク制限は別途適用してください。DNS/IPを検証する動的な設計では、検証したIPへ接続する独自の DialContext が必要です。LookupIP の結果を確認した後、既定のクライアントで再び名前解決するとDNSリバインディングを防げません。

参考資料