2026年9月2日

2026年9月2日

WordPressのREST APIにレート制限を追加するカスタム実装(mu-pluginで429を返す)

WordPress の REST API には、コアの機能としてのレート制限(一定時間あたりの回数制限)がありません。この記事では rest_pre_dispatch フィルターを使い、IPアドレスごとに回数を数えて上限を超えたら HTTP 429 と Retry-After ヘッダーを返す仕組みを mu-plugin として実装します。あわせて、どのルートを対象にすべきか、プロキシ配下でのIP判定、動作確認の方法、PHP側で制限することの限界まで説明します。

先に決めること:どこで制限するか

レート制限はアプリケーション(WordPress)より手前の層で行うほど効率的です。Nginx の limit_req やCDN・WAFのレート制限なら、PHPもデータベースも動く前にリクエストを止められます。WordPress 側で数える方法は、PHP と WordPress の読み込みが済んだ後に判定するため、大量アクセスそのものからサーバーを守る力は弱いと考えてください。

それでも WordPress 側で実装する価値があるのは、次のような場面です。

  • 共用サーバーなどでWebサーバーの設定を触れない
  • 「自作APIの特定ルートだけ」「未ログインのときだけ」のように、WordPress の情報を使って条件を分けたい
  • エラー応答を REST API の形式(JSON)でそろえたい

大量の攻撃的アクセスへの対策はサーバー・CDN側、細かい利用ルールはWordPress側、という役割分担がおすすめです。

実装の方針

今回のコードは次の考え方で作ります。

  • 対象は「自作API(例: /myplugin/v1/)」と「ユーザー一覧(/wp/v2/users)」のように、ルートを絞る。全ルートにかけるとブロックエディターやプラグインの通信まで止める恐れがあるためです。
  • 投稿の編集権限を持つログインユーザーは対象外にする。ブロックエディターは編集中に REST API を頻繁に呼び出します。
  • 数え方は「固定ウィンドウ方式」。60秒ごとの区切りの中で回数を数え、区切りが変われば0から数え直します。厳密さよりも実装の分かりやすさを優先した方式です。
  • 永続オブジェクトキャッシュ(Redis や Memcached のドロップイン)があれば wp_cache_incr() で加算し、無ければトランジェントで代用する。

mu-pluginのコード

作業前にサイトのバックアップを取り、ステージング環境で試してから本番に入れてください。wp-content/mu-plugins/ フォルダ(無ければ作成)に rest-rate-limit.php として保存します。mu-plugin は有効化の操作が不要で、管理画面から誤って停止されることもありません。テーマに書くとテーマ変更で消えてしまうため、子テーマの functions.php よりも mu-plugin の方が向いています。

<?php
/**
 * Plugin Name: REST API Rate Limit
 * Description: 指定したRESTルートにIP単位の回数制限をかける(固定ウィンドウ方式)。
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

const MY_RL_LIMIT  = 60; // 1ウィンドウあたりの上限回数
const MY_RL_WINDOW = 60; // ウィンドウの長さ(秒)

/** 制限の対象にするルートの先頭部分 */
function my_rl_target_prefixes() {
	return array( '/myplugin/v1/', '/wp/v2/users' );
}

/** 接続元IP。プロキシ配下ではWebサーバー側で REMOTE_ADDR を正しく設定しておく */
function my_rl_client_ip() {
	$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
	return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : 'unknown';
}

/** カウンターを1増やして、増やした後の値を返す */
function my_rl_hit( $key, $ttl ) {
	if ( wp_using_ext_object_cache() ) {
		wp_cache_add( $key, 0, 'my_rl', $ttl );
		return (int) wp_cache_incr( $key, 1, 'my_rl' );
	}
	$count = (int) get_transient( $key ) + 1;
	set_transient( $key, $count, $ttl );
	return $count;
}

/** rest_post_dispatch でヘッダーを付けるための一時保存 */
function my_rl_state( $set = null ) {
	static $state = null;
	if ( null !== $set ) {
		$state = $set;
	}
	return $state;
}

add_filter( 'rest_pre_dispatch', 'my_rl_check', 10, 3 );
function my_rl_check( $result, $server, $request ) {
	// 他の処理がすでに応答を決めている場合や、内部呼び出し(rest_do_request)は対象外
	if ( null !== $result || ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) {
		return $result;
	}

	$route     = $request->get_route();
	$is_target = false;
	foreach ( my_rl_target_prefixes() as $prefix ) {
		if ( 0 === strpos( $route, $prefix ) ) {
			$is_target = true;
			break;
		}
	}
	if ( ! $is_target || current_user_can( 'edit_posts' ) ) {
		return $result;
	}

	$now    = time();
	$bucket = (int) floor( $now / MY_RL_WINDOW );
	$reset  = ( $bucket + 1 ) * MY_RL_WINDOW;
	$key    = 'my_rl_' . md5( my_rl_client_ip() ) . '_' . $bucket;
	$count  = my_rl_hit( $key, MY_RL_WINDOW );

	if ( $count > MY_RL_LIMIT ) {
		$response = new WP_REST_Response(
			array(
				'code'    => 'rest_rate_limited',
				'message' => 'リクエストが多すぎます。時間をおいて再試行してください。',
				'data'    => array( 'status' => 429 ),
			),
			429
		);
		$response->header( 'Retry-After', (string) max( 1, $reset - $now ) );
		return $response;
	}

	my_rl_state(
		array(
			'limit'     => MY_RL_LIMIT,
			'remaining' => MY_RL_LIMIT - $count,
			'reset'     => $reset - $now,
		)
	);
	return $result;
}

add_filter( 'rest_post_dispatch', 'my_rl_add_headers', 10, 3 );
function my_rl_add_headers( $response, $server, $request ) {
	$state = my_rl_state();
	if ( $state && $response instanceof WP_HTTP_Response ) {
		$response->header( 'X-RateLimit-Limit', (string) $state['limit'] );
		$response->header( 'X-RateLimit-Remaining', (string) $state['remaining'] );
		$response->header( 'X-RateLimit-Reset', (string) $state['reset'] );
	}
	return $response;
}

コードの要点とつまずきやすい所

rest_pre_dispatch を使う理由

rest_pre_dispatch は、ルートに対応するコールバックが実行される前に呼ばれるフィルターで、null 以外を返すとその値がそのまま応答になります。ここで WP_REST_Response を返せば、ステータスコードやヘッダーを自由に指定できます。WP_Error に array( 'status' => 429 ) を付けて返す方法もありますが、その場合は Retry-After ヘッダーを付けにくいため、今回は応答オブジェクトを直接作っています。

このフィルターは rest_do_request() による内部呼び出しでも実行されます。テーマやプラグインがページ表示中に内部的にRESTを呼ぶ場合まで数えないよう、REST_REQUEST 定数で「HTTP経由のREST要求か」を確認しています。

ログイン状態の判定

REST API ではクッキー認証の際、nonce(X-WP-Nonce)が無いリクエストは未ログイン扱いになります。そのため、ブラウザーの JavaScript から nonce なしで呼んでいると、ログイン中の管理者でも制限対象になります。自作のフロントエンドから呼ぶ場合は wp_create_nonce( 'wp_rest' ) で作った値をヘッダーに付けてください。アプリケーションパスワードでの認証は、この判定の時点で反映されています。

IPアドレスの取り方

X-Forwarded-For などのヘッダーは、クライアントが自由に書き換えられます。何も考えずにこのヘッダーを信用すると、値を毎回変えるだけで制限をすり抜けられてしまいます。ロードバランサーやCDNの配下にある場合は、Nginx の real_ip モジュールや Apache の mod_remoteip で「信頼するプロキシから来たときだけ」実際の接続元を REMOTE_ADDR に反映させ、PHP側では REMOTE_ADDR だけを見る形が安全です。

逆にこの設定をしないままプロキシ配下で動かすと、全員が同じIP(プロキシのIP)として数えられ、正常な利用者までまとめて429になります。導入前に、自分のアクセスで REMOTE_ADDR に何が入っているかを確認してください。

カウンターの保存先

永続オブジェクトキャッシュが無い環境では、トランジェントは wp_options テーブルに保存されます。アクセスのたびに書き込みが発生し、さらに読み込みと書き込みの間に別のリクエストが割り込むと回数が少なめに数えられることがあります。小規模なAPIなら実用上問題になりにくいものの、アクセスが多いサイトでは Redis などのオブジェクトキャッシュを導入し、wp_cache_incr() の加算を使う構成にしてください。期限切れのトランジェントが溜まる点にも注意が必要です。

動作確認の方法

ステージング環境で、対象ルートに連続でリクエストを送ってステータスコードを確認します。

for i in $(seq 1 65); do
  curl -s -o /dev/null -w "%{http_code}\n" https://staging.example.com/wp-json/wp/v2/users
done

61回目以降が 429 になれば動作しています。ヘッダーは curl -i で確認できます。

curl -i https://staging.example.com/wp-json/wp/v2/users

X-RateLimit-Remaining が1ずつ減り、制限中は Retry-After に次のウィンドウまでの秒数が入ります。ページキャッシュやCDNがREST APIの応答をキャッシュしている場合は、リクエストがPHPまで届かず回数が増えないことがあるため、確認時はキャッシュの対象外にするか、クエリ文字列を変えて試してください。

運用時の注意

  • 上限値は、実際の利用状況(アクセスログで1分あたりのリクエスト数)を見てから決めます。最初はゆるめに設定し、ログを見ながら絞る方が事故を防げます。
  • X-RateLimit-* という名前のヘッダーは広く使われている慣習ですが、標準として決まったものではありません(2026年10月現在、IETFで標準化の議論が続いています)。API の利用者向けに意味を文書化しておくと親切です。
  • 誤って自分を締め出した場合でも、mu-plugin のファイルをFTPやSSHで削除または名前変更すれば即座に無効化できます。
  • ログイン試行(wp-login.php や xmlrpc.php)はREST APIではないため、このコードでは制限されません。別途対策が必要です。

まとめ

  • WordPress コアの REST API にはレート制限機能が無いため、rest_pre_dispatch でルートごとに回数を数えて429を返す形で実装できる。
  • 対象ルートを絞り、編集権限を持つログインユーザーは除外すると、ブロックエディターなどを止めずに済む。
  • IPは REMOTE_ADDR だけを使い、プロキシ配下ではWebサーバー側で実IPを反映させる。転送ヘッダーを直接信用しない。
  • カウンターは永続オブジェクトキャッシュの wp_cache_incr() が望ましく、無ければトランジェントで代用する。
  • 大量アクセス対策はサーバー・CDN側の制限と組み合わせ、mu-plugin で導入して、ステージングで確認してから本番に反映する。

お気軽にご相談ください

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