LLMが提案したURLを安全に取得する:GoによるSSRFガード
モデルが返すURLは信頼できない入力です。ダイヤル時のIPチェック、DNSリバインディング防御、ホップごとのリダイレクト検証、およびページの検証を備えたGoフェッチャー。
言語モデルにURLを見つけさせてからそれをフェッチするように要求するバックエンドがますます増えています。例えば、検索でグラウンディングされたモデルが公式ウェブサイトを提案したり、エージェントがドキュメントから抽出したリンクをたどったり、パイプラインがモデルの選んだページでレコードを拡充したりします。サーバーがモデルから出力されたURLに対してHTTPリクエストを発行した瞬間、古典的なSSRFの攻撃対象領域を構築したことになり、通常の「ホスト名を検証する」というアドバイスだけでは不十分です。この記事では、モデルの出力を信頼できない入力として扱う、本番環境のGo製フェッチャーについて順を追って説明します。具体的には、静的なURLチェック、DNSリバインディング対策としてのdial時のIPピニング、標準ライブラリのヘルパーが見逃す範囲をカバーするアドレスブロックリスト、ホップごとのリダイレクト再検証、そしてフェッチしたページが実際に意図したページであるかの最終チェックです。
なぜモデルが返すURLは信頼できない入力なのか
モデルからのURLは、あなたが制御できない3つの当事者によって形成されます。第一に、モデル自体が幻覚を起こしたり、類似ドメインを選択したりする可能性があります。第二に、モデルが検索ツールを使用する場合、返されるリンクは検索プロバイダーが所有するトラッキングリダイレクタであることが多く、表示されるURLは最終的に到達するURLではありません。第三に、そして最悪なことに、モデルが読み取ったコンテンツがモデルを誘導する可能性があります。ページには、モデルに攻撃者が選んだURLを出力させるよう説得するテキストが含まれている場合があります。プロンプトインジェクションは、「モデルがリンクを提案した」という状況を、いくつかの余分なステップを経て「攻撃者がリンクを提案した」という状況に変えてしまいます。
サーバーの観点から見ると、これはユーザーが送信したWebhook URLと同じ脅威です。問題となる攻撃はサーバーサイドリクエストフォージェリ(SSRF)です。これは、バックエンドに、それだけが到達できるターゲットへのリクエストを発行させることです。典型的な標的は、サービスアカウントトークンを渡すクラウドメタデータエンドポイントや、プライベートネットワーク上の任意の呼び出し元を信頼する内部サービスです。フェッチャーがクラウドVMやコンテナプラットフォーム上で実行されている場合、どちらも1つのHTTPリクエストでアクセスできてしまいます。
したがって、設計ルールは単純明快です。フェッチャーは、URLが何であれ、2回目に問い合わせたときにDNSが何と応答しようとも、開くすべての接続がパブリックアドレスに向かうことを、それ自体で強制しなければなりません。
静的なURLチェックが最初に行われますが、それだけでは不十分です
低コストなチェックが最初に行われるのは、ネットワーク処理を行う前にほとんどのガベージを拒否するためです:
func validateFetchURL(u *url.URL) error {
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("scheme not allowed: %q", u.Scheme)
}
if u.User != nil {
return fmt.Errorf("userinfo in URL rejected")
}
switch u.Port() {
case "", "80", "443", "8080":
default:
return fmt.Errorf("port not allowed: %s", u.Port())
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("empty host")
}
if strings.EqualFold(host, "metadata.google.internal") {
return fmt.Errorf("metadata host rejected")
}
return nil
}
スキームを固定することで、file、gopher、および類似のスキームを排除します。userinfoを拒否することで、https://[email protected]/ のような混乱を狙ったトリックを防ぎます。ポートの許可リストは、フェッチャーが汎用的なポートスキャナーとして使用されるのを防ぎます。名前付きメタデータホストは、攻撃者がGCPで最初に試すホスト名であるため、特別なケースとして扱う価値があります。
この関数が意図的に行わないことは、ホスト名を解決してIPをチェックすることです。ここではそれが自然に感じられるかもしれませんが、それこそが次の脆弱性を生む間違いなのです。もし検証時に解決し、その後、接続時にHTTPクライアントに再度解決させると、2つの答えが一致するとは限りません。
一度解決し、すべてのIPをチェックし、チェックしたIPにダイヤルする
DNSリバインディングは、time-of-check to time-of-use (チェック時・使用時) のバグです。攻撃者が制御するネームサーバーは、検証クエリには無害なパブリックアドレスで応答し、その後、クライアントの接続クエリにはメタデータアドレスまたは内部のRFC 1918アドレスで応答します。チェックはパスしましたが、リクエストは結局、内部のターゲットに送られてしまいました。低いTTLによって、これは実際には確実なものとなります。
修正方法は、別のチェックを追加することではなく、構造的なものです。ダイヤラーの内部で名前解決とポリシー決定を行い、そして承認したばかりのリテラルIPに接続します。Goでは、http.TransportがカスタムのDialContextを受け入れるため、これをクリーンに実現できます。
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil || len(ips) == 0 {
return nil, fmt.Errorf("resolve failed for %s", host)
}
for _, ip := range ips {
if !publicIP(ip) {
return nil, fmt.Errorf("non-public IP blocked: %s", ip)
}
}
d := &net.Dialer{Timeout: 10 * time.Second}
var lastErr error
for _, ip := range ips {
conn, dialErr := d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, lastErr
}
2つの詳細がセキュリティ上の重要性を担っています。クライアントはそれらのいずれかにフェイルオーバーする可能性があるため、最初のアドレスだけでなく、返されたすべてのアドレスがポリシーを通過しなければなりません。そして、ダイヤルはホスト名に戻るのではなく ip.String() に行われるため、攻撃者がポイズニングできる2回目の名前解決は存在しません。TLSは依然として機能します。トランスポートはSNIと証明書検証のために元のホスト名でハンドシェイクを実行するため、TCP接続を精査済みIPに固定しても、正当性を損なうことはありません。
プライベートレンジのチェックが見逃すアドレス
publicIP の自明な実装は、ip.IsPrivate() と ip.IsLoopback() を呼び出すだけです。それでは、深刻な抜け穴が残ります。以下に、レビューを通過した述語を示します。
func publicIP(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
return false
}
if v4 := ip.To4(); v4 != nil && v4[0] == 169 && v4[1] == 254 {
return false
}
if v6 := ip.To16(); v6 != nil && ip.To4() == nil &&
v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b {
return false
}
return true
}
リンクローカルのチェックが重要なのは、主要なクラウド上のメタデータアドレスである 169.254.169.254 が、RFC 1918 のプライベートアドレスではなく、リンクローカルアドレスだからです。IsLinkLocalUnicast はこれをカバーしており、明示的なバイトチェックは、リファクタリングによって決して失いたくない契約のための冗長性です。
最後のブロックは、ほとんどのフェッチャーが見逃すものです。NAT64 のウェルノウンプレフィックスである 64:ff9b::/96 です。NAT64 ネットワークでは、そのプレフィックス内の IPv6 アドレスは、下位 32 ビットに IPv4 アドレスをエンコードし、トランスレータは喜んで接続をそこに転送します。フィルターを通過するプライベート IPv4 アドレスを取得できない攻撃者は、代わりにその NAT64 エンコードされた IPv6 形式を送信できます。IsPrivate は、そのアドレスが IPv6 アドレスとしてはグローバルユニキャストであるため、問題ないと判断します。プレフィックスは自分で拒否しなければなりません。IPv4射影IPv6アドレス、つまり ::ffff: プレフィックス形式は、Go においてはそれほど罠ではありません。なぜなら、net パッケージが標準の述語が実行される前にそれらを正規化するためです。しかし、NAT64 プレフィックスはそのような助けを得られません。
リダイレクト、プロキシ、レスポンスの上限
最初のホップが検査済みであっても、2番目のホップについては何も証明されません。モデルが提案するリンクでは検索リダイレクタが一般的であるため、フェッチャーはリダイレクトを追跡しなければなりません。各ホップは、内部のどこかに到達する新たな機会となります。クライアントは各ホップで同じ静的検証を再実行し、チェーンに上限を設けます:
client := &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
Proxy: nil,
DialContext: safeDialContext,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return validateFetchURL(req.URL)
},
}
Proxy: nil は見落とされがちです。デフォルトのトランスポートは HTTP_PROXY 環境変数を尊重し、プロキシ経由の接続はターゲットではなくプロキシにダイヤルします。これは、注意深く作成したダイアラーがプロキシのアドレスのみをチェックし、他には何もチェックしないことを意味します。プロキシをオフにすることで、ダイヤル時のガードが権威を保ちます。
レスポンス側も同様に信頼しません。悪意のあるサーバーがギガバイト単位のデータを送りつけてくるのを防ぐため、io.LimitReader を通して厳格なバイトキャップ付きで読み取り、HTTP 200 を要求し、パースしようとしているものと Content-Type が一致するかをチェックします。リダイレクト後の最終的なURLを正規化して記録し、それをフェッチしたものの正規アドレスとして扱います。リダイレクト前のURLを保存するということは、リダイレクタを保存することを意味します。
要求したページを確実に取得したことを検証する
これまでの対策はすべて、ネットワークを保護するものです。しかし、正確性については何の効果もありません。モデルは、完全に公開され、完全に安全で、そして完全に間違ったページを渡す可能性があります。もしフェッチの目的が、あるURLが特定のエンティティの公式サイトであることを確認することであるならば、フェッチャーは、下流の何かがそのページを信頼する前に、その主張を検証すべきです。
2つの低コストなチェックで、ほとんどの間違った応答を検出できます。1つ目は言語です。韓国語のページを期待している場合、タグ、コメント、scriptブロック、styleブロックを取り除いた後の可視テキストに、少なくとも1文字のハングル文字が含まれていることを要求します。2つ目は名前の近接性です。期待されるエンティティ名とページテキストの両方から空白を削除して小文字に変換することで正規化し、その名前がテキスト内に出現することを要求します。実際のページでは名前のスペースの使い方が一貫していないため、正規化は重要です。そして、内部にスペースがある場合とない場合の同じ名前が一致する必要があります。
func normalizeForMatch(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if !unicode.IsSpace(r) {
b.WriteRune(r)
}
}
return b.String()
}
検証と並行して、ソーシャルプロフィール、マップリスティング、ブログプラットフォームなど、あなたのドメインにおいて正解となり得ないホストのためのプレーンなドメインのブロックリストを保持し、フェッチ前とすべてのリダイレクトホップの両方でそれをチェックしてください。なぜなら、リダイレクターは常にそれらのホストにバウンスするからです。これは関連性フィルターであり、セキュリティコントロールではありませんが、同じ場所で実行され、フェッチを節約します。
この設計が解決しようとしないこと
いくつかの正直な限界。ダイアラーはリゾルバーを信頼します。攻撃者がDNSパスを完全に制御している場合、このフェッチャー以上のものを制御していることになります。IPv6には、よく知られているもの以外にも変換プレフィックスがあります。ネットワークがカスタムNAT64プレフィックスを使用している場合は、それをブロックリストに追加してください。ページの検証はヒューリスティックであり、断固とした攻撃者は、期待される名前を自身が制御するページに配置できます。これを認証としてではなく、偶発的な誤った登録を減らすものとして扱ってください。そして、応答の上限は、その後HTMLを二次的な動作をするパーサーに渡す場合には役立ちません。したがって、解析も制限してください。
このパターンはLLMの出力を超えて一般化されます。Webhook URL、ユーザーが送信したRSSフィード、リンクプレビュー、OAuthリダイレクトターゲットはすべて同じ形を求めます。つまり、事前の静的チェック、リテラルアドレスに対するダイヤル時のポリシー適用、すべてのホップの再検証、応答サイズとタイプの制限、そしてユースケースに対するドメインレベルのサニティチェックです。このガードがカスタムダイアラーを備えた小さなパッケージとして存在すれば、それを使用することは、コードベース内のどのクライアントにとってもTransportを1つ交換するだけで済みます。これは、セキュリティレビューでの指摘事項とデフォルトであることの違いです。