開発

Blog

開発

【WordPress】ユーザー名の漏れを防ぐ対策と、REST API の制限が /wp/v2/Users ですり抜けた話

WordPress は、何もしないと、外からユーザー名を調べられる道がいくつかあります。ユーザー名がわかると、攻撃する側はあとはパスワードを当てるだけになるので、総当たりの手間が減ってしまいます。この記事では、僕がどの WordPress の案件にも入れている共通のプラグイン(NARI Base)で、その道をどうふさいでいるかと、REST API の制限が大文字を混ぜた URL ですり抜けていた失敗をまとめます。

この記事でわかること
  • WordPress でユーザー名が外に漏れる道
  • 投稿者アーカイブ・REST API・サイトマップ・oEmbed・ログインエラーのふさぎ方
  • Yoast SEO の「投稿者アーカイブを無効」だけでは足りない理由
  • REST API のルートを文字列で比べるときの落とし穴

ユーザー名が漏れる道

WordPress のユーザーには、ログイン名とは別に「ユーザー名のスラッグ(user_nicename)」があります。初期値ではログイン名から作られるので、多くの場合ログイン名とほぼ同じです。このスラッグや、ユーザーがいるかどうかが、次のところからわかります。

道何がわかるか
/?author=1その ID の人に公開済みの記事があると、/author/スラッグ/ へ転送される(転送先の URL にスラッグが出る)
/author/スラッグ/投稿者アーカイブが表示されれば、そのスラッグの人がいるとわかる
/wp-json/wp/v2/usersREST API のユーザー一覧。ID・名前・スラッグが返る
XML サイトマップWordPress 標準や Yoast SEO のサイトマップに、投稿者のページの一覧が出る
oEmbed(埋め込み)の情報記事を埋め込むための情報に author_name・author_url が入る
ログインエラーの文言「ユーザー名が登録されていない」「パスワードが違う」が分かれていると、ユーザーがいるかどうかがわかる

それぞれのふさぎ方

ここからのコードは、NARI Base の security.php からそのまま切り出しています。

投稿者アーカイブと ?author=1 を 404 にする

// 投稿者アーカイブ(/author/xxx/ と ?author=1)を 404 にする
// ?author=1 → /author/ログイン名/ へのリダイレクト(template_redirect)でログイン名が漏れるため、リダイレクトより先に止める。
// Yoast SEO も「投稿者アーカイブを無効」の設定で、wp アクション(優先度 10)でトップへ転送するので、それより先(wp の優先度 0)で止める
// (先に止めないと、ユーザーがいる ID だけ転送・いない ID は 404 になり、ユーザーの有無が分かってしまう)
add_action( 'wp', function () {
	if ( ! is_author() && ! array_key_exists( 'author', $_GET ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		return;
	}

	global $wp_query;
	$wp_query->set_404();
	status_header( 404 );
	nocache_headers();
}, 0 );

実際のコードでは、?author= が付いているかを isset() で判定しています。ただ、この記事を置いているサーバーの WAF(不正なアクセスを止める仕組み)が、その書き方を攻撃と見なして記事の保存を止めてしまうので、記事では同じ意味の array_key_exists() にしています。

?author=1 から /author/スラッグ/ への転送は、WordPress 本体が template_redirect で行っています(redirect_canonical())。転送されてしまうとスラッグが漏れるので、それより前の wp アクションで 404 にしています。

Yoast SEO の「投稿者アーカイブを無効」だけでは足りない

Yoast SEO にも投稿者アーカイブを無効にする設定があり、オンにすると投稿者アーカイブをトップページへ転送します。ただ、この転送も wp アクション(優先度 10)で動くので、Yoast に任せると、ユーザーがいる ID だけトップへ転送され、いない ID は 404 になる、という差が出ていました。

これだと「転送されたらユーザーがいる」とわかってしまいます。そこで、Yoast より先(wp の優先度 0)で、いる ID もいない ID も同じ 404 にしています。

REST API のユーザー一覧は、ログインしていないときは 401 にする

// REST API のユーザー一覧(/wp-json/wp/v2/users)は、ログインしていないと見られないようにする
// ブロックエディターの投稿者の選択で使うため、ログイン中は許可する
// WordPress は REST のルートを大文字・小文字を区別せずに照合する(/wp/v2/Users でも一覧が返る)ので、小文字にしてから比べる
add_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) {
	if ( str_starts_with( strtolower( $request->get_route() ), '/wp/v2/users' ) && ! is_user_logged_in() ) {
		return new \WP_Error( 'rest_forbidden', 'この操作は許可されていません。', array( 'status' => 401 ) );
	}
	return $result;
}, 10, 3 );

ユーザー一覧のエンドポイントごと消してしまう方法もありますが、ブロックエディターは投稿者の選択でこの一覧を使います。なので、消さずに「ログインしていないときだけ断る」形にしています。rest_pre_dispatch は、リクエストがそれぞれの処理に渡る前に呼ばれるフィルターで、ここで WP_Error を返すと、その内容がそのままレスポンスになります。

「大文字・小文字を区別せずに照合する」のコメントは、下で書く失敗のあとに足したものです。

サイトマップと oEmbed から投稿者を外す

// WordPress 標準のサイトマップからユーザーを除く
add_filter( 'wp_sitemaps_add_provider', function ( $provider, $name ) {
	return 'users' === $name ? false : $provider;
}, 10, 2 );

// Yoast SEO のサイトマップから投稿者を除く
add_filter( 'wpseo_sitemap_exclude_author', '__return_empty_array' );

// oEmbed(他サイトへの埋め込み)の情報から投稿者を除く
add_filter( 'oembed_response_data', function ( $data ) {
	unset( $data['author_name'], $data['author_url'] );
	return $data;
} );

ログインエラーで、ユーザー名とパスワードのどちらが違うかを出さない

// ログインエラーで「ユーザー名が存在しない」「パスワードが違う」を区別しない
add_filter( 'login_errors', fn() => '<strong>エラー:</strong> ユーザー名またはパスワードが違います。' );

WordPress の初期のエラーは、ユーザー名が登録されていないのか、パスワードが違うのかを分けて表示します。親切ではあるのですが、ユーザー名があるかどうかを確かめる道にもなるので、同じ文言にそろえています。

失敗:大文字を混ぜた URL で、REST API の制限をすり抜けていた

はじめは、ルートをそのまま比べていました。

if ( str_starts_with( $request->get_route(), '/wp/v2/users' ) && ! is_user_logged_in() ) {

これだと、/wp-json/wp/v2/users は 401 になりますが、/wp-json/wp/v2/Users のように大文字を混ぜると、ユーザー一覧が返ってきていました。

原因は、WordPress が REST API のルートを大文字・小文字を区別せずに照合していることです。本体の class-wp-rest-server.php では、ルートの正規表現に i(大文字・小文字を区別しない)を付けて照合しています(WordPress 7.1.4 で確認。抜粋)。

foreach ( $routes as $route => $handlers ) {
	$match = preg_match( '@^' . $route . '$@i', $path, $matches );

つまり、WordPress は /wp/v2/Users を /wp/v2/users と同じルートとして処理するのに、僕のチェックは大文字・小文字を区別して比べていたので、見逃していたわけです。直し方は、ルートを小文字にしてから比べるだけです。

if ( str_starts_with( strtolower( $request->get_route() ), '/wp/v2/users' ) && ! is_user_logged_in() ) {
URL やルートを文字列で比べるときは

自分のチェックを、相手(ここでは WordPress)の照合のしかたに合わせます。相手が大文字・小文字を区別しないなら、こちらも小文字にそろえてから比べる。区別のしかたがずれていると、そのずれがそのまま抜け道になります。

ふさげているかの確かめ方

ログインしていない状態で、次の URL のステータスコードを見ます。ターミナルなら curl で確かめられます(example.com は自分のサイトに置きかえます)。

curl -s -o /dev/null -w '%{http_code}\n' 'https://example.com/wp-json/wp/v2/Users'

このサイトのローカル環境で確かめた結果です。?rest_route= の形で REST API を呼んでも、同じフィルターを通るので 401 になります。

URL結果
/wp-json/wp/v2/users401
/wp-json/wp/v2/Users401
/wp-json/wp/v2/USERS/1401
/?rest_route=/wp/v2/Users401
/?author=1404
/?author=999404(いない ID も同じ)
/author/admin/404
ユーザー名を隠すのは、手間を増やす対策

ユーザー名がわからなくても、パスワードが弱ければ破られます。強いパスワードや、ログインの試行回数の制限などと組み合わせて使う前提の対策です。

おわりに

ユーザー名の漏れは、1つふさいでも別の道が残っていることが多く、今回のように、ふさいだつもりの道に抜け道があったりもします。

今はこの対策を共通のプラグインにまとめて、どの案件にも同じものが入るようにしています。次に抜け道が見つかっても、直すのは1か所で済みます。

お仕事のご相談

WordPress のセキュリティ対策のような制作・改修のご相談も受け付けています。お気軽にどうぞ。

//サイト内を検索

制作実績・ブログ・お知らせから探します。キーワードはスペースで区切って複数入れられます。