先に要点
- 読者が入力した URL をサーバーが取りに行く機能では、ホスト名の文字列を見るだけの検証は通り抜けられます。外部のドメイン名が内部アドレスへ解決されることがあるためです。
- 手元で確かめました。あるホスト名は文字列の確認を通るのに、実際に名前を解決すると
127.0.0.1と::1でした。名前ではなく解決したアドレスで判定する必要があります。 - リダイレクトを自動で追うと、入口での検証が素通しになります。入口の URL を検証してから読み込ませたところ、実際に読んだのは別ホストでした。自動追従は切り、行き先ごとに検証をやり直します。
- OWASP は拒否リストではなく許可リストを優先すること、完全な URL をそのまま受け取らないこと、リダイレクトの追従を無効にすることを挙げています。
リンクを貼ると見出しと画像が出る。Webhook の宛先を登録できる。監視したい URL を自分で追加できる。こうした機能はどれも、読者が指定したアドレスをサーバーが取りに行きます。ここを素直に実装すると、サーバーの中からしか見えない場所へ代わりに取りに行かせることができてしまいます。
この記事では、よくある2つの実装が実際に通り抜けられる様子を手元で再現し、どう組めば防げるのかを公式の指針と合わせて整理します。
なぜ危ないのか
外から来たリクエストは、境界で止められます。しかしサーバー自身が出すリクエストは、内側から出ます。社内向けの管理画面、データベースの管理ポート、クラウドが提供する設定情報の取得先。どれも外からは届きませんが、サーバーからは届きます。
つまり読者は、URL を1つ入力するだけでサーバーを代理人として使えることになります。取得した内容が画面に返る作りなら、そのまま中身が読めます。
文字列を見るだけでは足りない
最初に思いつくのは、ホスト名に localhost や 127. が入っていないかを見る方法です。これが足りない理由を確かめます。
import socket, ipaddress
from urllib.parse import urlparse
def looks_safe_by_string(url):
host = (urlparse(url).hostname or "").lower()
return not (host == "localhost" or host.startswith("127.") or host.startswith("10."))
for url in ["http://localtest.me/", "https://example.com/"]:
host = urlparse(url).hostname
addrs = sorted({i[4][0] for i in socket.getaddrinfo(host, None)})
bad = [a for a in addrs if not ipaddress.ip_address(a).is_global]
print(url, looks_safe_by_string(url), addrs, bad)
実行した結果です。
| URL | 文字列の確認 | 名前を解決した結果 |
|---|---|---|
http://localtest.me/ |
通る | 127.0.0.1 と ::1 |
https://example.com/ |
通る | グローバルなアドレスのみ |
ホスト名のどこにも数字は入っていません。それでも解決先はループバックでした。名前と解決先は別物なので、文字列だけを見ている限り、この差は永久に見えません。
そして片方だけ見て終わりにしないでください。上の例のように、1つのホスト名は複数のアドレスへ解決されます。返ってきたアドレスが全部グローバルであることを確認します。
リダイレクトを自動で追うと、検証が素通しになる
もう1つの穴は、取得そのものの設定にあります。多くの HTTP クライアントは、何も指定しなければリダイレクトを自動で追います。
入口の URL だけを検証して読み込ませると、どうなるかを確かめました。ループバックだけで完結する再現です。
from http.server import BaseHTTPRequestHandler, HTTPServer
import threading, urllib.request
class Redirector(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(302)
self.send_header("Location", "http://127.0.0.1:8742/secret")
self.end_headers()
def log_message(self, *a): pass
class Internal(BaseHTTPRequestHandler):
def do_GET(self):
body = b"internal-only data"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a): pass
for port, h in ((8741, Redirector), (8742, Internal)):
threading.Thread(target=HTTPServer(("127.0.0.1", port), h).serve_forever, daemon=True).start()
r = urllib.request.urlopen("http://127.0.0.1:8741/ogp", timeout=5)
print(r.geturl())
print(r.read().decode())
結果です。
| 値 | |
|---|---|
| 入口として検証した URL | http://127.0.0.1:8741/ogp |
| 実際に読んだ URL | http://127.0.0.1:8742/secret |
| 受け取った中身 | internal-only data |
検証したアドレスと、中身を取ってきたアドレスが違います。入口さえ通れば、その先は何の確認も受けません。OWASP も入力検証の迂回を防ぐため、クライアント側でリダイレクトの追従を無効にすることを挙げています。
自動追従を切って自分で1ホップずつ進めることです。Location を受け取ったら、その行き先を最初と同じ検証にかけ直してから次へ進みます。追う回数の上限も決めておきます。
時間差で切り替えられる
検証したときと、実際に接続したときで、名前の解決結果が変わることがあります。検証の瞬間はグローバルなアドレスを返し、接続の瞬間は内部のアドレスを返すという切り替えです。DNS リバインディングと呼ばれます。
完全に閉じるのは簡単ではありません。現実的なのは次の2つです。
対策
検証で得たアドレスに、そのまま接続する
名前をもう一度解決させない。ホスト名はヘッダーで渡す。
対策
取得の出口を専用の経路に寄せる
内部の宛先へ出られない場所からだけ取りに行く。
OWASP も、自組織のドメインが内部の DNS で先に解決されるようにし、許可リストのドメインがローカルのアドレスへ解決されていないか監視することを挙げています。
実装の順番
上から順に効きます。
URLを取得する機能の組み方
- 1完全な URL をそのまま受け取らない
OWASP は URL の検証は難しく解析器が悪用されうると指摘しています。用途が決まっているなら、識別子だけ受け取って組み立てる方が安全です。
- 2行き先を許可リストで絞る
拒否リストは抜け道が多く、許可リストが優先されます。
- 3許可リストが現実的でないなら、解決したアドレスで判定する
名前ではなくアドレス。しかも全部のアドレス。
- 4リダイレクトの自動追従を切り、各ホップを同じ検証にかける
回数の上限も決める。
- 5取得した内容をそのまま画面に返さない
必要な項目だけ抜き出す。エラーの詳細も返さない。
- 6許可する形式を絞る
取得したいのが画像やページなら、それ以外の形式は受け付けない。
別のファイルから来たURLも、同じ検証を通す
見落としやすいのがここです。読者が直接入力した URL だけを検証して、取得したファイルの中に書かれていた URL を素通しにする作りになっていることがあります。
たとえば sitemap を読んで、その中の URL を順に確認しにいく処理です。その URL は読者が用意したファイルから来ています。入力欄から来たものと危険度は変わりません。
URL がどこから来たかではなく、これから接続する相手が何かで判断してください。検証は接続の直前に1か所へ集め、そこを必ず通す形にすると漏れません。
クラウドでは被害が大きくなりやすい
クラウドの仮想マシンには、そのマシン自身の設定情報を返す取得先が用意されていることがあります。ここには一時的な認証情報が含まれる場合があり、取られると同じ権限で操作されます。
各社が対策を用意しているので、使っている環境の最新の推奨設定を公式ドキュメントで確認してください。カテゴリ全体の位置づけは OWASP Top 10 を実務目線で読む にまとめています。
URLを取得する機能の安全性に関するよくある質問
Q. プライベートアドレスの範囲を自分で書いて判定してもよいですか?
用意されている判定を使ってください。ループバック、リンクローカル、共用アドレス空間、IPv6 の各種と、範囲は思っているより多く、自分で書くと抜けます。上の例で使った is_global のような、言語側の判定に任せる方が安全です。
Q. 許可リストが現実的でない機能ではどうしますか?
読者が任意のサイトを指定する機能では許可リストを作れません。その場合は解決したアドレスでの判定、リダイレクトの逐次検証、出口の分離を組み合わせます。1つでは足りないという前提で重ねます。
Q. 取得に失敗したことを画面に出してよいですか?
詳細は出さないでください。接続できたか、どんなエラーが返ったかの違いから、内部に何があるかを推測できます。返すのは、取得できなかったという結果だけにします。
Q. この記事の再現コードを動かして問題ありませんか?
どちらも自分の機械の中だけで完結します。1つ目は名前の解決結果を表示するだけ、2つ目はループバックに2つのポートを開いて自分で読むだけです。外部へは何も送りません。
まとめ
読者が指定した URL をサーバーが取りに行く機能では、見るべきものは名前ではなく、これから接続するアドレスです。文字列の確認は通り抜けられ、リダイレクトの自動追従は入口の検証を無効にします。どちらも手元で再現できました。
完全な URL を受け取らない。許可リストを優先する。解決したアドレス全部で判定する。リダイレクトは1ホップずつ検証し直す。この4つを最初から入れておけば、あとから作り直す必要がありません。