2026年8月2日

2026年8月2日

WordPressのSSRF(サーバーサイドリクエストフォージェリ)対策:wp_safe_remote_getと通信制限で内部ネットワークを守る

WordPressのSSRF対策の中心は、「利用者が指定したURLへサーバーから通信する処理」を見つけ、その通信を wp_safe_remote_get() と許可リストで絞り込み、さらにサーバー側で内部ネットワークやクラウドのメタデータへの通信を止めることです。WordPress標準の安全確認には抜けている範囲があるため、コードとネットワークの二段構えにします。この記事では、SSRFが起きる場所の見つけ方、プラグインやテーマのコードの直し方、wp-config.php とファイアウォールでの制限、対策が効いているかの確認方法までを解説します。

SSRFとは何か、WordPressではどこで起きるか

SSRF(Server-Side Request Forgery)は、攻撃者がWebサーバーに「このURLを取得して」と頼み、サーバー自身に本来届かない宛先へ通信させる攻撃です。外部からは見えない社内システム、データベースの管理画面、127.0.0.1 で待ち受けるサービス、クラウドのインスタンスメタデータ(169.254.169.254)などが狙われます。メタデータからは一時的な認証情報が取得できる環境もあり、被害がサイトの外にまで広がるおそれがあります。

WordPressでSSRFの入口になりやすいのは、次のような「URLを受け取ってサーバーが取りに行く」機能です。

  • URLを貼るとタイトルや画像を取得するリンクプレビュー、OGP取得機能
  • 「URLから画像をインポート」「外部フィードを読み込む」などの取り込み機能
  • 管理画面でWebhookや外部APIのURLを自由に設定できるプラグイン
  • 指定URLのページをPDFや画像に変換する機能
  • XML-RPCのピンバック(相手サイトのURLを取得して確認する仕組み)

自分のサイトにどのような機能があるかを洗い出すには、テーマとプラグインのコードから外部通信を行う関数を検索するのが早道です。

cd /var/www/html/wp-content
grep -rnE "wp_remote_(get|post|request|head)|wp_safe_remote_|curl_exec|file_get_contents\(\s*\\\$" plugins themes --include=*.php | less

見つかった箇所のうち、URLが $_GET・$_POST・REST APIのパラメーター・投稿者が入力できるメタ情報などから来ているものが調査対象です。固定のAPIエンドポイントにしか通信しないコードは、通常はSSRFの心配がありません。

WordPress標準の安全確認でできること・できないこと

WordPressのHTTP APIには、通信先を検査する仕組みがすでに用意されています。

  • wp_remote_get() は通信先を検査しません。管理者が設定した固定URLなど、信頼できるURL向けです。
  • wp_safe_remote_get()(post・head・request 版もあります)は、内部で reject_unsafe_urls を有効にし、wp_http_validate_url() でURLを検査します。

wp_http_validate_url() が拒否するのは、主に次のものです。

  • http と https 以外のスキーム(file:// や gopher:// など)
  • user:pass@ のような認証情報を含むURL
  • 解決先が 127.0.0.0/8・10.0.0.0/8・172.16.0.0/12・192.168.0.0/16・0.0.0.0/8 のIPv4アドレス
  • 80・443・8080 以外のポート(サイト自身のポートは例外)

一方で、次の点は標準の検査ではカバーされません。ここを知らずに「safe版を使ったから大丈夫」と考えるのが一番のつまずきどころです。

  • リンクローカル帯域(169.254.0.0/16)は拒否対象に含まれていないため、クラウドのメタデータへの通信は通ってしまいます。
  • 名前解決はIPv4で行われるため、IPv6の内部アドレスは検査されません。
  • 検査時と実際の通信時でDNSの応答を変える「DNSリバインディング」には対応できません。
  • サイト自身と同じホスト名への通信は検査なしで許可されます。

手元で確かめるには、WP-CLIで関数を直接呼び出します。

wp eval 'var_dump( wp_http_validate_url( "http://127.0.0.1/" ) );'
# bool(false)  … 拒否される

wp eval 'var_dump( wp_http_validate_url( "http://169.254.169.254/" ) );'
# string(23) "http://169.254.169.254/"  … 通ってしまう

このため、コード側で追加の確認を行い、最終的な防波堤としてサーバー側の通信制限を組み合わせます。

プラグイン・テーマのコードを直す

自作や改修可能なコードでURLを受け取って取得している場合は、権限の確認、URLの検査、安全な取得、応答の絞り込みの順で守りを固めます。まず、URLを検査する関数です。

/**
 * 外部取得を許可してよいURLかを判定する。
 */
function myplugin_is_allowed_url( $url ) {
	if ( ! wp_http_validate_url( $url ) ) {
		return false;
	}

	$parts = wp_parse_url( $url );
	if ( empty( $parts['scheme'] ) || 'https' !== $parts['scheme'] || empty( $parts['host'] ) ) {
		return false;
	}

	// 取得先を限定できる機能なら、ホストの許可リストで絞る
	$allowed_hosts = array( 'example.com', 'api.example.com' );
	$host          = strtolower( $parts['host'] );
	if ( ! in_array( $host, $allowed_hosts, true ) ) {
		return false;
	}

	// 解決したIPがプライベート・予約済み帯域(169.254.0.0/16 を含む)なら拒否
	$ips = gethostbynamel( $host );
	if ( false === $ips ) {
		return false;
	}
	foreach ( $ips as $ip ) {
		if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
			return false;
		}
	}

	return true;
}

リンクプレビューのように取得先を事前に決められない機能では、許可リストの部分を外し、IPの確認だけを残します。ただし、その場合は後述のサーバー側の制限が前提になります。

次に、この関数を使って取得する処理です。管理画面から Ajax で呼ぶ例です。

add_action( 'wp_ajax_myplugin_fetch_title', 'myplugin_fetch_title' );

function myplugin_fetch_title() {
	check_ajax_referer( 'myplugin_fetch_title' );

	if ( ! current_user_can( 'edit_posts' ) ) {
		wp_send_json_error( 'forbidden', 403 );
	}

	$url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : '';
	if ( '' === $url || ! myplugin_is_allowed_url( $url ) ) {
		wp_send_json_error( 'invalid url', 400 );
	}

	$response = wp_safe_remote_get(
		$url,
		array(
			'timeout'             => 5,
			'redirection'         => 0,
			'limit_response_size' => 512 * 1024, // 512KB まで
		)
	);

	if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
		wp_send_json_error( 'fetch failed', 502 );
	}

	$title = '';
	if ( preg_match( '/<title[^>]*>(.*?)<\/title>/is', wp_remote_retrieve_body( $response ), $m ) ) {
		$title = sanitize_text_field( $m[1] );
	}

	wp_send_json_success( array( 'title' => $title ) );
}

それぞれの設定には理由があります。redirection を 0 にするのは、許可したホストが内部アドレスへリダイレクトする手口を避けるためです。リダイレクトを追う必要がある場合は、転送先のURLを自分で取り出し、同じ検査を通してから取得し直します。timeout と limit_response_size は、巨大なファイルや応答しない宛先でPHPのプロセスが占有されるのを防ぎます。

最後の「必要な値だけ返す」も見落とされがちです。取得した本文やエラーメッセージをそのまま画面に返すと、攻撃者は内部のサービスの応答内容を読み取れます。返す情報を絞るだけで、被害を「通信できた」程度にとどめられます。

wp_ajax_nopriv_ で未ログインの利用者にも開放したり、REST APIのルートで permission_callback を __return_true にしたりすると、誰でもサーバーを踏み台にできる状態になります。URLを取得する処理は、本当に必要な権限を持つ人だけが呼べるようにしてください。

サイト全体で外部通信を制限する

コードを直せない市販プラグインもあるため、WordPress全体やサーバー側でも制限をかけます。設定を変える前に、wp-config.php とファイアウォール設定のバックアップを取っておきます。

wp-config.php で通信先を許可リストにする

WordPressには、HTTP APIによる外部通信を止め、指定したホストだけを許可する定数があります。

// wp-config.php(「編集が必要なのはここまでです」の行より上に書く)
define( 'WP_HTTP_BLOCK_EXTERNAL', true );
define( 'WP_ACCESSIBLE_HOSTS', 'api.wordpress.org,downloads.wordpress.org,*.example.com' );

この設定では、WP_ACCESSIBLE_HOSTS に書いたホスト以外への通信がWordPress側で止まります。*.example.com のようなワイルドカードも使えます。注意点は2つあります。1つは、localhost とサイト自身のホストへの通信は常に許可されることです。もう1つは、更新確認やライセンス認証、外部APIを使うプラグインの通信まで止まることです。導入前に後述の方法で通信先を記録し、必要なホストを洗い出してから有効にします。また、PHPの curl_exec() を直接使うコードはこの制限の対象外です。

サーバーのファイアウォールでメタデータへの通信を止める

クラウド上のサーバーでは、PHPを動かしているユーザーからメタデータのアドレスへの通信をOSのファイアウォールで拒否しておくと、コードの不備があっても認証情報の流出を防げます。

# PHP-FPM の実行ユーザーを確認(Debian/Ubuntu では www-data が多い)
ps -eo user,comm | grep php-fpm

# そのユーザーから 169.254.169.254 への通信を拒否する
sudo iptables -A OUTPUT -d 169.254.169.254 -m owner --uid-owner www-data -j REJECT

再起動後も残すには、ディストリビューションに合わせて iptables-persistent などで保存します。ただし、メディアをクラウドストレージに置くプラグインなどが、インスタンスに付けたロールの認証情報をメタデータから取得している場合は動かなくなります。その場合はロールではなく、権限を絞ったアクセスキーを使う構成に切り替えるか、AWSであればメタデータの取得にトークンが必要になるIMDSv2を必須に設定し、単純なGETリクエストだけでは情報を取れないようにします。

内部ネットワークのDBや管理画面も、Webサーバーから届く必要がない宛先は閉じておきます。DNSリバインディングやIPv6のように、コードの検査では防ぎにくい手口にもネットワーク側の制限は有効です。

使っていないピンバックを止める

ピンバックを使っていないなら、サーバーが他サイトへ通信するきっかけを1つ減らせます。「設定」→「ディスカッション」で、他のブログからの通知を受け付ける項目のチェックを外します。この設定は新しい投稿に適用されるもので、既存の投稿には投稿ごとの設定が残る点に注意してください。XML-RPC自体を使っていない場合は、Webサーバーの設定で xmlrpc.php へのアクセスを拒否する方法もあります。

外部通信を記録して確認する

対策が効いているか、またどのプラグインがどこへ通信しているかは、http_api_debug アクションで記録できます。ステージング環境で一定期間動かし、通信先の一覧を作ると、許可リストを作る材料にもなります。

// wp-content/mu-plugins/log-outgoing-http.php(調査が終わったら削除する)
add_action( 'http_api_debug', function ( $response, $context, $class, $parsed_args, $url ) {
	$host   = wp_parse_url( $url, PHP_URL_HOST );
	$status = is_wp_error( $response ) ? $response->get_error_code() : wp_remote_retrieve_response_code( $response );
	error_log( sprintf( '[outgoing-http] %s %s %s', $parsed_args['method'], $host, $status ) );
}, 10, 5 );

ログにはホスト名とステータスだけを残し、URLのクエリ文字列やリクエストヘッダーは書き出さないようにします。APIキーやトークンが含まれることがあるためです。WP_DEBUG_LOG を有効にしていれば、wp-content/debug.log に記録されます。

修正したコードについては、次の点を確認します。

  • http://127.0.0.1/・http://169.254.169.254/・file:///etc/passwd を渡すとエラーになる
  • 許可リストにないホストを渡すとエラーになる
  • ログインしていない状態や権限のないユーザーでは呼び出せない
  • 正当なURLでは期待どおりの値だけが返る

確認は自分が管理するステージング環境で行い、他人のサーバーに対して試さないでください。

まとめ

  • SSRFは、利用者が指定したURLをサーバーが取得する機能から起きます。まず wp_remote_get() などの呼び出し箇所を検索し、URLの出どころを確認します。
  • wp_safe_remote_get() はプライベートIPや危険なスキームを拒否しますが、169.254.0.0/16・IPv6・DNSリバインディングは防げません(2026年10月現在の WordPress の実装にもとづきます)。
  • コード側では、権限とnonceの確認、ホストの許可リスト、解決後のIPの確認、リダイレクト無効化、取得内容を絞った応答を組み合わせます。
  • WP_HTTP_BLOCK_EXTERNAL と WP_ACCESSIBLE_HOSTS で通信先を絞り、OSのファイアウォールでメタデータや内部ネットワークへの通信を止めると、コードの不備があっても被害を抑えられます。
  • http_api_debug で外部通信を記録し、許可リスト作りと対策後の確認に使います。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲