一覧SD-09-1 · 外部通信とSSRF

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試料

1
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', '')
2
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
3
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
4
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
5
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')]
6
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')]
7
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')]

演習完了