SSRF とは?外の URL を取りに行く機能の守り方(判定した IP に固定・リダイレクトを追わない)

PR
SSRF とは?外の URL を取りに行く機能の守り方(判定した IP に固定・リダイレクトを追わない)
この記事は約37分で読めます。

SSRF(Server-Side Request Forgery)は、アプリのサーバーに、作った人が意図していない宛先へ通信させてしまう弱点です。たとえば「画像の URL を入れると取り込んでくれる」機能で、利用者の入れた URL の中身をサーバーが取りに行くとき、宛先を確かめていないと、外からは届かないはずの社内のサービスにも、サーバーを通して届いてしまうことがあります。

守りの柱は、この記事では 3 つに絞ります。(1) 取りに行ってよい宛先を絞る、(2) 名前から IP アドレスを調べ(これを名前解決といいます)、確かめた IP にそのまま接続する、(3) リダイレクト(「ここではなく別の URL へ行って」という転送)を追わないです。(2) と (3) は、手元の Python の標準ライブラリで流した結果を、出力のまま載せます。

この記事は、URL を受け取ってサーバーから取りに行く機能(URL のプレビュー、画像の取り込み、Webhook など)を作り始めた人に向けて書いています。書いてあることは、OWASP・MDN・JPCERT/CC・IANA・Python の文書とクラウド各社の文書を、2026年10月21日に開いて確かめたものです。手元の確かめは、すべて自分のパソコンの中に立てたテスト用のサーバーだけで流しています。外のサイトにも、会社の内側のサービスにも、クラウドのメタデータにも、何も送っていません。

OWASP の Cheat Sheet(開発者向けの短い手引き)は頻繁に書き換わります。この記事の「2026年10月時点」は、その日に読んだ内容です。作るときは、最新の文書を開き直してください。

この記事でわかること
  • SSRF とは何か。なぜ「外の URL を取りに行く機能」で起きるのか
  • 2026年10月に、JPCERT/CC のブログが SSRF 対策として書いていること
  • 守り方の全体(宛先を絞る・URL を丸ごと受け取らない・スキームを絞る・IP に固定する・リダイレクトを追わない・ネットワークで分ける)
  • 内側のアドレスの見分け方。is_private だけでは足りないアドレス(100.64.0.0/10・マルチキャスト)と、Python の版で結果が変わる書き方
  • クラウドの「メタデータ」とは何か。各社の守りの形が違うこと
  • 手元で確かめた結果。名前を渡すと 2 回目の名前解決で内側に届いたこと、IP に固定すると許可した宛先だけに届いたこと、リダイレクトを切ると届かなかったこと

SSRF とは?なぜ外の URL を取りに行く機能で起きるの?

MDN(ウェブ技術の解説サイト)は、SSRF を「攻撃者が任意の宛先へネットワークの要求を出させる脆弱性」と説明しています。ポイントは、その要求がサーバー自身から出ることです。サーバーは、外の利用者よりも広い範囲に通信できるのがふつうなので、外の利用者には届かない場所にも、サーバーを通せば届いてしまいます。

OWASP の Cheat Sheet は、起きやすい場面を次のように挙げています。

  • 外のサーバーにある画像(利用者が自分のアバター画像の URL を入れて、アプリが取り込む)
  • Webhook(利用者が、通知を受け取る先の URL を決める)
  • 内側の別のサービスに要求を出して、機能を作る場合

OWASP API Security Top 10 2023 の API7 は、SSRF がよく起きるようにしている理由として、Webhook・URL からのファイル取得・独自の SSO(ログインの仕組み)・URL のプレビューを挙げています。さらに、クラウド・Kubernetes・Docker が、管理のための窓口を HTTP の決まった場所に出していることが、狙われやすさを上げているとも書いています。

MDN の例は、渡された URL から画像を取ってくる入口です。サーバーが会社の内側のネットワークに届くとき、URL を確かめないと、内側の URL を渡されてデータを抜かれることがあります。応答の本文が返ってこなくても、大量の要求でサービスを止められたり、状態コードや応答の時間から内側の様子を推し量られたりすることがあります。OWASP は、SSRF は HTTP だけの話ではなく、file:// のようなほかのスキーム(URL の頭の https:// の部分)も使われうると書いています。

CWE-918(弱点の分類。版 4.20)は、「サーバーが受け取った URL の中身を取りに行くが、要求が期待した宛先に向かうことを十分に確かめていない」ものと説明しています。OWASP Top 10:2025 では、SSRF(CWE-918)は A01「アクセス制御の不備」に入っています(2021 年版で単独の項目だったものが統合された経緯はWeb 開発のセキュリティの基本に書いています)。ログインした人に何を許すかという認可の話は、認証と認可の違いとは?で説明しています。

被害の例と、「完全には消せない」こと

API7 は、起こりうる影響として、内側のサービスの洗い出し(ポートスキャン)、情報の漏えい、ファイアウォールなどの回避を挙げ、場合によっては DoS(サービスを止める攻撃)や、攻撃の踏み台にされることもあると書いています。応答が攻撃者に返る型のほうが、応答が返らず、効いたかどうかが攻撃者に分からない型(Blind SSRF)より悪用しやすい、とも書いています。

また API7 は、外向きの通信を絞るのは難しく、「SSRF のリスクは、常に完全には消せるとは限らない」と書いています。守りを選ぶときは、事業上のリスクと必要なものを考える、という立場です。この記事の守り方も、「これで終わり」ではなく、抜けにくくする積み重ねとして読んでください。

2026年10月の JPCERT/CC のブログ(2026年10月時点)

JPCERT/CC のブログ「連続不正アクセス事案の攻撃手法に関する調査」(2026年10月15日)は、勧める対策の 1 つに SSRF 対策を挙げています。「外部URLの内容を取得するAPIでは、アクセス先を制限し、リダイレクト後の宛先も検証する」という内容です。同じブログは、攻撃元のサーバーで、攻撃者がアプリの配布ファイル(APK)を解析し、関係する API に SSRF などの脆弱性がないか調べていた痕跡を確認した、とも書いています。また、SSRF の検証に使われたと考えられる Python のスクリプトは、HTTPS のサーバーとして動き、特定の要求に 302 のリダイレクトを返すものでした。

この「リダイレクト後の宛先も検証する」という一文が、この記事の後半(手元で確かめる部分)につながります。注意喚起そのものの読み解きはJPCERT/CC の注意喚起(2026年10月)を読み解くに、開発者の点検の全体はWeb 開発のセキュリティの基本にあります。この 2 本は SSRF を「ブログが勧めていること」として紹介するところまでだったので、防ぎ方の中身はこの記事で書きます。

守り方の全体は?宛先を絞る・IP に固定する・リダイレクトを追わない

OWASP の SSRF Prevention Cheat Sheet は、守りを 2 つの場合に分けて書いています。

場合例守りの中心
決まった信頼できる相手にだけ送る提携先の決まった API を呼ぶ許可リスト(送ってよい相手の一覧)が使える
どんな外の IP・ドメインにも送る利用者が自由に決める Webhook の宛先許可リストは使えない。拒否リスト(送ってはいけない相手の一覧)が、破られうると知ったうえでの、この場合のいちばんの方法

OWASP は、この 2 つは大きく違うので別々に説明する、と書いています。そして、決まった相手に送る場合でも、アプリの層とネットワークの層の両方を固める(多層の守り)としています。

守りの部品を、出典ごとに並べると次のとおりです。

守り中身書いている資料
URL を丸ごと受け取らないURL は検証が難しく、パーサー(URL を読み分けるプログラム)を悪用されうる。要るなら IP かドメイン名だけを受け取って許可リストと照らし、スキーム・ポート・パスはアプリが決めて、要求を自分で組み立てるOWASP
ホストの読み方が食い違う URL は断るパーサーごとにホストの読み方が食い違う URL は、すり合わせようとせずに断るOWASP
スキームを絞る普通の Web アプリなら https:// だけで足りることが多いMDN
ドメイン名の許可リストは完全一致で大文字小文字も区別した文字列の完全一致で比べるOWASP
確かめた IP に接続する名前解決した後の IP を許可したネットワークと照らし、確かめた IP にだけ接続するOWASP
リダイレクトを追わない追わない。追うなら、リダイレクト先にも検証や許可リストを当て、連鎖を制限するOWASP・API7・MDN・JPCERT/CC
ネットワークで分けるファイアウォールで、そのアプリが話してよい相手への経路だけを通す。取りに行く仕組みを、ほかから隔てるOWASP・API7
権限を最小に外へ要求を出すサービスに、必要以上の権限を持たせない。重要な内側のサービスと同じ場所に置かないMDN
生の応答を返さない取ってきた応答を、そのまま利用者に返さないAPI7
記録して見る要求をログに残し、監視するMDN

この記事で手元で流すのは、「確かめた IP に接続する」と「リダイレクトを追わない」の 2 つです。ほかの部品は、文書に書いてあることを紹介するだけで、手元では流していません。

OWASP が「URL を丸ごと受け取らない」理由として挙げるパーサーの食い違いについては、割れる文字列の例を、この記事には載せません。「URL の読み方が、使っているプログラムの部品によって違うことがある。だから、自前で URL を切り分けて確かめるより、IP かドメイン名だけを受け取って組み立てる」と覚えておけば十分です。

内側のアドレスはどう見分ける?is_private だけでは足りません

「内側のアドレスか、外のアドレスか」を判定するときは、IP アドレスを文字列のまま比べずに、ipaddress のような、IP アドレスを扱う部品に読ませます。OWASP も、IP アドレスの検証は、正しい IPv4/IPv6 の形かを実績のあるライブラリで確かめ、そのライブラリが出した値で比べる、と書いています。

内側に当たる範囲

内側や特別な用途のアドレスは、IANA(インターネットのアドレスの割り当てを管理する組織)が、特別な用途のアドレスの登録簿にまとめています(IPv4・IPv6 とも、2025年10月9日に更新)。登録簿で「グローバルに届かない」(Globally Reachable が False)とされている行の一部を挙げます。

範囲何か
127.0.0.0/8・::1/128ループバック(自分自身)
10.0.0.0/8・172.16.0.0/12・192.168.0.0/16プライベートなネットワーク用(RFC 1918)
100.64.0.0/10Shared Address Space(RFC 6598)
169.254.0.0/16・fe80::/10リンクローカル(そのネットワークの上だけで通じるアドレス。RFC 3927 など)
0.0.0.0/8・::/128「このネットワーク」・未指定
fc00::/7Unique-Local(IPv6 の、外へ出さない用途のアドレス)
::ffff:0:0/96IPv4-mapped(IPv4 のアドレスを IPv6 の形で書いたもの)

手元の Python で調べた結果

Python 3.12.3 で、ipaddress の is_private・is_global・is_multicast を調べました。8.8.8.8 とマルチキャストの 2 つは文字の判定だけで、通信はしていません。

アドレスis_privateis_globalis_multicast
127.0.0.1TrueFalseFalse
10.0.0.5TrueFalseFalse
172.16.0.1TrueFalseFalse
192.168.1.1TrueFalseFalse
0.0.0.0TrueFalseFalse
::1TrueFalseFalse
fe80::1TrueFalseFalse
100.64.0.1FalseFalseFalse
8.8.8.8FalseTrueFalse
224.0.0.1FalseTrueTrue
ff02::1FalseTrueTrue

リンクローカルの範囲(169.254.0.0/16)のアドレスや、クラウド 2 社が IPv6 で用意しているメタデータの入口のアドレスも、手元では is_global が False でした。

100.64.0.1 は、is_private も is_global も False です。 Python の文書にも、100.64.0.0/10 では両方が False になると書かれています。つまり、「is_private なら断る」と書くと 100.64.0.1 は通ってしまい、「is_global でなければ断る」と書けば断れます。OWASP の Cheat Sheet に載っている監視用の Python の例も not ip.is_global で判定しています(ただし、それは DNS の監視の例で、接続時の守りの代わりにはならない、と OWASP 自身が書いています)。

224.0.0.1 と ff02::1(マルチキャスト)は、is_private が False で、is_global は True でした。 3.13.11 でも同じでした。つまり、「is_global なら外」とするだけでは、マルチキャストは通ってしまいます。そのため、次の判定の関数では is_multicast も見て、マルチキャストを断ります。

名前の側も見ておきましょう。socket.getaddrinfo("localhost", 80) は、手元では ::1 と 127.0.0.1 の 2 つを返しました。どちらも内側です。名前を調べるときは、IPv4 の答え(A レコード)だけでなく、IPv6 の答え(AAAA レコード)も全部調べて、同じ判定を当てる、と OWASP も書いています。

IPv4-mapped のアドレスは、Python の版で結果が変わります

::ffff:127.0.0.1 は、IPv4 の 127.0.0.1 を IPv6 の形で書いたもの(IPv4-mapped)です。手元で is_loopback(自分自身か)を調べると、版で結果が違いました。

アドレスPython 3.12.3Python 3.13.11
::ffff:127.0.0.1 の is_loopbackFalseTrue
::ffff:127.0.0.1 の is_globalFalseFalse

Python の変更履歴には、IPv4-mapped のループバックで is_loopback が True を返すようになったのは 3.13.0 beta 1 の変更(gh-117566)、is_global や is_link_local などを中の IPv4 のアドレスで決めるようにしたのは 3.13.1 の変更(gh-122792)と書かれています。3.12 系の変更履歴では、gh-117566 は 3.12.4、gh-122792 は 3.12.7 の節に入っています。

版による違いに左右されないように、IPv4-mapped のアドレスは、先に中の IPv4 に直してから判定します。手元で流した判定の関数は、次のとおりです(classify.py の一部)。

import ipaddress

def judge(ip_text):
    ip = ipaddress.ip_address(ip_text)
    # IPv4-mapped (::ffff:a.b.c.d) は中の IPv4 で判定する
    if ip.version == 6 and ip.ipv4_mapped is not None:
        ip = ip.ipv4_mapped
    return "外(global)" if ip.is_global and not ip.is_multicast else "内側か特別(拒否)"

この関数は、3.12.3 でも 3.13.11 でも、::ffff:127.0.0.1 と、マルチキャストの 224.0.0.1・ff02::1 を「内側か特別(拒否)」と判定し、8.8.8.8 は「外(global)」と判定しました(リンクローカルの IPv4-mapped のアドレスでも同じでした)。

拒否リストより許可リスト

OWASP は、拒否リストは破られやすいので許可リストを選ぶように、と書き、どうしても避けられないときに塞ぐ最小の範囲も表で示しています。その表は、localhost(127.0.0.0/8・0.0.0.0/8・::1/128)、RFC 1918 の 3 つの範囲、IPv6 の fc00::/7 と fe80::/10、マルチキャスト(224.0.0.0/4・ff00::/8)、それにクラウドのメタデータの入口です。OWASP 自身が「minimum(最小)」と書いているとおり、この表には、上で見た 100.64.0.0/10 や、IPv4-mapped の範囲(::ffff:0:0/96)の行はありません。クラウドのメタデータの入口(次の節で説明します)の行も、AWS と Google Cloud は名前とアドレス 1 つ、Azure はアドレス 1 つで書かれていて、IPv4 のリンクローカルの範囲(169.254.0.0/16)全体の行はありません。

IANA の登録簿に沿って判定する is_global は、100.64.0.0/10 と 169.254.0.0/16 も False にします。IPv4-mapped のアドレスは、Python の文書のとおり中の IPv4 と同じ値になる(範囲まるごと False にはならない)ので、前の節のとおり中の IPv4 に直してから判定します。また、OWASP の表にあるマルチキャスト(224.0.0.0/4・ff00::/8)は、手元の Python 3.12.3・3.13.11 では is_global が True でした(文字の判定だけ)。そこで、is_global が True で、しかも is_multicast が False のものだけを通します。拒否リストを自分で書き並べるより書き漏れは起きにくい形ですが、これで全部とは言えません。もっと確実なのは、取りに行ってよい相手が決まっているなら、その相手の IP の許可リストを作ることです。

クラウドのメタデータとは?SSRF で名前が挙がる理由

クラウドの仮想マシン(クラウドの上で借りる、ソフトで作られたコンピュータ)には、メタデータというサービスがあります。その仮想マシン自身の設定や、付けられた役割(ロール)の一時的な資格情報(アクセスするための鍵のようなもの)を、中で動くプログラムに返すサービスです。AWS の文書は、アプリはこの入口から、ロールの一時的な資格情報を取る、と書いています。この資格情報は自動で入れ替わります。Azure の文書も、マネージド ID(Azure が仮想マシンに与える身分)のトークンをこの入口から取れる、と書いています。Google Cloud の文書は、サービスアカウント(プログラム用のアカウント)の OAuth2 アクセストークンを返す入口があると書いています。

この入口は、仮想マシンの中からだけ届くように作られています。AWS のブログ(2019年)は、リンクローカルのアドレスにあるので、インスタンスの上で動くソフトだけが届く、と書いています。Azure の文書も、ルーティングされないアドレスで、仮想マシンの中からだけ届くと書いています。

ところが、SSRF では、その「仮想マシンの中で動くアプリ」自身が、攻撃者に代わって取りに行ってしまいます。OWASP の Cheat Sheet は、クラウドでは SSRF が、メタデータのサービスから資格情報やアクセストークンを盗むのによく使われる、と書いています。API7 の例にも、Webhook の作成時にテストの要求を送り、その応答を利用者に見せる製品が出てきます。応答を見せてしまうので、内側のクラウドのメタデータから、資格情報を見られてしまう、という例です。

この記事では、メタデータの入口のアドレスや、資格情報が出る項目の名前は、書きません(つなげると、そのまま動く入力になってしまうためです)。ここで知っておいてほしいのは、次の 2 点です。

  • 内側の入口には、資格情報を返すものがある。だから SSRF の守りでは、リンクローカルなどの内側のアドレスへ行かせないことが大事になる
  • クラウド各社は、その入口に独自の守りを用意している。ただし形は会社ごとに違い、アプリで宛先を確かめる代わりにはならない

3 社の守りは形が違います

AWSGoogle CloudAzure
守りの形IMDSv2。PUT でトークンを取り、以後の GET に付ける決まったヘッダーが無い要求には答えない決まったヘッダーが無い要求には答えない
X-Forwarded-For ヘッダー付きの要求PUT は断る自動で断る付いていてはいけない
文書の注意トークン必須にすると、トークンが無い・切れた要求は 401──「IMDS の API は認証が無く、仮想マシンの中のどのプロセスにも開いている」

X-Forwarded-For は、「この要求は、別のところから中継されてきた」ことを示すヘッダーです。3 社とも、これが付いた要求を断る(AWS は PUT の要求。Azure は付いていてはいけない)という内容を書いています。「どのクラウドも同じ守り」ではない点に気をつけてください。

AWS の文書には、次の話もあります。

  • 既定では、IMDSv1(トークンを使わない古い方式)と IMDSv2 の両方が使える。インスタンスごとに「IMDSv2 だけ」(トークン必須)に設定でき、そうすると IMDSv1 の呼び出しは失敗する
  • Amazon Linux 2023 は、既定で IMDSv1 を切っている。IMDSv2 だけにする移り方も、AWS の文書が勧めている
  • PUT の応答の hop limit(IP パケットが越えられるルーターの数)は、既定で 1。コンテナの環境では、1 だと問題が出ることがあり、設定を直接渡すか、2 に上げることを考える

IMDSv1 の扱いは、資料で言い方が違います。2019年の AWS のブログは、IMDSv1 は「fully secure(完全に安全)」で、AWS は引き続き対応する、と書いています。いっぽう OWASP の Cheat Sheet は「IMDSv2 に移り、古い IMDSv1 を切る」ことを勧めていて、IMDSv2 は多層の守りの 1 つで、SSRF の一部を和らげるものだと位置づけています。同じ AWS のブログは、PUT で始めてトークンを要る形は、固定のヘッダーを要るだけより、常に強い、とも書いています。固定のヘッダーでの防ぎは、攻撃者が URL しか操れないときだけ効きますが、アプリのヘッダー処理ごと操れる SSRF も多い、というのが AWS の分析です。

つまり、クラウド側の守りは大事ですが、そこに任せずに、アプリの側でも、内側のアドレスへ行かせないのが筋です。次の節からは、その「アプリの側」を手元で確かめます。

手元で確かめる:判定した IP に接続を固定すると何が変わる?

ここからは、手元の Python で流した結果です。OWASP の Cheat Sheet は、次の 2 つを書いています。

  • ドメインの許可リストだけでは、DNS リバインディングを防げない。名前解決した後の IP を許可したネットワークと照らし、HTTP クライアントが確かめた IP にだけ接続するようにする。確かめてから接続するまでの間に、確かめていない名前解決がもう一度入ると、すり抜けられる
  • 確かめた IP に接続しながら、元のホスト名を Host ヘッダー・TLS の SNI・証明書の確認に残す仕組みを使う(例として curl の宛先 IP の指定)。再試行や予備の接続にも、同じ決まりを当てる

DNS リバインディングは、確かめたときと接続するときで、同じ名前の答え(IP)が変わってしまう手口のことです。手元では、この「2 回目に答えが変わる」場面を作って、固定する前と後を比べます。

実験の読み替え(先に読んでください)

手元には、外の(is_global が True の)IP で動くサーバーが無いうえ、外のサイトには何も送らない決まりにしています。そこで、次のように読み替えました。

手元のもの読み替え
127.0.0.1:18081(自分のテスト用サーバー A)許可した宛先のつもり
[::1]:18081(テスト用サーバー X)内側のつもり
127.0.0.1:18082(テスト用サーバー B)内側のつもり(後の節で、リダイレクトの行き先にする)
127.0.0.1:18443(HTTPS のテスト用サーバー)HTTPS の確かめ用
許可リスト127.0.0.1 だけ
app.test という名前名前解決の答えを、socket.getaddrinfo を差し替えて作った。1 回目は 127.0.0.1、2 回目からは ::1 を返す。本物の DNS は使っていない

つまり、外の IP に「判定して、固定してつなぐ」ところは流していません。流したのは、「許可した宛先のつもり」と「内側のつもり」を、自分のパソコンの中に作った形です。また、本物の DNS で答えが変わる様子(TTL やキャッシュの効き方)も見ていません。ここで確かめたのは「名前解決の答えが 2 回目に変わった場合」までで、「DNS リバインディングを再現した」とは言えません。

環境は、macOS 14.4.1(arm64)、Python 3.12.3、Python 3.13.11、curl 8.4.0 です。HTTP のプロキシの環境変数(http_proxy など)は外して流しました(プロキシ越しだと、つなぐ先はプロキシになるためです。プロキシ越しは試していません)。以下の出力は Python 3.12.3 のもので、3.13.11 でも、版の行以外は同じ出力でした。出力の == 2-1 のような番号は、流したファイルの中の見出しです。名前解決の回数 は、差し替えた getaddrinfo が app.test の問い合わせを受けた回数、サーバーの記録 は、サーバーが受け取った(サーバーの名前, パス, Host ヘッダー, SNI)です。

判定の部品:許可リストに無い宛先は断る

名前を解決して、答えの IP が全部許可リストに入っているかを調べる部品です(ssrf_local.py から抜き出しました)。

import ipaddress, socket

real_getaddrinfo = socket.getaddrinfo
LOOKUPS = []
def fake_getaddrinfo(host, port, *args, **kw):
    if host == "app.test":
        LOOKUPS.append(host)
        ip = "127.0.0.1" if len(LOOKUPS) == 1 else "::1"   # 1 回目と 2 回目で答えが変わる
        return real_getaddrinfo(ip, port, *args, **kw)
    return real_getaddrinfo(host, port, *args, **kw)
socket.getaddrinfo = fake_getaddrinfo

ALLOWED = {ipaddress.ip_address("127.0.0.1")}   # この実験で「許可した宛先」とみなす IP
def resolve_and_check(host, port):
    infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
    ips = [ipaddress.ip_address(i[4][0]) for i in infos]
    for ip in ips:
        if ip not in ALLOWED:
            raise PermissionError(f"許可していない宛先 {ip}")
    return str(ips[0])

上の半分(fake_getaddrinfo)は、答えが変わる様子を作るための実験用の仕掛けで、アプリには要りません。下の半分が、判定の部品です。localhost を渡すと、答えに ::1 が入るので、断られました。

== 2-0 判定で断る(localhost の答えに ::1 が入る)
  PermissionError 許可していない宛先 ::1
  名前解決の回数: 0

判定のあとに名前を渡すと、名前解決がもう一度入る

app.test を判定すると、1 回目の答えは 127.0.0.1(許可)で、通ります。そのあと、判定した IP ではなく、名前のまま urllib.request.urlopen に渡しました。

ip = resolve_and_check("app.test", 18081)
print("  判定した IP:", ip, "→ 許可")
print("  返事:", urllib.request.urlopen("http://app.test:18081/", timeout=5).read().decode().strip())
== 2-1 確かめたあと、urllib に名前を渡す(名前解決がもう一度入る)
  判定した IP: 127.0.0.1 → 許可
  返事: hello from X [::1]:18081(内側のつもり)
  名前解決の回数: 2
  サーバーの記録: ('X [::1]:18081(内側のつもり)', '/', 'app.test:18081', None)

名前解決の回数が 2 になり、2 回目の答え(::1)のサーバー、つまり内側のつもりのサーバーに届きました。判定は 1 回目の答えに対して通っていたのに、接続した先は、別の答えだったのです。OWASP の言う「確かめてから接続するまでの間に、確かめていない名前解決がもう一度入る」形です。

判定した IP に接続を固定する

http.client.HTTPConnection の子クラスを作り、接続する部分(connect())だけを、判定した IP に直接つなぐように差し替えました。

import http.client, socket

class PinnedHTTPConnection(http.client.HTTPConnection):
    def __init__(self, host, port, pinned_ip, **kw):
        super().__init__(host, port, **kw)
        self.pinned_ip = pinned_ip
    def connect(self):
        self.sock = socket.create_connection((self.pinned_ip, self.port), self.timeout)

host には元の名前(app.test)を残し、つなぐ先だけを pinned_ip にしています。使い方は、判定した IP を渡すだけです。

ip = resolve_and_check("app.test", 18081)
c = PinnedHTTPConnection("app.test", 18081, ip, timeout=5)
c.request("GET", "/"); r = c.getresponse()
== 2-2 判定した IP に接続を固定する(http.client)
  判定した IP: 127.0.0.1 → 許可
  返事: 200 hello from A 127.0.0.1:18081(許可した宛先のつもり)
  名前解決の回数: 1
  サーバーの記録: ('A 127.0.0.1:18081(許可した宛先のつもり)', '/', 'app.test:18081', None)

名前解決は判定の 1 回だけで、許可した宛先のつもりのサーバーに届きました。Host ヘッダーは app.test:18081 のままです。Python の http.client の文書は、Host ヘッダーを付けることを求めていて、自動で送ります。同じ IP で複数のサイトを動かしている相手でも、正しい名前で要求が届きます(一般的な説明で、手元では試していません)。

なお、socket.create_connection() は、数字でない名前を渡されると、IPv4 と IPv6 の両方で名前解決し、つながるまで答えを順に試します。上のコードは数字の IP を 1 つだけ渡しているので、名前解決は入りません(手元でも回数は 1 のままでした)。OWASP が参照する GitLab の指針も、名前解決を済ませてホスト名を IP に置き換えた URI を使わないと、DNS リバインディングに守られない、最初に受け取った IP を確かめて使う、と書いています。

HTTPS では、SNI と証明書の確認を元の名前で行う

HTTPS では、IP につなぐだけでは足りません。SNI(Server Name Indication)は、TLS の最初に「どの名前に接続したいか」を相手に伝える仕組みです。1 つのサーバーが、名前ごとに別の証明書を持っているときに、使う証明書を選ぶために使われます。IP だけを見てつなぐと、この名前が失われ、証明書の持ち主が合っているかの確認もできなくなります。そこで、IP につないだあと、wrap_socket に元の名前を渡して TLS を始めます。

import http.client, socket

class PinnedHTTPSConnection(http.client.HTTPSConnection):
    def __init__(self, host, port, pinned_ip, context, **kw):
        super().__init__(host, port, context=context, **kw)
        self.pinned_ip = pinned_ip
        self.ctx = context
    def connect(self):
        sock = socket.create_connection((self.pinned_ip, self.port), self.timeout)
        self.sock = self.ctx.wrap_socket(sock, server_hostname=self.host)  # SNI と証明書の確認は元の名前で

手元の実験では、openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 2 -subj "/CN=app.test" -addext "subjectAltName=DNS:app.test" で、app.test 用の自己署名の証明書(自分で作った、実験用の証明書)を作り、クライアントには ssl.create_default_context(cafile="cert.pem") で、それを信頼する設定を渡しました。

== 2-3 HTTPS: IP に固定し、SNI と証明書の確認は app.test のまま
  返事: 200 hello from T https 127.0.0.1:18443 / 相手の証明書の subject: ((('commonName', 'app.test'),),)
  名前解決の回数: 0
  サーバーの記録: ('T https 127.0.0.1:18443', '/', 'app.test:18443', 'app.test')

サーバー側には、SNI として app.test、Host として app.test:18443 が届き、app.test の証明書の確認も通りました。判定の部品を通さず 127.0.0.1 を直接渡したので、名前解決は 0 回です。では、名前を取り違えるとどうなるでしょうか。server_hostname を other.test にして同じことをしました。

== 2-4 HTTPS: 名前を取り違えたら(server_hostname=other.test)
   SSLCertVerificationError Hostname mismatch, certificate is not valid for 'other.test'.
  名前解決の回数: 0

IP に固定しても、証明書の確認は生きています。名前が合わなければ、SSLCertVerificationError で止まりました。

curl では --resolve

OWASP が例に挙げている curl の「宛先 IP の指定」は、--resolve です。curl の文書は「名前解決より自分がよく分かっているなら、IP を自分で渡せる」と説明しています。同じ HTTPS のテスト用サーバーに、次のように流しました(-w は結果を最後に表示する指定です)。

$ curl -sS --resolve app.test:18443:127.0.0.1 --cacert cert.pem -w " [http_code=%{http_code} remote_ip=%{remote_ip}]" https://app.test:18443/
== 2-5 curl の --resolve(OWASP が例に挙げる仕組み)で同じ HTTPS の口へ
  curl: hello from T https 127.0.0.1:18443
 [http_code=200 remote_ip=127.0.0.1]  [exit 0]
  名前解決の回数: 0
  サーバーの記録: ('T https 127.0.0.1:18443', '/', 'app.test:18443', 'app.test')

SNI は app.test、つないだ先は 127.0.0.1、結果は 200 でした。

リダイレクトは追う?追わない?手元で行き先に届くか確かめる

リダイレクトは、サーバーが「この URL ではなく、別の URL へ行ってください」と返す仕組みです。HTTP の状態コード 301・302・303・307・308 と、行き先を書いた Location ヘッダーで伝えます。Python の urllib.request は、既定でこれを自動で追います(このあと確かめます)。

SSRF の守りでは、これが穴になります。MDN は、攻撃者はリダイレクトやその連鎖を使って検証をすり抜けることがある、と書いています。自分で用意したドメインにつながせ、そこから内側の URL へ転送させれば、入力の検証(最初に受け取った URL への確認)を通ったあとに、検証を通っていない宛先へ行けてしまうからです。JPCERT/CC のブログが勧める対策にも、「リダイレクト後の宛先も検証する」とあります。

Python の urllib の既定は、リダイレクトを追う

手元の Python の urllib.request は、既定でリダイレクトを追いました。許可した宛先のつもりの 127.0.0.1:18081 の /redirect に、127.0.0.1:18082 の /secret(内側のつもり)を指す 302 を返させて、次のように取りました。

r = urllib.request.urlopen("http://127.0.0.1:18081/redirect", timeout=5)
print("  返事:", r.status, r.read().decode().strip(), "/ 最後の URL:", r.url)
== 3-1 urllib の既定(リダイレクトを追う)
  返事: 200 hello from B 127.0.0.1:18082(内側のつもり) / 最後の URL: http://127.0.0.1:18082/secret
  名前解決の回数: 0
  サーバーの記録: ('A 127.0.0.1:18081(許可した宛先のつもり)', '/redirect', '127.0.0.1:18081', None)
  サーバーの記録: ('B 127.0.0.1:18082(内側のつもり)', '/secret', '127.0.0.1:18082', None)

最初の URL(A)は許可した宛先でしたが、urlopen はそのまま 18082 へ行き、200 を返しました。内側のつもりのサーバー B にも、要求が届いています。応答の url を見ると、リダイレクトを追ったかどうかが分かります。

追わない形にする

urllib.request は、build_opener() に渡した handler(処理の担当)の中に、既定の担当と同じもの、またはその子クラスがあれば、既定の担当の代わりにそれを使います。そこで、HTTPRedirectHandler の子クラスを作り、redirect_request()(リダイレクトするなら新しい要求を返し、しないなら HTTPError を投げるか None を返す)で None を返すようにしました。

class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None   # 追わない

opener = urllib.request.build_opener(NoRedirect)
print("  opener の handler:", [type(h).__name__ for h in opener.handlers])
try:
    opener.open("http://127.0.0.1:18081/redirect", timeout=5)
except urllib.error.HTTPError as e:
    print("  ", type(e).__name__, e.code, "/ Location:", e.headers.get("Location"))
== 3-2 HTTPRedirectHandler を差し替えて追わない
  opener の handler: ['UnknownHandler', 'HTTPHandler', 'HTTPDefaultErrorHandler', 'FTPHandler', 'FileHandler', 'DataHandler', 'HTTPSHandler', 'NoRedirect', 'HTTPErrorProcessor']
   HTTPError 302 / Location: http://127.0.0.1:18082/secret
  名前解決の回数: 0
  サーバーの記録: ('A 127.0.0.1:18081(許可した宛先のつもり)', '/redirect', '127.0.0.1:18081', None)

handler の一覧から既定の HTTPRedirectHandler が消えて、NoRedirect が入りました。302 は HTTPError 302 になり(Location は読めます)、内側のつもりの 18082 には、要求が届きませんでした(サーバーの記録に B が無い)。

リダイレクトを追わないやり方は、HTTP クライアントごとに違います。手元で流したのは、Python の urllib.request だけです。Node.js の fetch と axios の違いはfetch と axios の違いで比べていますが、サーバーから取りに行くときのリダイレクトの扱いは、その記事では書いていません。使っているクライアントの文書で、リダイレクトを追うかどうかの既定と、止め方を確かめてください。

前の節の、IP に固定した http.client の接続は、もともとリダイレクトを追いません。同じ /redirect に流すと、302 をそのまま返しました。

== 3-3 http.client(固定した接続)は 302 をそのまま返す
  返事: 302 / Location: http://127.0.0.1:18082/secret
  名前解決の回数: 0
  サーバーの記録: ('A 127.0.0.1:18081(許可した宛先のつもり)', '/redirect', 'app.test:18081', None)

追う場合の書き方(手元では流していません)

リダイレクトの扱いには、資料によって 2 通りの書き方があります。

書き方内容書いている資料手元
追わないHTTP クライアントのリダイレクトを追う機能を切る。302 などが返ってきたら、そこで止めるOWASP の Cheat Sheet・API7流した(3-2・3-3)
追うなら、行き先を確かめ直すリダイレクトを自動で追わず、リダイレクト先にも検証や許可リストを当てる。連鎖の長さを制限するMDN・JPCERT/CC のブログ流していない

「追わない」は、OWASP の Cheat Sheet と API7 が同じことを言っています(MDN と JPCERT/CC のブログは、追うこと自体は残して、行き先の確かめ直しを求めています)。「追うなら」の流れは、手元の部品でいえば、Location の URL を取り出して、前の節の「判定して、その IP に固定してつなぐ」を、その URL に対して最初からやり直す形になります(http.client の固定した接続は、302 を返すだけなので、行き先の URL は自分で扱えます)。ただ、この流れは組んで流していないので、コードは載せません。追う理由が特にないなら、追わない形のほうが、確かめることが少なく済みます。

OWASP は、Webhook のように宛先を決められない場合の流れでも、最後に「確かめた IP にだけ接続し、リダイレクトを切る」としています。

よくある質問

この IP に固定する書き方は、Python の公式の方法ですか?

公式の方法とは言えません。Python の文書にあるのは、HTTPConnection の使い方、socket.create_connection() が数字でない名前を名前解決してつながるまで順に試すこと、wrap_socket() の server_hostname が SNI に使われることの、それぞれの説明です。「判定した IP に固定する方法」として書いた文は、確かめた範囲では見つかりませんでした。この記事のコードは、標準ライブラリの部品を組み合わせた一例です。OWASP が求めているのは、「確かめた IP に接続しつつ、元のホスト名を Host・SNI・証明書の確認に残す」ことです。

requests や Node.js の fetch でも、同じように書けますか?

確かめていません。この記事で流したのは、Python の標準ライブラリだけです。ほかのクライアントについては、公式の文書を開いておらず、手元でも流していません。使うクライアントの文書で、「確かめた IP に接続しつつ、元のホスト名を残せるか」と「リダイレクトを追うかどうか、止められるか」を調べてください。

自分のアプリで確かめること(チェックリスト)
  • URL を丸ごと受け取らず、IP かドメイン名だけを受け取って、スキーム(できれば https:// だけ)・ポート・パスはアプリで決めているか(→「守り方の全体は?宛先を絞る・IP に固定する・リダイレクトを追わない」)
  • 取りに行ってよい相手が決まっているなら、その IP の許可リストで照らしているか。拒否リストを最後の手段にしているか(→「拒否リストより許可リスト」)
  • 内側かどうかの判定を、is_private だけにしていないか。名前を解決した答えは、IPv4 も IPv6 も全部に判定を当てているか。マルチキャストも断っているか(→「手元の Python で調べた結果」)
  • ::ffff: で始まる IPv4-mapped のアドレスを、中の IPv4 に直してから判定しているか。使う Python の版で試したか(→「IPv4-mapped のアドレスは、Python の版で結果が変わります」)
  • 判定したあとに、名前をそのまま HTTP クライアントに渡して、2 回目の名前解決を入れていないか(→「判定のあとに名前を渡すと、名前解決がもう一度入る」)
  • 判定した IP に接続し、Host ヘッダー・SNI・証明書の確認は元の名前のままにしているか(→「HTTPS では、SNI と証明書の確認を元の名前で行う」)
  • リダイレクトを追わない設定にしているか。追うなら、行き先の URL にも最初から判定をやり直しているか(→「追う場合の書き方(手元では流していません)」)
  • クラウドのメタデータの守り(AWS なら IMDSv2 だけにする設定など)を使いつつ、アプリで宛先を確かめる代わりにしていないか(→「3 社の守りは形が違います」)

まとめ

  • SSRF は、サーバーに、意図しない宛先へ通信させてしまう弱点。URL のプレビュー・画像の取り込み・Webhook のように、外の URL を取りに行く機能で起きる。OWASP Top 10:2025 では A01(アクセス制御の不備)に入っている
  • JPCERT/CC のブログ(2026年10月15日)は、外部 URL を取得する API では「アクセス先を制限し、リダイレクト後の宛先も検証する」と勧めている
  • 守りは、宛先を絞る(許可リスト)・URL を丸ごと受け取らない・スキームを絞る・確かめた IP に接続する・リダイレクトを追わない・ネットワークで分ける、の積み重ね。API7 は、リスクは常に完全には消せないと書いている
  • 内側かどうかの判定は、is_private だけでは足りない。100.64.0.1 は is_private も is_global も False。マルチキャスト(224.0.0.1・ff02::1)は is_global が True だったので、is_multicast も見て断る。::ffff:127.0.0.1 の is_loopback は、手元の Python では 3.12.3 で False、3.13.11 で True だったので、IPv4-mapped は中の IPv4 に直してから判定する。拒否リストより許可リスト
  • クラウドのメタデータには資格情報を返す入口があり、AWS・Google Cloud・Azure が、それぞれ形の違う守りを用意している。アプリで宛先を確かめる代わりにはならない
  • 手元の確かめ(読み替えあり)では、判定のあとに名前を渡すと名前解決がもう一度入り、2 回目の答えの「内側のつもり」のサーバーに届いた。判定した IP に固定すると、許可した宛先のつもりのサーバーにだけ届いた。HTTPS でも、SNI と証明書の確認を元の名前にしたまま固定できた。リダイレクトを追わない形にすると、行き先の「内側のつもり」のサーバーには届かなかった
  • 固定のコードは、標準ライブラリの部品を組み合わせた一例で、Python の公式の方法とは言えない。「追うなら行き先を確かめ直す」形と、ほかのクライアントでの書き方は、流していない

Web 開発で点検することの全体は、Web 開発のセキュリティの基本にまとめています。

参考資料

この記事の内容は、2026年10月21日に開いた文書と、手元の Python 3.12.3・3.13.11 での確かめをもとにしています。OWASP の Cheat Sheet とクラウド各社の文書は、書き換わることがあります。

※この記事は、公式の資料をもとに AI(Claude)で下書きし、2026年10月21日時点の公式の資料と照らして確かめてから公開しています。誤りに気づいたらお問い合わせからお知らせください。

PR
PR
タイトルとURLをコピーしました