先週、社内の検証環境で、URL を貼るとリンク先のタイトルと画像を取ってくる「リンクのプレビュー」を試作していました。
サーバーが、利用者の入力した URL へ自分でリクエストを送る機能です。これは SSRF(Server-Side Request Forgery)の入り口そのものの形をしています。たとえば EC2 の上で動かすなら、http://169.254.169.254/ を渡されるだけで、インスタンスメタデータを取りに行かされるおそれがあります。
私は最初、「ホストが内部の IP なら拒否する」ガードを書いて、それで終わったつもりでいました。ところがレビューで「http://2130706433/ は止まりますか」と聞かれて、その場で答えられなかったんですよね。2130706433 は、127.0.0.1 を 1 つの 10 進数で書いたものです。
止まるかどうかは、ガードの書き方と、その言語が URL をどう読むかで決まります。そこで、ありがちな書き方を 7 通り用意し、抜け道になりそうな入力を 12 通り流して、どれが内部まで届くかを数えました。
試した環境
Docker の node:22-bookworm の中で、Node.js v22.23.1(undici 7.30.1)と Python 3.11.2 を使いました。Python は python:3.12-bookworm(3.12.13)と python:3.14-slim(3.14.6)でも同じものを流しています。
同じコンテナに、役割の違うサーバーを 2 つ立てました。
- 内部のサーバー:
127.0.0.1:9000と[::1]:9000で待ち受け、SECRETとだけ返す。メタデータや管理画面の代わり - 外のサーバー: コンテナの外向きのアドレス(今回は
172.17.0.4:8000)で待ち受け、/r?to=...に来たら指定の URL へ 302 で転送する。/okにはPUBLICと返す
もう 1 つ、docker run --add-host evil.example:127.0.0.1 で、evil.example という名前が 127.0.0.1 を指すようにしました。攻撃する側が、自分のドメインの DNS を内部のアドレスに向けた状況の代わりです。
ガードが拒否する範囲は、127.0.0.0/8・0.0.0.0/8・10.0.0.0/8・192.168.0.0/16・169.254.0.0/16 と、IPv6 の ::1・::・fc00::/7・fe80::/10 です。172.16.0.0/12 だけは、今回「外」の代わりに使ったので外しました。本番なら当然入れる範囲です。
用意したガードは次の 7 通りです。A〜E が Node.js、F と G が Python です。
- A: URL の文字列の先頭を正規表現で見る(
127.やlocalhostで始まれば拒否) - B:
new URL()でホストを取り出し、IP なら範囲を見る。名前ならそのまま通す - C: B に加えて、名前を
dns.lookupで引き、返ってきた IP を全部見る - D: undici の
Agentに、IP を確かめるlookupを渡し、接続の瞬間に見る - E: D に加えて、IP が直接書かれたホストも接続の直前に見る
- F:
urllib.parseでホストを取り出し、ipaddressで IP と読めれば範囲を見る。読めなければ名前として通す - G: F の代わりに
socket.getaddrinfoで引き、返ってきた IP を全部見る
取得には、A〜C が Node.js の標準の fetch、D と E が undici の fetch、F と G が urllib.request.urlopen を使いました。リダイレクトはどれも既定のまま、追いかける設定です。
最初に書いた形は、12 通り中 7 通りを内部まで通した
正直なところ、私が最初に書いたのは F に近いものでした。
import ipaddress, urllib.parse, urllib.request
def fetch_preview(url):
host = urllib.parse.urlparse(url).hostname
try:
ip = ipaddress.ip_address(host)
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_unspecified:
raise PermissionError(f"内部宛てのため拒否: {host}")
except ValueError:
pass # IP として読めない = ホスト名とみなして通す
return urllib.request.urlopen(url, timeout=3).read()
# 実行結果(Python 3.11.2。3.12.13・3.14.6 も同じ):
# http://127.0.0.1:9000/ -> 内部宛てのため拒否: 127.0.0.1
# http://2130706433:9000/ -> b'SECRET'
# http://0x7f000001:9000/ -> b'SECRET'
# http://127.1:9000/ -> b'SECRET'
2130706433・0x7f000001・0177.0.0.1・127.1 の 4 つは、ipaddress.ip_address() が IP として受け付けず、ValueError になります。ガードはそれを「名前」とみなして通します。ところが urlopen が接続するときに使う OS の名前解決(getaddrinfo)は、この 4 つを 127.0.0.1 と読みます。G が 4 つとも止めたのは、getaddrinfo が返した 127.0.0.1 を見たからです。
ガードと接続とで、同じ文字列の読み方が食い違っていたわけです。
同じ考え方の B(Node.js)は、この 4 つを全部止めました。new URL('http://2130706433:9000/').hostname は、その時点で 127.0.0.1 になっています。Node.js の URL は WHATWG の URL 標準どおりに IPv4 の書き方をそろえるので、ガードが見る文字列と、接続に使う文字列が同じになります。言語が違うだけで、同じ発想のガードの結果が割れるんですよね。
A(正規表現)も 7 通りを通しました。127. で始まる 127.1 は止めた一方で、10 進・16 進・8 進と [::ffff:127.0.0.1] は先頭の文字が違うので素通りです。
IPv4 射影アドレス(::ffff:127.0.0.1)では、Python の版による差が出ました。ipaddress.ip_address("::ffff:127.0.0.1").is_loopback は、3.11.2 だと False、3.12.13 と 3.14.6 では True です。is_private は 3 つとも True だったので今回の F は止めましたが、is_loopback だけで判定する書き方なら、3.11.2 ではこのアドレスを内部と見なせません。Node.js の net.BlockList は、IPv4 の範囲で作ったリストに ::ffff:127.0.0.1 を渡しても、一致と返しました。
名前を引いても、リダイレクトで抜けた
C と G は、名前を引いてから IP を確かめます。127.0.0.1 を指す evil.example もここで止まり、内部まで届いたのは 12 通り中 2 通りまで減りました。
残った 2 通りは、どちらもリダイレクトです。外のサーバーの URL は検査を通り、その応答の 302 で http://127.0.0.1:9000/ や http://evil.example:9000/ へ送られると、fetch も urlopen もそのまま追いかけていきます。ガードが見たのは、最初の URL だけでした。
リダイレクトを止めるだけなら、fetch に redirect: 'manual' を渡せば済みます。ただ、プレビューは短縮 URL や http から https への転送を追えないと役に立ちません。追いかけるなら、1 回ごとに確かめ直すしかありません。
C にはもう 1 つ穴があります。ガードで 1 回、fetch の接続でもう 1 回、名前を引いています。1 回目と 2 回目で違う IP を返す DNS(DNS rebinding)が相手なら、確かめた IP と繋いだ IP が別物になります。今回そこまでは試していませんが、作りとしては残る穴です。
接続の瞬間に見る形にしたら、今度は IP の直書きが全部通った
「確かめた IP にそのまま繋ぐ」「リダイレクトも 1 回ずつ確かめる」を両方満たすなら、接続を作る瞬間に見ればよい、と考えました。それが D です。undici の Agent に、名前を引いて IP を確かめる lookup を渡しておけば、リダイレクトの先へ繋ぐときもこの関数を通ります。
結果は想定外でした。12 通り中 9 通りが内部まで届き、7 通りの中でいちばん悪い数字です。
import dns from 'node:dns';
import net from 'node:net';
import { Agent, fetch } from 'undici';
const deny = new net.BlockList();
deny.addSubnet('127.0.0.0', 8, 'ipv4');
deny.addAddress('::1', 'ipv6');
// 実際は 0/8・10/8・192.168/16・169.254/16・fc00::/7・fe80::/10 も足す
const looked = [];
const lookup = (hostname, opts, cb) => {
looked.push(hostname);
dns.lookup(hostname, { ...opts, all: true }, (err, list) => {
if (err) return cb(err);
const bad = list.find((a) => deny.check(a.address, `ipv${a.family}`));
if (bad) return cb(new Error(`拒否: ${hostname} -> ${bad.address}`));
return opts.all ? cb(null, list) : cb(null, list[0].address, list[0].family);
});
};
const agent = new Agent({ connect: { lookup } });
for (const url of ['http://evil.example:9000/', 'http://127.0.0.1:9000/']) {
const out = await fetch(url, { dispatcher: agent })
.then((r) => r.text())
.catch((e) => e.cause?.message ?? e.message);
console.log(url, '->', out, '/ lookup:', looked.splice(0).join() || '呼ばれず');
}
// 実行結果:
// http://evil.example:9000/ -> 拒否: evil.example -> 127.0.0.1 / lookup: evil.example
// http://127.0.0.1:9000/ -> SECRET / lookup: 呼ばれず
名前の evil.example は止まり、127.0.0.1 はそのまま届きました。ホストがもともと IP なら、名前を引く必要がないので、lookup は呼ばれずに接続されます。今思えば当たり前なのですが、「接続のたびに必ず通る関数」だと思い込んでいました。
通った 9 通りは、そのままの 127.0.0.1・10 進・16 進・8 進・省略形・0.0.0.0・[::1]・射影アドレスと、「127.0.0.1 へのリダイレクト」です。リダイレクト以外の 8 つは、B なら全部止められていたものでした。B をやめて D に置き換えていたら、守りは前より弱くなっていたことになります。
最後の形: IP の直書きも名前も、接続の直前に同じ範囲で見る
E は、undici の buildConnector で作った標準の接続処理の手前に、IP の直書きを確かめる処理をはさんだものです。
import net from 'node:net';
import { Agent, buildConnector } from 'undici';
// deny と lookup は上と同じもの
const connectViaLookup = buildConnector({ lookup });
const guarded = new Agent({
connect(opts, cb) {
const host = opts.hostname.replace(/^\[|\]$/g, '');
const family = net.isIP(host);
if (family && deny.check(host, `ipv${family}`)) {
return cb(new Error(`拒否: ${host}`), null);
}
return connectViaLookup(opts, cb);
},
});
// 実行結果(OUT は外のサーバー):
// http://127.0.0.1:9000/ -> 拒否: 127.0.0.1
// http://[::ffff:127.0.0.1]:9000/ -> 拒否: ::ffff:7f00:1
// http://OUT:8000/r?to=http://127.0.0.1:9000/ -> 拒否: 127.0.0.1
// http://OUT:8000/r?to=http://evil.example:9000/ -> 拒否: evil.example -> 127.0.0.1
// http://OUT:8000/ok -> PUBLIC
//
// 改善の余地あり: リダイレクトの回数・宛先のポート・応答の大きさに上限を入れていない。
// リダイレクトの回数は、今は undici の fetch の既定(20 回まで)に任せている。
12 通りの結果は次のとおりです。列の A〜G は上に並べた 7 通りのガードで、「通過」は内部のサーバーの SECRET まで届いたもの、「拒否」はガードが止めたものです。
| 入力(ホストの部分) | A | B | C | D | E | F | G |
|---|---|---|---|---|---|---|---|
| 127.0.0.1(そのまま) | 拒否 | 拒否 | 拒否 | 通過 | 拒否 | 拒否 | 拒否 |
| localhost | 拒否 | 拒否 | 拒否 | 拒否 | 拒否 | 拒否 | 拒否 |
| 2130706433(10進) | 通過 | 拒否 | 拒否 | 通過 | 拒否 | 通過 | 拒否 |
| 0x7f000001(16進) | 通過 | 拒否 | 拒否 | 通過 | 拒否 | 通過 | 拒否 |
| 0177.0.0.1(8進) | 通過 | 拒否 | 拒否 | 通過 | 拒否 | 通過 | 拒否 |
| 127.1(省略形) | 拒否 | 拒否 | 拒否 | 通過 | 拒否 | 通過 | 拒否 |
| 0.0.0.0 | 拒否 | 拒否 | 拒否 | 通過 | 拒否 | 拒否 | 拒否 |
| [::1] | 拒否 | 拒否 | 拒否 | 通過 | 拒否 | 拒否 | 拒否 |
| [::ffff:127.0.0.1](射影) | 通過 | 拒否 | 拒否 | 通過 | 拒否 | 拒否 | 拒否 |
| evil.example(127.0.0.1を指す名前) | 通過 | 通過 | 拒否 | 拒否 | 拒否 | 通過 | 拒否 |
| 外の302 → 127.0.0.1 | 通過 | 通過 | 通過 | 通過 | 拒否 | 通過 | 通過 |
| 外の302 → evil.example | 通過 | 通過 | 通過 | 拒否 | 拒否 | 通過 | 通過 |
| 内部まで届いた数(12通り中) | 7 | 3 | 2 | 9 | 0 | 7 | 2 |
| 対照: 外の /ok | 取得 | 取得 | 取得 | 取得 | 取得 | 取得 | 取得 |
Python の F と G は、3.11.2・3.12.13・3.14.6 で同じ結果でした。
E が 12 通りとも止められた理由は、2 つあると考えています。1 つは、IP の直書きも、名前を引いた結果も、同じ deny で見ていることです。もう 1 つは、リダイレクトの先へ繋ぐときも、この connect を通ることです。名前を引いた結果をそのまま接続に使うので、C のように「確かめた IP と繋いだ IP が別物」になる隙間もありません。外の /ok は、7 通りとも普通に取れています。
運用を考えると
ここは好みが分かれるところですが、私はアプリのガードだけで守り切ろうとは考えていません。
運用を考えると、ガードは「書いた人が正しく使ったときだけ効く」ものです。D のように、筋がよさそうに見える書き方で、かえって弱くなることもありました。URL を取りに行く処理が新しく増えたとき、誰かが E の Agent を渡し忘れれば、それで終わりです。
なので、アプリの外でも絞ります。EC2 なら IMDSv2 を必須にしておけば、PUT でトークンを取ってからでないとメタデータは返りません。GET しか送れない SSRF では届かなくなり、追加の料金もかかりません。もう一段、外向きの通信を専用のプロキシに集めて、そこで行き先を絞る形もあります。こちらは常に動いているサーバーが 1 台増え、その費用と、監視・更新の手間がずっと残ります。
今の規模なら、IMDSv2 の必須化(0 円)とコード側の E で守り、プロキシは URL を取りに行く処理が増えてから考える、というのが私の判断です。
おわりに
7 通りのうち、12 通りを全部止めたのは E だけでした。そして、いちばん筋がよさそうに見えた D が、いちばん多く通しました。
今後は、この 12 通りの入力を、URL を取りに行く処理のテストとして CI に入れておきたいと考えています。ガードを書き換えたときに、どれかが通り始めたらすぐ気づけるようにするためです。Python で E に当たる形をどう書くかも、まだ試せていません。DNS rebinding も、偽の DNS サーバーを立てて実際に流してみるつもりです。