セキュリティ 2026年11月2日

SSRF対策の「内部IPなら拒否」をNode.jsとPythonで7通り書き、12通りの抜け道を流した。接続時のlookupで見る形はIP直書きを9通り素通しし、全部止めたのは1通りだけだった

XECIN SSRFNode.jsPython技術検証

先週、社内の検証環境で、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 まで届いたもの、「拒否」はガードが止めたものです。

入力(ホストの部分)ABCDEFG
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通り中)7329072
対照: 外の /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 サーバーを立てて実際に流してみるつもりです。