SSRF対策とURLの検証
検査した名前とIPを後の接続へ引き継ぎ、転送のたびに同じ条件を適用する必要性を説明します。
説明
SSRFは、外から渡された宛先をサーバーが取得する機能で、意図しない相手へ通信してしまう問題です。URLの文字列、名前解決したIP、リダイレクト後のURLを別々の検査対象にします。
取得先をfeed.example.testに限定した配信機能を使います。固定DNS資料から接続計画を作り、HTTPの転送応答をたどります。要求と応答の処理はループバック上の試料サーバーで確認します。
検査した名前とIPを後の接続へ引き継ぎ、転送のたびに同じ条件を適用する必要性を説明します。
1URLを構成要素へ分解する
正規の取得先はHTTPS、443、feed.example.testです。
from sd09 import *
lab = LabHTTP()
url = "https://feed.example.test/final"
p = urllib.parse.urlsplitURLを標準パーサーで解析する(url)
print(p.scheme,p.hostname,p.port,p.path)
print(destination(url,DNS)固定DNS資料から接続計画を作る)省略ポートは443として計画に入ります。計画にはホスト名と選択IPを両方保持します。
2文字列に許可名が含まれていても信用しない
userinfoや類似名を含むURLを標準パーサーで調べます。
for candidate in ("https://feed.example.test@other.example.test/final","https://feed.example.test.other.example.test/final"):
p = urllib.parse.urlsplit(candidate)
print(p.hostname,p.username表示上の文字列と実ホストを比較する)
try:print(destination(candidate,DNS))
except ValueError as e:print(str(e))最初の実ホストはother.example.test、次は末尾が別ドメインです。許可名の部分文字列検索ではなく、解析したhostnameを完全一致させます。
3名前が正しくても解決先を検査する
同じ許可名に内部IPを含むDNS応答を与えます。
for ips in単一・混在・IPv6の解決先を試す (["127.0.0.1"],["93.184.215.14","10.0.0.7"],["::1"]):
try:print(destination(url,{"feed.example.test":ips}))
except ValueError as e:print(ips,str(e))すべてaddressです。A/AAAAの候補に内部宛先が混じる場合、この取得機能は応答全体を拒否します。単に先頭だけを検査して後段へ全候補を渡しません。
4選択済みIPを接続まで保持する
検証後にDNS資料が変わっても、既に作った計画のIPを再解決で置き換えません。
records = {"feed.example.test":["93.184.215.14"]}
plan = destination検証済み宛先を固定する(url,records)
records["feed.example.test"] = ["127.0.0.1"]
print(plan[2],records["feed.example.test"][0])
conn,response = lab.send(plan)同じ計画を転送処理へ渡す
print(response.status,response.read().decode())
conn.close()計画は93.184.215.14、変更後資料は127.0.0.1です。試料は固定のループバック橋渡しで応答を取得します。本番のHTTPSクライアントでは選択IPへの接続と、元ホスト名でのTLS証明書検証の両方が必要です。
5許可された転送先を再検証する
/redirectは同じ許可ホストの/finalへ302を返します。
lab.log.clear()
print(lab.fetch転送先ごとに検査して取得する("https://feed.example.test/redirect"))
print(lab.log実HTTP試料が受けた要求を照合する)302と200が記録され、2要求ともHostはfeed.example.testです。各転送先にURL・IPの検査を適用しています。
6内部宛先へ転送された後の要求を止める
/internalはhttp://127.0.0.1/privateへ転送します。
lab.log.clear()
print(lab.fetch("https://feed.example.test/internal"許可された入口から内部宛先への転送を試す))
print(lab.log)scheme_or_portで止まり、ログは/internalの1件だけです。最初の許可だけで転送を自動追跡すると、この再検査が抜けます。
7転送を許可しない設定も比較する
転送数の上限を0にし、同じ302応答を受けます。
lab.log.clear()
print(lab.fetch("https://feed.example.test/redirect",redirects=0転送追跡を禁止する))
print(lab.log)redirect_limit、/redirectの1件で終了します。転送が不要な機能なら、追跡自体を止めることで検査対象を減らせます。
まとめ
URLを解析し、名前・scheme・portを検証します。
解決したIPを検証し、接続時に別の解決先へ置き換えません。
転送先にも同じ検査を行うか、転送自体を止めます。
操作履歴と解説を振り返れます。
Python 3.15 / 固定DNS資料・ループバックHTTP試料
from sd09 import *
lab = LabHTTP()
url = "https://feed.example.test/final"
p = urllib.parse.urlsplit(url)
print(p.scheme,p.hostname,p.port,p.path)
print(destination(url,DNS))https feed.example.test None /final
('feed.example.test', 443, '93.184.215.14', '/final', '')for candidate in ("https://feed.example.test@other.example.test/final","https://feed.example.test.other.example.test/final"):
p = urllib.parse.urlsplit(candidate)
print(p.hostname,p.username)
try:print(destination(candidate,DNS))
except ValueError as e:print(str(e))other.example.test feed.example.test url_shape feed.example.test.other.example.test None host
for ips in (["127.0.0.1"],["93.184.215.14","10.0.0.7"],["::1"]):
try:print(destination(url,{"feed.example.test":ips}))
except ValueError as e:print(ips,str(e))['127.0.0.1'] address ['93.184.215.14', '10.0.0.7'] address ['::1'] address
records = {"feed.example.test":["93.184.215.14"]}
plan = destination(url,records)
records["feed.example.test"] = ["127.0.0.1"]
print(plan[2],records["feed.example.test"][0])
conn,response = lab.send(plan)
print(response.status,response.read().decode())
conn.close()93.184.215.14 127.0.0.1 200 public feed
lab.log.clear()
print(lab.fetch("https://feed.example.test/redirect"))
print(lab.log)('public feed', [('feed.example.test', '93.184.215.14', 302), ('feed.example.test', '93.184.215.14', 200)])
[('GET', '/redirect', 'feed.example.test'), ('GET', '/final', 'feed.example.test')]lab.log.clear()
print(lab.fetch("https://feed.example.test/internal"))
print(lab.log)('scheme_or_port', [('feed.example.test', '93.184.215.14', 302)])
[('GET', '/internal', 'feed.example.test')]lab.log.clear()
print(lab.fetch("https://feed.example.test/redirect",redirects=0))
print(lab.log)('redirect_limit', [('feed.example.test', '93.184.215.14', 302)])
[('GET', '/redirect', 'feed.example.test')]演習完了