- 対象: 「URL を指定するとサーバーがその URL を取りに行く」機能を持つサイトを作っている・運用している Web 担当者・制作会社の方
- 解決すること: その機能が、外からは届かない社内サーバーやクラウドのメタデータへの「踏み台」にされる SSRF を防ぐ
- ゴール: 棚卸し → 許可リスト → URL の書式検証 → 接続先 IP の検証と固定 → WordPress の正しい関数 → サーバー側の多層防御 までコピペで揃える
はじめに:SSRF とは
SSRF(Server-Side Request Forgery / サーバーサイド・リクエスト・フォージェリ)は、利用者が指定した URL にサーバーが代わりにアクセスする機能を悪用され、本来は外から届かない宛先へサーバー自身がリクエストを送ってしまう脆弱性です。
リンクのプレビュー(OGP カード)、「画像の URL から取り込む」ボタン、Webhook の送信先登録、RSS の取り込み、Web ページの PDF 化など、身近な機能ほど該当します。
問題は、サーバーはインターネット側からは見えない場所にもアクセスできることです。
-
127.0.0.1やlocalhostで待ち受けている管理画面・キャッシュサーバー・検索エンジン - 同じネットワーク内の社内サーバー(
10.x.x.x/192.168.x.xなど) - クラウドのインスタンスメタデータ(AWS なら
169.254.169.254)。設定によってはここから一時的な認証情報まで読み出せます
OWASP も SSRF 対策のチートシートを公開しており、**「許可リストで絞る」「アプリ側の検証とネットワーク側の制限を重ねる」**を基本としています。
- OWASP: Server-Side Request Forgery Prevention Cheat Sheet
https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
以下、自社のコードとサーバーを対象に、再発防止の観点で 5 つの検証と多層防御を示します。コードは PHP 8.2 以降(ext-curl)を前提にしています。
手順0:URL を取りに行くコードを棚卸しする
まず「利用者の入力が URL として使われる箇所」がどこにあるかを把握します。自作のテーマ・プラグイン・アプリのディレクトリを対象に実行してください。
# 外部へリクエストを送る可能性のある関数呼び出しを一覧にする
grep -rnE --include='*.php' \
'curl_init|CURLOPT_URL|file_get_contents\(\s*\$|fopen\(\s*\$|getimagesize\(\s*\$|simplexml_load_file\(|wp_remote_(get|post|head|request)\(' \
wp-content/themes/your-theme wp-content/plugins/your-plugin /var/www/app
ヒットした箇所ごとに「その URL は利用者が決められるか」を確認します。コード内の固定 URL(決済 API のエンドポイントなど)は対象外です。利用者が決められる箇所だけが、以下の対策の適用先です。
検証1:そもそも自由な URL を受け取らない(許可リスト)
いちばん確実なのは、利用者に URL そのものを入力させない設計です。
- 連携先が決まっているなら、URL ではなく「連携先の種類」(
news/blogなど)を選ばせ、URL はサーバー側の対応表から引く - Webhook のように相手先が限られるなら、ホスト名の許可リストと完全一致で照合する
<?php
// Webhook の送信先: https かつ許可したホスト名と完全一致のときだけ登録を受け付ける
const WEBHOOK_ALLOWED_HOSTS = ['hooks.example-chat.com', 'api.partner.example.jp'];
function is_allowed_webhook(string $url): bool
{
$p = parse_url($url);
return is_array($p)
&& strtolower($p['scheme'] ?? '') === 'https'
&& !isset($p['user']) && !isset($p['pass'])
&& !isset($p['port']) // ポート指定付きは受け付けない
&& in_array(strtolower($p['host'] ?? ''), WEBHOOK_ALLOWED_HOSTS, true);
}
str_contains($host, 'example.com') のような部分一致は使いません。example.com.別のドメイン のような名前をすり抜けさせる原因になります。
OGP プレビューのように「任意のサイトを取りに行くこと自体が機能」の場合は許可リストが使えないため、次の検証2〜4 をすべて入れます。
検証2:スキーム・書式・ポートを絞る
URL の文字列としての検証です。ここでは通してよいものだけを通す考え方で書きます。
- スキームは
http/httpsのみ(file:ftp:などを拒否) -
user:pass@付きの URL は拒否(表示上のホスト名を読み違える原因) - ポートは 80 / 443 のみ(社内サービスの待ち受けポートへ向けさせない)
- 空白・制御文字・バックスラッシュを含む URL は拒否(URL の解釈が実装ごとに食い違う原因)
<?php
// safe_fetch.php(前半)— 利用者が指定した URL を安全に取得するためのヘルパー
declare(strict_types=1);
const SAFE_FETCH_PORTS = [80, 443];
const SAFE_FETCH_MAX_BYTES = 2 * 1024 * 1024; // 取得サイズの上限 2MB
const SAFE_FETCH_MAX_HOPS = 3; // リダイレクトを追う上限
final class UnsafeUrlException extends RuntimeException {}
/** 検証2: スキーム・書式・ポート */
function sf_parse_url(string $url): array
{
if ($url === '' || strlen($url) > 2048 || preg_match('/[\s\x00-\x1f\x7f\\\\]/', $url)) {
throw new UnsafeUrlException('URL に使えない文字が含まれています');
}
$p = parse_url($url);
if ($p === false || !isset($p['scheme']) || empty($p['host'])) {
throw new UnsafeUrlException('URL の形式が不正です');
}
$scheme = strtolower($p['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) {
throw new UnsafeUrlException('http / https 以外は使えません');
}
if (isset($p['user']) || isset($p['pass'])) {
throw new UnsafeUrlException('ユーザー情報付きの URL は使えません');
}
$port = $p['port'] ?? ($scheme === 'https' ? 443 : 80);
if (!in_array($port, SAFE_FETCH_PORTS, true)) {
throw new UnsafeUrlException('許可されていないポートです');
}
// IPv6 リテラル [::1] の角かっこと、末尾のドットを外して正規化する
$host = rtrim(strtolower(trim($p['host'], '[]')), '.');
return ['scheme' => $scheme, 'host' => $host, 'port' => $port];
}
「127.0.0.1 や localhost という文字列を含んでいたら拒否」という文字列のブラックリストは使いません。内部を指す書き方は何通りもあり、名前解決の結果が内部アドレスになるホスト名も作れるためです。内部かどうかは、次の検証3で名前解決した後の IP アドレスで判定します。
検証3:名前解決した「すべての」IP アドレスが公開アドレスか確かめる
ホスト名を DNS で引き、返ってきた IPv4(A)と IPv6(AAAA)の全部が、インターネット上の公開アドレスであることを確認します。1 つでも内部向けのアドレスが混じっていたら拒否します。
判定には PHP の filter_var() を使います。
| フラグ | 拒否する範囲(PHP マニュアルより) |
|---|---|
FILTER_FLAG_NO_PRIV_RANGE |
IPv4 の 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16、IPv6 の FC / FD で始まるアドレス |
FILTER_FLAG_NO_RES_RANGE |
IPv4 の 0.0.0.0/8 127.0.0.0/8 169.254.0.0/16 240.0.0.0/4、IPv6 の ::1 :: ::ffff:0:0/96 fe80::/10
|
FILTER_FLAG_GLOBAL_RANGE |
RFC 6890 で Global でない範囲をまとめて拒否(PHP 8.2.0 以降) |
NO_PRIV_RANGE と NO_RES_RANGE だけでは、キャリアグレード NAT 用の 100.64.0.0/10 などが残ります。PHP 8.2 以降なら FILTER_FLAG_GLOBAL_RANGE を必ず併用してください。AWS のメタデータは IPv4 が 169.254.169.254、IPv6 が fd00:ec2::254 で、どちらもこの判定で拒否されます。
<?php
// safe_fetch.php(中盤)
/** 検証3: インターネット上の公開アドレスか */
function sf_is_public_ip(string $ip): bool
{
return filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6
| FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
| FILTER_FLAG_GLOBAL_RANGE // PHP 8.2 以降
) !== false;
}
/** 検証3: 名前解決した全アドレスを確認し、接続に使うアドレスを 1 つ返す */
function sf_resolve_public(string $host): string
{
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
$ips = [$host];
} else {
$ips = [];
foreach (@dns_get_record($host, DNS_A | DNS_AAAA) ?: [] as $r) {
if (isset($r['ip'])) { $ips[] = $r['ip']; }
if (isset($r['ipv6'])) { $ips[] = $r['ipv6']; }
}
}
if ($ips === []) {
throw new UnsafeUrlException('名前解決できません');
}
foreach ($ips as $ip) {
if (!sf_is_public_ip($ip)) {
throw new UnsafeUrlException('内部向けのアドレスには接続できません');
}
}
return $ips[0];
}
名前解決できなかったホスト名は、その時点で拒否します(「解決できないから通す」にしない)。
検証4:検証した IP に接続を固定し、リダイレクトは 1 回ごとに検証し直す
検証3だけでは不十分です。よくある抜けが 2 つあります。
- 検証と接続で名前解決が 2 回走る: 検証時は公開アドレスでも、curl が接続時にもう一度 DNS を引くと別のアドレスが返ることがあります(DNS の応答は相手側が自由に変えられます)
-
リダイレクトの自動追従: 最初の URL は公開サイトでも、その応答の
Location:が内部を指していれば、自動追従した瞬間に内部へアクセスします
対策は次の 3 点です。
-
CURLOPT_RESOLVEで検証済みの IP アドレスに接続先を固定する(ホスト名は変えないので、HTTPS の証明書検証と SNI はそのまま効きます) -
CURLOPT_FOLLOWLOCATIONはfalseにし、3xx の行き先は検証2・3 からやり直す - 環境変数のプロキシ設定を使わない(
CURLOPT_NOPROXY)。プロキシ経由だと接続先の固定が効きません
<?php
// safe_fetch.php(後半)
/** 検証4: 検証済みの IP に固定して取得する。リダイレクトは 1 回ごとに検証し直す */
function safe_fetch(string $url): string
{
for ($hop = 0; $hop <= SAFE_FETCH_MAX_HOPS; $hop++) {
$u = sf_parse_url($url);
$ip = sf_resolve_public($u['host']);
$pin = str_contains($ip, ':') ? "[{$ip}]" : $ip; // IPv6 は角かっこで囲む
$body = '';
$ch = curl_init($url);
if (filter_var($u['host'], FILTER_VALIDATE_IP) === false) {
// ホスト名のときだけ、検証済みの IP に接続先を固定する(IP 直指定は DNS を引かない)
curl_setopt($ch, CURLOPT_RESOLVE, ["{$u['host']}:{$u['port']}:{$pin}"]);
}
curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_NOPROXY => '*',
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 10,
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use (&$body): int {
if (strlen($body) + strlen($chunk) > SAFE_FETCH_MAX_BYTES) {
return 0; // 上限を超えたら転送を中断する
}
$body .= $chunk;
return strlen($chunk);
},
]);
// 通信に使うプロトコルを http / https に限定する
if (defined('CURLOPT_PROTOCOLS_STR')) { // PHP 8.3 以降 + cURL 7.85 以降
curl_setopt($ch, CURLOPT_PROTOCOLS_STR, 'http,https');
} else {
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
}
$ok = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$next = curl_getinfo($ch, CURLINFO_REDIRECT_URL);
if ($ok === false) {
throw new UnsafeUrlException('取得に失敗しました');
}
if ($status >= 300 && $status < 400 && is_string($next) && $next !== '') {
$url = $next; // 行き先は次の周回で検証2・3 からやり直す
continue;
}
if ($status !== 200) {
throw new UnsafeUrlException('取得に失敗しました');
}
return $body;
}
throw new UnsafeUrlException('リダイレクトが多すぎます');
}
呼び出す側では、取得した中身をそのまま画面に返さないことと、エラー表示を出し分けないことが大切です。「接続できなかった」「タイムアウトした」を区別して返すと、外から社内ネットワークの様子を探る手がかりになります。
<?php
// 呼び出し側の例(リンクプレビュー)
require __DIR__ . '/safe_fetch.php';
try {
$html = safe_fetch((string)($_POST['url'] ?? ''));
// ここで <title> など必要な値だけを取り出す。表示時は htmlspecialchars() でエスケープ
} catch (UnsafeUrlException $e) {
error_log('safe_fetch rejected: ' . $e->getMessage()); // 詳細はログにだけ残す
http_response_code(400);
exit('この URL は取得できません'); // 利用者への表示は 1 種類
}
動作確認(自分の環境だけで実行)
検証関数が内部向けの URL を拒否するかを、PHP CLI で確かめます。実際の通信は行わず、書式検証と名前解決だけを確認します。
<?php
// test_safe_fetch.php — php test_safe_fetch.php で実行
require __DIR__ . '/safe_fetch.php';
$mustReject = [
'file:///tmp/test.txt', 'http://127.0.0.1/', 'http://localhost/', 'http://[::1]/',
'http://169.254.169.254/', 'http://10.0.0.1/', 'http://100.64.0.1/',
'http://user:pass@example.com/', 'http://example.com:8080/',
];
foreach ($mustReject as $url) {
try {
$u = sf_parse_url($url);
sf_resolve_public($u['host']);
echo "NG(通ってしまった): {$url}\n";
} catch (UnsafeUrlException $e) {
echo "OK(拒否): {$url} … {$e->getMessage()}\n";
}
}
すべて OK(拒否) になれば検証2・3 は機能しています。1 件でも NG が出たら、PHP のバージョン(8.2 未満だと FILTER_FLAG_GLOBAL_RANGE が無い)を確認してください。
検証5:WordPress では wp_safe_remote_get() を使う
WordPress には SSRF 対策済みの関数が用意されています。利用者が指定した URL を取りに行くときは、wp_remote_get() ではなく wp_safe_remote_get()(POST なら wp_safe_remote_post())を使います。公式ドキュメントには「URL と、リダイレクト先のすべての URL が wp_http_validate_url() で検証される」と明記されています。
wp_http_validate_url() が行う主なチェックは次のとおりです(現行のソースより)。
- スキームは
http/httpsのみ、user:pass@付きは拒否 - ホスト名を名前解決し、
127.0.0.0/810.0.0.0/8172.16.0.0/12192.168.0.0/16169.254.0.0/16100.64.0.0/10などの内部・予約アドレスなら拒否 - ポートは既定で 80 / 443 / 8080 のみ(
http_allowed_safe_portsフィルターで変更可)
<?php
// 自作プラグインの例: 管理画面から「フィード URL」を取り込む Ajax 処理
add_action( 'wp_ajax_myplugin_fetch_feed', function () {
check_ajax_referer( 'myplugin_fetch_feed' ); // CSRF 対策(nonce)
if ( ! current_user_can( 'manage_options' ) ) { // 権限確認
wp_send_json_error( '権限がありません', 403 );
}
$url = esc_url_raw( wp_unslash( $_POST['feed_url'] ?? '' ), array( 'http', 'https' ) );
$response = wp_safe_remote_get( $url, array(
'timeout' => 10,
'redirection' => 3,
'limit_response_size' => 2 * MB_IN_BYTES,
) );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
wp_send_json_error( 'この URL は取得できません', 400 ); // 理由は出し分けない
}
// 以降、wp_remote_retrieve_body() から必要な値だけを取り出して返す
} );
注意点は 3 つです。
-
フィルターで保護を外さない:
http_request_host_is_externalに無条件でtrueを返すと、内部アドレスの拒否が丸ごと無効になります。必要なら特定のホスト名のときだけtrueを返します -
自サイトのホスト名は検査対象外:
homeと同じホスト名は IP を検査せずに通します。「サーバー自身からなら許可」という IP 制限で守っている管理用 URL があれば見直します -
多層防御と併用する: IP の検査は
gethostbyname()(IPv4)で行われ、接続時には改めて名前解決されます。次節のサーバー側の制限を重ねてください
多層防御:サーバー側で「行き先」を制限する
アプリの検証は、書き忘れた 1 か所から破られます。サーバー側でも行き先を絞っておきます。
1. PHP の実行ユーザーからメタデータへの通信を拒否する
PHP-FPM の実行ユーザー(Debian / Ubuntu の既定は www-data)から、クラウドのメタデータへの通信を OS のファイアウォールで止めます。
# PHP-FPM の実行ユーザーからメタデータ(IPv4 / IPv6)への通信を拒否する
sudo iptables -I OUTPUT -m owner --uid-owner www-data -d 169.254.169.254 -j REJECT
sudo ip6tables -I OUTPUT -m owner --uid-owner www-data -d fd00:ec2::254 -j REJECT
# 再起動後も残す(Debian / Ubuntu)
sudo apt install -y iptables-persistent
sudo netfilter-persistent save
PHP のコードが AWS SDK でインスタンスロールの認証情報を使っている場合は、この設定で動かなくなります。その場合は次の IMDSv2 の必須化を優先してください。
2. AWS なら IMDSv2 を必須にする
EC2 のメタデータは、トークンを使う IMDSv2 を必須にすると、トークン取得の手順を踏まない単純な GET では読めなくなります。AWS も httpTokens=required を推奨しています。切り替え前に、CloudWatch の MetadataNoToken メトリクスで IMDSv1 の呼び出しが 0 件であることを確認してください。
# 現在の設定を確認する(HttpTokens が required なら IMDSv2 必須)
aws ec2 describe-instances --instance-ids i-1234567890abcdef0 \
--query 'Reservations[].Instances[].MetadataOptions'
# IMDSv2 を必須にする
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-endpoint enabled
- AWS: Modify instance metadata options for existing instances
https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-existing-instances.html
3. 「localhost だから認証なし」をやめる
SSRF で届くのは、まさに 127.0.0.1 で待ち受けているサービスです。キャッシュサーバーや検索エンジン、社内向け管理画面は、localhost 限定であっても認証を必須にしておきます。
まとめ
| # | 対策 | 目的 |
|---|---|---|
| 0 | URL を取りに行くコードの棚卸し | 対策の適用先を漏れなく把握する |
| 1 | 自由な URL を受け取らない(対応表・ホスト許可リスト) | 行き先そのものを固定する |
| 2 | スキーム・書式・ポートの検証 |
file: や社内サービスのポートを入口で落とす |
| 3 | 名前解決した全 IP が公開アドレスか確認 | 内部・メタデータへの接続を拒否する |
| 4 | 検証済み IP への固定 + リダイレクトの再検証 | 検証後のすり替えを防ぐ |
| 5 | WordPress は wp_safe_remote_get()
|
用意された対策済み関数を使う |
| + | 実行ユーザーの通信制限 + IMDSv2 + localhost も認証 | アプリの書き忘れを下で受け止める |
SSRF は「URL を取りに行く便利な機能」を作った時点で生まれる脆弱性です。まずは手順0の棚卸しを 1 回実行し、利用者が URL を決められる箇所がいくつあるかを数えるところから始めてみてください。
関連記事
本記事のような設定の抜けは、Web サイトを 9 つの守りでまるごと守るサイトドックでまとめて対策できます → https://sitedock.jp