はじめに
外部入力として URL を受け取り、サーバー側でその URL にアクセスする処理を書くことがあります。
たとえば、次のような機能です。
- 入力された URL の OGP を取得する
- Webhook の送信先 URL を登録する
- 画像 URL を取得してプロキシする
- 顧客サイトの origin URL に対して Edge Worker から
fetch()する - 指定された URL の HTML をクロールする
このような処理では、SSRF に注意が必要です。
SSRF は Server-Side Request Forgery の略です。ここでは、「攻撃者が指定した URL に、サーバー側からアクセスさせられてしまう脆弱性」と考えます。
ブラウザから直接アクセスできない内部ネットワークでも、サーバーからならアクセスできることがあります。たとえば、次のような宛先です。
127.0.0.1localhost169.254.169.254- 社内ネットワークの IP
- クラウドのメタデータエンドポイント
- 本来外部公開していない管理画面
そのため、外部入力の URL をそのまま fetch(url) するのは危険です。
ここで大事なのは、URL を「文字列の見た目」だけで検査しないことです。
if (url.includes('127.0.0.1')) {
throw new Error('blocked');
}
このような検査は、一見よさそうに見えます。しかし URL には、同じ宛先を別の見た目で表す書き方があります。
たとえば、次の URL は見た目は違いますが、new URL() に通すと 127.0.0.1 として扱われます。
new URL('http://0x7f000001/').hostname;
// => '127.0.0.1'
new URL('http://2130706433/').hostname;
// => '127.0.0.1'
つまり、127.0.0.1 という文字列だけを探しても足りません。
また、Zod や Valibot の url() のようなバリデーションは、「URL としてパースできるか」を確認するには便利です。しかし、SSRF 対策で必要なのは「その URL をサーバー側でアクセスしてよいか」の判断です。
この2つは別物です。
この記事では、new URL() が URL をどのように解釈し、正規化するのかを確認したうえで、SSRF 対策の第一段階としてどのように検査すればよいかを整理します。
結論
URL を検査するときは、次の順番にします。
ポイントは、生の文字列ではなく、new URL() が返した結果を見ることです。
new URL() に通すと、URL はいくつかのルールに従って正規化されます。代表的には、次のようなものがあります。
- IPv4 の特殊な表記が
127.0.0.1のような通常表記にそろう -
expected.com@attacker.comのような URL で、本当の hostname が取り出せる - IPv6 の表記がそろう
- 大文字小文字や国際化ドメイン名がそろう
- デフォルトポートが空文字に正規化される
この結果を使えば、URL の見た目に引っ張られにくい検査を書けます。
ただし、new URL() は SSRF 対策の全部ではありません。あくまで入口です。
一般的なバリデーションライブラリが主に見るのは、次のような内容です。
- URL として構文的に妥当か
- protocol が許可されたものか
- host の形式が妥当か
- userinfo を禁止するか
- hostname の allowlist / denylist に合うか
一方で、SSRF ガードでは、さらに次のようなことを考える必要があります。
-
new URL()後の正規化結果を見る - IP リテラルを拒否する
-
localhost/.localhostを拒否する - DNS 解決後の IP が private / loopback / link-local / metadata 宛てでないか確認する
- リダイレクト先を再検査する
- fetch 実行環境の egress をネットワーク側で制限する
- 可能なら allowlist 方式にする
そのため、この記事で扱うのは「URL として正しいか」ではなく、「サーバー側でその URL にアクセスしてよいか」を考えるための最初のガードです。
なぜ「文字列の見た目」だけでは足りないのか
まず、不十分な例を見ます。
次のコードは、expected.com だけを許可したい気持ちで書かれています。
function assertExpectedDomain(rawUrl: string): void {
if (!rawUrl.includes('expected.com')) {
throw new Error('blocked');
}
}
しかし、これは危険です。
const rawUrl = 'http://expected.com@attacker.com/';
この URL には expected.com という文字列が含まれています。けれども、実際の接続先 hostname は expected.com ではありません。
const u = new URL('http://expected.com@attacker.com/');
console.log(u.username);
// => 'expected.com'
console.log(u.hostname);
// => 'attacker.com'
@ より前は userinfo と呼ばれる部分です。昔の URL では、ユーザー名やパスワードを URL に含めるために使われました。
http://user:password@example.com/
この場合、hostname は example.com です。user:password は hostname ではありません。
つまり、URL の中で「ドメインっぽく見える文字列」が、必ずしも接続先とは限りません。
そのため、SSRF 対策では「URL 文字列に何が含まれているか」ではなく、「URL パーサーが hostname として何を解釈したか」を見る必要があります。
new URL() がやってくれること
new URL() は、URL 文字列をただ分割するだけではありません。
URL の仕様に従ってパースし、いくつかの表記をそろえます。ここでは、SSRF 対策で特に重要なものを 5 つに絞って見ます。
1. IPv4 の特殊表記を通常の IPv4 表記にそろえる
まず重要なのが IPv4 です。
普段よく見る IPv4 は、次のような形式です。
127.0.0.1
これは dotted decimal と呼ばれる表記です。この記事では、わかりやすく「通常の IPv4 表記」と呼びます。4つの数字を . で区切る形です。
しかし、URL パーサーは、これ以外の IPv4 表記も解釈します。
const cases = [
'http://127.0.0.1/',
'http://0x7f000001/',
'http://2130706433/',
];
for (const c of cases) {
const u = new URL(c);
console.log(c, '=>', u.hostname);
}
結果は次のようになります。
http://127.0.0.1/ => 127.0.0.1
http://0x7f000001/ => 127.0.0.1
http://2130706433/ => 127.0.0.1
0x7f000001 は 16 進数表記です。2130706433 は 10 進数の単一数値表記です。どちらも new URL() に通すと 127.0.0.1 に正規化されます。
これが、「文字列に 127.0.0.1 が含まれているか」を見るだけでは危ない理由です。
次のような検査は抜けられます。
if (rawUrl.includes('127.0.0.1')) {
throw new Error('blocked');
}
しかし、new URL() の結果を見るなら、特殊表記が通常の表記にそろった後で検査できます。
const u = new URL(rawUrl);
if (u.hostname === '127.0.0.1') {
throw new Error('blocked');
}
もちろん実際には 127.0.0.1 だけでなく、プライベート IP やリンクローカル IP なども考える必要があります。ただ、まずは「正規化された hostname を見る」ことが前提になります。
2. userinfo と hostname を分けてくれる
次に userinfo です。
URL には、次のような形式があります。
http://user:password@example.com/
この場合、user:password は接続先ではありません。接続先 hostname は example.com です。
SSRF 対策で問題になりやすいのは、次のような URL です。
http://expected.com@attacker.com/
見た目だけでは expected.com にアクセスしているように見えるかもしれません。しかし実際の hostname は attacker.com です。
const u = new URL('http://expected.com@attacker.com/');
console.log(u.username);
// => 'expected.com'
console.log(u.hostname);
// => 'attacker.com'
new URL() を使えば、userinfo と hostname を分けて取得できます。
SSRF ガードでは、外部入力の URL に userinfo を許可しない方が安全です。
if (u.username !== '' || u.password !== '') {
throw new Error('URL must not contain userinfo');
}
多くのアプリケーションでは、Webhook 送信先や origin URL に userinfo は不要です。不要なものは拒否しておくと、見た目の紛らわしさを減らせます。
3. IPv6 を bracket 付きの hostname として扱う
IPv6 は [] で囲まれます。
const u = new URL('http://[::1]/');
console.log(u.hostname);
// => '[::1]'
::1 は IPv6 の loopback アドレスです。IPv4 の 127.0.0.1 に近いものです。
IPv6 には、IPv4 よりも表記ゆれがあります。たとえば、0 の連続を :: で省略できます。また、IPv4-mapped IPv6 address(IPv4 アドレスを IPv6 の形で表す形式)のようなものもあります。
const u = new URL('http://[::ffff:127.0.0.1]/');
console.log(u.hostname);
// Node.js では例: '[::ffff:7f00:1]'
SSRF ガードをシンプルに保ちたい場合は、外部入力の URL で IP リテラルそのものを拒否する設計が安全です。
ここでいう IP リテラルとは、ドメイン名ではなく IP アドレスを直接 URL に書く形式です。
http://127.0.0.1/
http://[::1]/
たとえば、次のように判定できます。
function isIpLiteralHostname(hostname: string): boolean {
if (hostname.startsWith('[')) {
return true; // IPv6
}
// IPv4 は new URL() 後に dotted decimal へ正規化される。
// この関数は new URL() 後の hostname にだけ使う前提。
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname)) {
return true; // IPv4 dotted decimal
}
return false;
}
この関数は、new URL() で正規化された後の hostname に対して使う前提です。
この正規表現は「IPv4 らしい形」を見るためのものです。生の入力値を検証するための汎用 IPv4 validator ではありません。
生の URL 文字列に対して使うものではありません。
4. ドメイン名の大文字小文字や IDN をそろえる
ドメイン名では、大文字小文字の違いは通常区別されません。
const u = new URL('https://Example.COM/');
console.log(u.hostname);
// => 'example.com'
new URL() に通すと、hostname は小文字に正規化されます。
また、日本語やキリル文字などを含む国際化ドメイン名は、punycode(ASCII 化された表記)に変換されます。
const u = new URL('https://тест.example/');
console.log(u.hostname);
// => 'xn--e1aybc.example'
そのため、ドメインの許可リストと比較するときは、生の URL 文字列ではなく、u.hostname を使う方が扱いやすくなります。
const allowedHosts = new Set([
'example.com',
'api.example.com',
]);
const u = new URL(rawUrl);
if (!allowedHosts.has(u.hostname)) {
throw new Error('host is not allowed');
}
ただし、IDN には見た目が紛らわしい文字の問題もあります。たとえば、ラテン文字に似た別の文字を使うケースです。
重要な送信先を許可する場合は、「任意の外部ドメインを受け付けて後から危険なものを弾く」よりも、「許可した hostname だけを通す」許可リスト方式の方が安全です。
5. デフォルトポートは空文字になる
URL には port もあります。
const u = new URL('http://example.com:8080/');
console.log(u.port);
// => '8080'
ただし、scheme のデフォルトポートは空文字に正規化されます。
console.log(new URL('http://example.com:80/').port);
// => ''
console.log(new URL('https://example.com:443/').port);
// => ''
これは、http://example.com と http://example.com:80 が同じデフォルトポートを意味するためです。
ポートを検査するときは、この挙動を前提にします。
たとえば、標準ポートだけを許可し、非標準ポートを拒否したいなら、次のように考えます。
function isAllowedPort(u: URL): boolean {
// http:80 / https:443 のようなデフォルトポートは空文字になる
if (u.port === '') {
return true;
}
// 非標準ポートを個別に許可したい場合だけ追加する
return false;
}
http://example.com:80/ や https://example.com:443/ の port は空文字になるので、u.port === '80' や u.port === '443' だけを見ても判定できません。
実際に確認してみる
ここまでの挙動は、手元で確認できます。
const cases = [
'http://10.0.0.1/',
'http://169.254.169.254/',
'http://127.0.0.1/',
'http://localhost/',
'http://expected.com@attacker.com/',
'http://0x7f000001/',
'http://2130706433/',
'http://[::1]/',
'http://[::ffff:127.0.0.1]/',
'http://example.com:80/',
'https://Example.COM./',
'https://тест.example/',
];
for (const c of cases) {
const u = new URL(c);
console.log(c, '=>', {
protocol: u.protocol,
username: u.username,
password: u.password,
hostname: u.hostname,
port: u.port,
});
}
Node.js で実行すると、たとえば次のような結果になります。
http://0x7f000001/
=> { protocol: 'http:', username: '', password: '', hostname: '127.0.0.1', port: '' }
http://2130706433/
=> { protocol: 'http:', username: '', password: '', hostname: '127.0.0.1', port: '' }
http://expected.com@attacker.com/
=> { protocol: 'http:', username: 'expected.com', password: '', hostname: 'attacker.com', port: '' }
http://[::1]/
=> { protocol: 'http:', username: '', password: '', hostname: '[::1]', port: '' }
http://[::ffff:127.0.0.1]/
=> { protocol: 'http:', username: '', password: '', hostname: '[::ffff:7f00:1]', port: '' }
http://example.com:80/
=> { protocol: 'http:', username: '', password: '', hostname: 'example.com', port: '' }
https://Example.COM./
=> { protocol: 'https:', username: '', password: '', hostname: 'example.com.', port: '' }
https://тест.example/
=> { protocol: 'https:', username: '', password: '', hostname: 'xn--e1aybc.example', port: '' }
ここで注目したいのは、見た目が違っても、URL パーサーを通すと検査しやすい形にそろうことです。
0x7f000001 は 127.0.0.1 になります。
expected.com@attacker.com は hostname が attacker.com になります。
Example.COM は example.com になります。
http://example.com:80/ の port は空文字になります。
この結果に対して検査するのが基本です。
SSRF ガードの第一段階を書く
ここから、実際の検査コードを組み立てます。
前提として、これは「SSRF 対策の第一段階」です。このコードだけで SSRF を完全に防げるわけではありません。
特に、hostname を DNS 解決した結果が内部 IP に変わるケースや、DNS rebinding のような問題は、URL parser だけでは防げません。そこは後で説明します。
まずは、外部入力の URL に対して最低限の検査を入れます。
export function assertHttpUrlForServerFetch(rawUrl: string): URL {
const safeUrlForLog = rawUrl.slice(0, 128);
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new Error(`invalid URL: ${safeUrlForLog}`);
}
// 1. scheme を http / https に限定する
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`URL must use http or https: ${url.protocol}`);
}
// 2. userinfo は拒否する
if (url.username !== '' || url.password !== '') {
throw new Error(`URL must not contain userinfo: ${safeUrlForLog}`);
}
// 3. IP リテラルは拒否する
if (isIpLiteralHostname(url.hostname)) {
throw new Error(`URL must not use an IP address directly: ${url.hostname}`);
}
// 4. localhost 系の hostname は拒否する
if (isLoopbackHostname(url.hostname)) {
throw new Error(`URL must not target localhost: ${url.hostname}`);
}
return url;
}
function isIpLiteralHostname(hostname: string): boolean {
// IPv6 は new URL() 後の hostname では [::1] のように bracket 付きになる
if (hostname.startsWith('[')) {
return true;
}
// IPv4 は new URL() 後に dotted decimal へ正規化される
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname);
}
function isLoopbackHostname(hostname: string): boolean {
const lower = hostname.toLowerCase();
return lower === 'localhost' || lower.endsWith('.localhost');
}
使う側はこうです。
const url = assertHttpUrlForServerFetch(inputUrl);
const response = await fetch(url);
このコードでやっていることは、次の4つです。
-
http:/https:以外を拒否する - userinfo を拒否する
- IP アドレスを直接書いた URL を拒否する
-
localhost/*.localhostを拒否する
ここで重要なのは、すべて new URL() の結果に対して検査していることです。
0x7f000001 のような URL は、new URL() の時点で 127.0.0.1 に正規化されます。そのため、isIpLiteralHostname() では dotted decimal の IPv4 だけを見れば検出できます。
バリデーションライブラリだけで十分ではないのか
ここで、「Zod や Valibot の url() でよいのでは」と思うかもしれません。
入力値が URL として妥当かを見るだけなら、それで十分な場面は多いです。
たとえば、フォームで「ユーザーのプロフィール URL」を保存するだけなら、URL としてパースできるか、https: かどうか、長すぎないか、という検査で足りるかもしれません。
しかし、サーバー側でその URL にアクセスする場合は話が変わります。
問題は、「URL として正しいか」ではなく、「その URL にサーバーからアクセスしてよいか」です。
一般的なバリデーションライブラリは、主に次のような検査を助けてくれます。
- URL の構文として妥当か
-
http:/https:など、許可した protocol か - host が存在するか
- userinfo を許可するか禁止するか
- 文字列として許可リストや拒否リストに合うか
これらは有用です。
ただし、SSRF 対策では、それだけでは足りません。
たとえば、次のような観点は、通常の url() バリデーションだけでは閉じません。
- hostname を DNS 解決した結果が内部 IP ではないか
- DNS rebinding で解決結果が変わらないか
- リダイレクト先が内部 IP へ向かわないか
- クラウドのメタデータエンドポイントに到達しないか
- 実行環境から内部ネットワークへ egress できてしまわないか
つまり、バリデーションライブラリは「URL として成立しているか」を確認する道具です。
一方で、SSRF ガードは「サーバー側でアクセスしてよい宛先か」を判断する仕組みです。
両者は重なりますが、同じものではありません。
そのため、バリデーションライブラリを使う場合でも、SSRF 対策としては追加の検査やネットワーク制御が必要になります。
許可リスト方式にできるなら、その方が安全
ここまでのコードは、危険そうな URL を弾く方式です。
しかし、安全性を高めたいなら、できるだけ許可リスト方式に寄せた方がよいです。
つまり、「危険なものを探して拒否する」のではなく、「許可したものだけを通す」方式です。
たとえば、Webhook 送信先や origin URL が事前に決まっているなら、hostname を許可リストで比較します。
const allowedHosts = new Set([
'example.com',
'api.example.com',
]);
export function assertAllowedOrigin(rawUrl: string): URL {
const url = assertHttpUrlForServerFetch(rawUrl);
if (!allowedHosts.has(url.hostname)) {
throw new Error(`host is not allowed: ${url.hostname}`);
}
return url;
}
OWASP でも、SSRF 対策では scheme、port、destination を positive allowlist、つまり許可リストで制限することが推奨されています。特に、接続先が事前に決まっているシステムでは許可リストが強いです。
一方で、OGP 取得や一般的な URL クロールのように、任意の外部 URL を受け付けたい機能もあります。その場合は許可リストが難しいため、URL パーサーによる検査、DNS 解決後の IP 検査、egress 制御、リダイレクト制御などを組み合わせる必要があります。
URL パーサーの挙動をテストで固定する
ここまでの検査は、new URL() の挙動に依存しています。
たとえば、次の前提があります。
-
0x7f000001は127.0.0.1に正規化される -
2130706433も127.0.0.1に正規化される -
expected.com@attacker.comは hostname がattacker.comになる - IPv6 は bracket 付きの hostname になる
- デフォルトポートは空文字になる
これらは WHATWG URL Standard に沿った挙動ですが、自分のコードの前提として使っているなら、テストで固定しておくと安心です。
import { describe, expect, it } from 'vitest';
describe('URL parser assumptions', () => {
it('IPv4 hex 表記は dotted decimal に正規化される', () => {
expect(new URL('http://0x7f000001/').hostname).toBe('127.0.0.1');
});
it('IPv4 decimal 表記も dotted decimal に正規化される', () => {
expect(new URL('http://2130706433/').hostname).toBe('127.0.0.1');
});
it('userinfo は hostname から分離される', () => {
const u = new URL('http://expected.com@attacker.com/');
expect(u.username).toBe('expected.com');
expect(u.hostname).toBe('attacker.com');
});
it('IPv6 は bracket 付きの hostname になる', () => {
expect(new URL('http://[::1]/').hostname).toBe('[::1]');
});
it('default port は空文字になる', () => {
expect(new URL('http://example.com:80/').port).toBe('');
expect(new URL('https://example.com:443/').port).toBe('');
});
});
これは、一見すると「自分のコードではなく、標準ライブラリをテストしている」ように見えます。
しかし、目的は標準ライブラリの正しさを検証することではありません。
自分の SSRF ガードが依存している前提を、CI 上で見える形にすることです。Node.js や Edge runtime を更新したときに、もし URL パーサーの挙動が変われば、このテストが気づくきっかけになります。
この検査だけでは防げないもの
ここまでの話は、URL 文字列をパースする段階の防御です。
ただし、SSRF は URL パーサーだけでは閉じません。
ここは重要です。
DNS rebinding
DNS rebinding は、hostname の解決結果があとから変わる問題です。
たとえば、最初は外部 IP を返す hostname が、次の DNS 解決では 127.0.0.1 やプライベート IP を返すようなケースです。
URL パーサーは DNS 解決をしません。
const u = new URL('http://evil.example.com/');
console.log(u.hostname);
// => 'evil.example.com'
この時点では、evil.example.com がどの IP に解決されるかはわかりません。
つまり、new URL() による検査を通っても、実際の fetch() 時に内部 IP へ向かう可能性は残ります。
対策としては、次のような設計が必要です。
- DNS 解決後の IP アドレスを検査する
- 内部 IP・リンクローカル IP・メタデータ IP への egress をネットワーク側で止める
- リダイレクト先も再検査する
- 可能なら接続先を許可リストで制限する
クラウドのメタデータエンドポイント
クラウド環境では、メタデータエンドポイントにも注意が必要です。
代表例として、AWS では 169.254.169.254 がインスタンスメタデータサービスのアドレスとして使われます。
このような IP を直接 URL に書かれた場合、先ほどの IP リテラル拒否で止められます。
http://169.254.169.254/
しかし、hostname 経由で内部のメタデータサービスに到達できる環境もあります。
たとえば、クラウド環境によっては metadata.google.internal のような hostname がメタデータサービスに使われます。
このような名前は、IP アドレスを直接書いた URL ではないため、単純な IP リテラル拒否では止まりません。
そのため、メタデータエンドポイントへのアクセスは、アプリケーションの URL 検査だけでなく、クラウド側・ネットワーク側の egress 制御、つまりサーバーから外向きに出ていく通信の制御でも防ぐ必要があります。
リダイレクト
URL の最初の宛先だけを検査しても、リダイレクト先が危険な場合があります。
https://safe.example/redirect?to=http://127.0.0.1/
最初の URL は安全そうに見えても、fetch が自動的にリダイレクトをたどると、内部 IP に向かう可能性があります。
そのため、SSRF 対策ではリダイレクトにも注意します。
- 自動リダイレクトを無効にする
- リダイレクト先 URL を再度検査する
- リダイレクト回数に上限を設ける
new URL() の検査は、最初の URL だけでなく、リダイレクト先にも必要です。
末尾ドット付きのホスト名
ドメイン名には、末尾に . が付くことがあります。
const u = new URL('https://Example.COM./');
console.log(u.hostname);
// => 'example.com.'
example.com と example.com. は、DNS の文脈では近い意味を持つことがあります。しかし、文字列比較では別物です。
許可リストと比較するときに、末尾の dot をどう扱うかは設計で決める必要があります。
たとえば、許可リストに example.com だけを入れている場合、example.com. を同じものとして扱うのか、それとも拒否するのかを決めます。
単純に扱うなら、外部入力では trailing dot を拒否してもよいです。
if (url.hostname.endsWith('.')) {
throw new Error(`hostname must not end with a dot: ${url.hostname}`);
}
または、末尾の dot を取り除いてから許可リストと比較する設計もあります。
どちらにするかは、サービスの要件次第です。
まとめ
URL を検査するときは、文字列の見た目だけで判断しない方が安全です。
0x7f000001 のような IPv4 の特殊表記は、new URL() に通すと 127.0.0.1 に正規化されます。
expected.com@attacker.com のような URL は、expected.com ではなく attacker.com が hostname として扱われます。
国際化ドメイン名や大文字小文字、デフォルトポートも、URL パーサーの規則に従ってそろえられます。
そのため、SSRF ガードでは次の順番が基本になります。
- 生の URL 文字列を
new URL()に通す -
protocol/username/password/hostname/portを見る -
http:/https:以外を拒否する - userinfo を拒否する
- IP リテラルや localhost を拒否する
- 可能なら hostname を許可リストで制限する
- DNS 解決後の IP、リダイレクト、egress 制御も別途考える
一般的なバリデーションライブラリは、URL として妥当かを確認するには便利です。
しかし、サーバー側で fetch する URL では、それだけでは足りません。必要なのは、「URL として正しいか」だけでなく、「サーバーからアクセスしてよい宛先か」を判断することです。
new URL() は SSRF 対策の全部ではありません。
しかし、最初に URL を正規化し、URL パーサーが解釈した結果を見ることで、文字列ベースの検査よりも URL の意味に沿ったガードを書きやすくなります。
SSRF 対策では、「URL 文字列をそのまま見ない」ことが出発点になります。まずパースし、正規化された結果を見て判断します。