Server & WordPress

WP REST APIのセキュリティ対策:ボットが使う裏口を塞ぐ

· 6 min read

2週間かけてコンタクトフォームに多層的なスパム対策を構築したとします。Honeypotフィールド、タイミング解析、JavaScriptチャレンジ、場合によってはCAPTCHAまで導入。送信数はほぼゼロに。これで安心、と思うでしょう。

ところがサーバーログを確認すると、/wp-json/contact-form-7/v1/contact-forms/123/feedback に直接送られた6,000件のPOSTリクエストが記録されています。ページの読み込みなし。JavaScriptの実行なし。Honeypotの発動なし。ボットはフロントエンドを完全にスキップして、APIと直接通信していたのです。

REST APIが有効なすべてのWordPressサイト――つまりバージョン4.7以降のすべてのWordPressサイト――には、公開されたAPIが存在します。 これらのエンドポイントの多くは、デフォルトで認証なしのリクエストを受け付けます。そして、ほとんどのサイト管理者はその存在すら知りません。

これは多くの管理者が見落としている公開エンドポイント――フロントエンドの防御を迂回可能な経路――です。フロントエンドは三重のデッドボルトが付いた正面玄関。REST APIは鍵がマットの下に置かれた勝手口のようなものです。


問題:フロントエンドの防御はAPIを守らない

WordPressは2016年12月にREST APIをコア機能として導入しました。開発者にとっては大きな出来事でした。WordPressのデータをHTTP経由で読み書きするための、標準化されたJSONベースのインターフェースです。ヘッドレスフロントエンド、モバイルアプリ、サードパーティ連携が、HTMLのスクレイピングやadmin-ajax.phpの濫用なしにWordPressと通信できるようになりました。

しかし、REST APIは相互運用性のために設計されたものであり、ロックダウンのために設計されたものではありません。REST API自体が危険なのではなく、認証やバリデーションの設計によってリスクが生じます。デフォルトでは、多くのエンドポイントが認証なしで公開されています。 誰でも――あるいは何でも――/wp-json/wp/v2/users にGETリクエストを送信すれば、ユーザー名の一覧を取得できます。プラグインが適切なパーミッションチェックを実装していなければ、カスタムエンドポイントへのPOSTも可能です。

ボットはすぐにこれに気づきました。

ボットがあなたのサイトを見るとき、何が見えるか

多くのボットはコンタクトページを読み込みません。CSSをレンダリングせず、JavaScriptも実行しないケースがほとんどです。単一のHTTPリクエストを送信するだけです:

GET /wp-json/ HTTP/1.1
Host: example.com

WordPressは、登録されたすべてのRESTルートの完全なインデックス――メソッド、エンドポイントURL、受け付けるパラメータ――を返します。APIサーフェス全体の無料マップを、聞いてきた相手に渡しているようなものです。

ここから、ボットはどのエンドポイントがPOSTデータを受け付けるか、どのフィールドが期待されているか、リクエストの形式はどうすべきかを正確に把握します。推測不要。ブラウザ不要。

# WordPressサイトに登録されたすべてのルートを列挙する
curl -s https://example.com/wp-json/ | jq '.routes | keys[]'

これにより、サイトが公開しているすべてのRESTルートが返されます。プラグインがいくつか入った一般的なWordPressでは、50〜200のエンドポイントがリストされます。それぞれが潜在的な攻撃対象です。

Contact Form 7の例

Contact Form 7はフォーム送信用に独自のRESTエンドポイントを登録しています:

POST /wp-json/contact-form-7/v1/contact-forms/{id}/feedback

これはユーザーが送信ボタンをクリックしたときにフロントエンドのJavaScriptが呼び出すエンドポイントです。同時に、ボットがフロントエンドに施されたすべての対策を迂回したいときに呼び出すエンドポイントでもあります。リクエストはシンプルです:

curl -X POST https://example.com/wp-json/contact-form-7/v1/contact-forms/123/feedback 
  -F "your-name=Bot McBotface" 
  -F "your-email=spam@example.com" 
  -F "your-message=Buy cheap backlinks at http://spam-domain.com" 
  -F "_wpcf7=123" 
  -F "_wpcf7_version=6.0" 
  -F "_wpcf7_unit_tag=wpcf7-f123-o1"

ページの読み込みなし。JavaScriptなし。Honeypotフィールドのレンダリングなし。CAPTCHAの配信なし。ボットは正しいフィールド名を使った生のHTTP POSTを送信し、Contact Form 7は通常の送信と同じように処理します。

追加したすべてのクライアントサイド防御――Honeypotフィールド、JavaScriptタイマー、マウスの動きのトラッカー――は、この経路に対しては機能しません。ボットはそもそもページを読み込んでいないケースが多く、これらの防御は、ボットがリクエストしなかったHTML上にのみ存在するのです。


技術詳解:REST API認証モデルの仕組み

問題を修正するには、WordPressがREST API経由で誰に何を許可するかをどう決定しているかを理解する必要があります。

Permission Callbackシステム

WordPressのすべてのRESTルートにはpermission callbackを定義できます。これはエンドポイントのロジックの前に実行され、trueまたはfalseを返す関数です。falseを返すと、リクエストは403ステータスで拒否されます。

適切にセキュリティが確保されたカスタムエンドポイントは以下のようになります:

add_action( 'rest_api_init', function () {
    register_rest_route( 'myplugin/v1', '/data', array(
        'methods'             => 'GET',
        'callback'            => 'myplugin_get_data',
        'permission_callback' => function ( WP_REST_Request $request ) {
            return current_user_can( 'edit_posts' );
        },
    ) );
} );

ここでのpermission_callbackは、リクエスト元がedit_posts権限を持つログイン済みユーザーかどうかをチェックします。匿名リクエストは失敗します。購読者レベルのアカウントも失敗します。エディター以上のみが通過できます。

問題は: 多くのプラグインがpermission callbackを__return_true(全員許可)に設定しているか、完全に省略していることです。WordPress 5.5からpermission callbackのないルートに対して_doing_it_wrongの通知がログに記録されるようになりましたが、リクエスト自体はブロックされません。エンドポイントは動作し続けます。

WordPress Nonce:あなたが思っているものとは違う

「nonce」という言葉は「一度だけ使われる数」を意味しますが、WordPress nonceは真のnonceではありません。 特定のユーザーセッションとアクションに紐づけられた、時間制限付きのHMACトークンです。WordPress nonceの有効期限は24時間(12時間のティックウィンドウ2回分)で、その期間内は再利用可能です。

REST APIリクエストでは、WordPressはX-WP-Nonceヘッダーまたは_wpnonceパラメータを使用します:

// フロントエンドJavaScriptで認証済みRESTリクエストを送信
fetch( '/wp-json/wp/v2/posts', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-WP-Nonce': wpApiSettings.nonce  // wp_localize_script()で生成
    },
    body: JSON.stringify({ title: 'My Post', status: 'draft' })
} );

nonceはリクエストをログイン済みのユーザーセッションに紐づけます。リクエストに有効なnonceが含まれていない場合、WordPressはそれを非認証(匿名)リクエストとして扱います。 リクエストを拒否するのではなく、現在のユーザーを0に設定するだけです。

ここが多くの人を混乱させるポイントです。nonceがなくても403にはなりません。ログインしていないかのようにリクエストが処理されるだけです。それが問題になるかどうかは、エンドポイントのpermission callbackが認証をチェックしているかどうかに完全に依存します。

利用可能な認証方法

WordPressはREST APIに対して、ユースケースに応じた複数の認証方法をサポートしています:

方法 メカニズム 最適な用途
Cookie + Nonce セッションCookie + X-WP-Nonceヘッダー 同一ドメインのフロントエンドJS
Application Passwords Base64エンコードされたusername:passwordAuthorizationヘッダーに 外部アプリ、モバイルクライアント
OAuth 2.0(プラグイン経由) AuthorizationヘッダーにBearerトークン サードパーティ連携
JWT(プラグイン経由) AuthorizationヘッダーにJSON Web Token ヘッドレス/デカップルドフロントエンド

フォーム送信エンドポイントをボットから保護するには、Cookie + Nonce方式が最も関連性が高いですが、ログイン済みユーザーにしか機能しません。そして、コンタクトフォームの訪問者はほぼ確実にログインしていません。

これが根本的な矛盾です:匿名の訪問者からの送信を受け付けるエンドポイントが必要であると同時に、その送信が正当であることを検証する必要もあるのです。


解決策:REST APIをレイヤーごとに保護する

単一の修正方法はありません。wp rest api securityのセキュリティ確保には、多層的なアプローチが必要です――制限できるものは制限し、制限できないものは検証し、すべてを監視します。

レイヤー1:非認証ユーザーに対してルートインデックスを無効化する

ルートインデックス(/wp-json/)はボットにとって無料の偵察ツールです。認証済みの機能を壊すことなく、匿名の訪問者から隠すことができます:

/**
 * 非認証リクエストに対してREST APIルートインデックスを削除する。
 * 認証済みユーザー(有効なnonceを持つ)は引き続き完全なインデックスを見ることができる。
 */
add_filter( 'rest_authentication_errors', function ( $result ) {
    // 以前の認証チェックがすでに失敗している場合は、そのまま通す。
    if ( is_wp_error( $result ) ) {
        return $result;
    }

    // ログイン済みユーザーにはすべてのエンドポイントへのアクセスを許可する。
    if ( is_user_logged_in() ) {
        return $result;
    }

    // REST APIインデックスへの非認証アクセスをブロックする。
    $rest_route = $GLOBALS['wp']->query_vars['rest_route'] ?? '';

    if ( empty( $rest_route ) || $rest_route === '/' ) {
        return new WP_Error(
            'rest_forbidden',
            __( 'REST API index is not available.' ),
            array( 'status' => 403 )
        );
    }

    return $result;
} );

これにより、ボットがエンドポイント構造を発見するのを防ぎます。既知のエンドポイントへのアクセスはブロックしません――CF7のURLをすでに知っているボットは依然としてアクセスできます――が、無料の列挙ステップを排除します。

レイヤー2:Usersエンドポイントを制限する

/wp-json/wp/v2/users エンドポイントは、最も悪用されるディスカバリー経路の一つです。ブルートフォースログイン攻撃に使用できるユーザー名が漏洩します。保護しましょう:

/**
 * usersエンドポイントを認証済みリクエストのみに制限する。
 */
add_filter( 'rest_endpoints', function ( $endpoints ) {
    if ( ! is_user_logged_in() ) {
        // 匿名ユーザーに対して /wp/v2/users ルートを完全に削除する。
        unset( $endpoints['/wp/v2/users'] );
        unset( $endpoints['/wp/v2/users/(?P<id>[\\d]+)'] );
    }
    return $endpoints;
} );

一部のプラグインはusersエンドポイントに依存しています。本番環境にデプロイする前に、ステージング環境でこの変更をテストしてください。なお、rest_endpointsフィルタはルート定義を配列から除外するものであり、直接URLへのアクセスが環境によっては通過する可能性があります。より確実な保護には、rest_authentication_errorsrest_pre_dispatchフィルタとの併用を推奨します。

レイヤー3:プラグインエンドポイントにカスタムPermission Callbackを追加する

カスタムRESTエンドポイントを作成している場合――または既存のプラグインのエンドポイントを強化する必要がある場合――rest_pre_dispatchにフックして追加の検証を適用できます:

/**
 * 特定のRESTルートに対して有効なリファラーとカスタムトークンを要求する。
 * 生のcURLリクエストでは満たせないサーバーサイドの検証レイヤーを追加する。
 */
add_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) {
    $route = $request->get_route();

    // CF7 feedbackエンドポイントにのみ適用する。
    if ( strpos( $route, '/contact-form-7/' ) === false ) {
        return $result;
    }

    // リクエストが自サイトドメインから送信されたことを確認する。
    $referer = $request->get_header( 'referer' );
    if ( empty( $referer ) || parse_url( $referer, PHP_URL_HOST ) !== parse_url( home_url(), PHP_URL_HOST ) ) {
        return new WP_Error(
            'rest_forbidden',
            'Direct API access is not permitted.',
            array( 'status' => 403 )
        );
    }

    // カスタムのスパム対策トークン(フロントエンドJSで設定)を検証する。
    $token = $request->get_param( '_antispam_token' );
    if ( empty( $token ) || ! my_verify_antispam_token( $token ) ) {
        return new WP_Error(
            'rest_forbidden',
            'Invalid submission token.',
            array( 'status' => 403 )
        );
    }

    return $result;
}, 10, 3 );

重要な注意点: Refererヘッダーは偽装可能です。洗練されていないボットはブロックできますが、唯一のチェックにすべきではありません。カスタムトークン――サーバーサイドで生成され、フォームのHTMLに埋め込まれ、送信時に検証される――が、ここでの本当の防御レイヤーです。

レイヤー4:ステートレスなスパム対策トークンを実装する

ページを読み込まなければボットが偽造できないサーバーサイドトークンの完全な実装を示します:

/**
 * フォーム検証用のステートレスHMACトークンを生成する。
 * トークンにはフォームIDとタイムスタンプが含まれ、サーバーシークレットで署名される。
 */
function my_generate_form_token( $form_id ) {
    $timestamp = time();
    $payload   = $form_id . '|' . $timestamp;
    $signature = hash_hmac( 'sha256', $payload, wp_salt( 'auth' ) );

    return base64_encode( $payload . '|' . $signature );
}

/**
 * サーバーサイドでトークンを検証する。
 * 1時間以上経過したトークンや、署名が無効なトークンを拒否する。
 */
function my_verify_antispam_token( $token, $form_id = null ) {
    $decoded = base64_decode( $token, true );
    if ( $decoded === false ) {
        return false;
    }

    $parts = explode( '|', $decoded );
    if ( count( $parts ) !== 3 ) {
        return false;
    }

    list( $token_form_id, $timestamp, $signature ) = $parts;

    // フォームIDの一致を検証する(指定されている場合)。
    if ( $form_id !== null && $token_form_id !== (string) $form_id ) {
        return false;
    }

    // 3600秒(1時間)以上経過したトークンを拒否する。
    if ( abs( time() - (int) $timestamp ) > 3600 ) {
        return false;
    }

    // 署名を再計算して比較する。
    $expected_payload   = $token_form_id . '|' . $timestamp;
    $expected_signature = hash_hmac( 'sha256', $expected_payload, wp_salt( 'auth' ) );

    return hash_equals( $expected_signature, $signature );
}

このトークンはページの読み込み時に生成され、フォームに埋め込まれます。具体的には、<input type="hidden" name="_antispam_token" value="..."> としてフォームのHTMLに出力し、送信時にPOSTデータとして返送させます。ボットがページを読み込まずにAPIに直接送信した場合、トークンがありません。ボットが古いトークンを再利用した場合、タイムスタンプチェックが拒否します。ボットがトークンを偽造しようとした場合、HMACチェックが検出します。

主な利点: このアプローチはステートレスです。データベースへの書き込みなし、セッションストレージなし、wp_optionsを圧迫する有効期限付きのtransientなし。サーバーがトークンを生成し、ブラウザがそれを返送し、サーバーが既に持っている秘密鍵だけでそれを検証します。

レイヤー5:Webサーバーレベルでレート制限を行う

PHPでのアプリケーションレベルのレート制限はコストが高い――実行される時点ではすでにWordPressがロードされています。レート制限をNginxまたはApacheにプッシュすれば、拒否されるリクエストあたりのコストは無視できるレベルになります:

# REST APIのすべてのPOSTリクエストをIPあたり毎分10件に制限する。
limit_req_zone $binary_remote_addr zone=restapi:10m rate=10r/m;

location ~* ^/wp-json/ {
    # POST/PUT/PATCH/DELETEにのみレート制限を適用する。
    limit_except GET HEAD OPTIONS {
        limit_req zone=restapi burst=5 nodelay;
    }

    # 通常どおりPHP-FPMに渡す。
    try_files $uri $uri/ /index.php?$args;
}

Apacheの場合、mod_ratelimitまたは.htaccessルールとmod_rewriteを使用した同等の設定:

# 有効なRefererなしのCF7エンドポイントへの直接POSTをブロックする。
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REQUEST_METHOD} POST
    RewriteCond %{REQUEST_URI} ^/wp-json/contact-form-7/
    RewriteCond %{HTTP_REFERER} !^https://example.com [NC]
    RewriteRule .* - [F,L]
</IfModule>

繰り返しますが、Refererチェックだけでは洗練されたボットに対して不十分です。しかし、コストゼロで、PHPが起動する前に実行され、最も手抜きな自動化トラフィックの多くを削減できるケースがあります。

レイヤー6:使用していないRESTエンドポイントを無効化する

REST APIはWordPressのコア機能であり、完全な無効化は推奨されません。ただし、ヘッドレスフロントエンドを使用していないなら、ほとんどのRESTエンドポイントを公開しておく必要はないでしょう。使用していないものを選択的に無効化します:

/**
 * このサイトが必要としないREST APIエンドポイントを削除する。
 * コア機能とアクティブなプラグインに必要なルートのみを残す。
 */
add_filter( 'rest_endpoints', function ( $endpoints ) {
    // REST APIを外部で使用しないサイトで削除するルート。
    $routes_to_remove = array(
        '/wp/v2/users',
        '/wp/v2/users/(?P<id>[\\d]+)',
        '/wp/v2/comments',
        '/wp/v2/comments/(?P<id>[\\d]+)',
        '/wp/v2/search',
        '/wp/v2/block-renderer',
    );

    foreach ( $routes_to_remove as $route ) {
        unset( $endpoints[ $route ] );
    }

    return $endpoints;
} );

デプロイ前にテストしてください。 ブロックエディター(Gutenberg)はRESTエンドポイントを頻繁に使用します。エディターが依存するルートを無効化すると、管理画面が壊れます。必ずステージング環境でテストし、投稿の編集、メディアのアップロード、プラグイン設定が正常に動作することを確認してください。


すべてをまとめる:完全なセキュリティ設定

主要な保護を組み合わせた単一のmu-pluginファイルを示します。wp-content/mu-plugins/にドロップすれば、通常のプラグインの前にすべてのリクエストで自動的に読み込まれます:

<?php
/**
 * Plugin Name: REST API Security Hardening
 * Description: Restricts REST API access for unauthenticated users.
 * Version: 1.0.0
 */

// 直接アクセスを防止する。
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * 1. 匿名ユーザーに対してREST APIインデックスを隠す。
 */
add_filter( 'rest_authentication_errors', function ( $result ) {
    if ( is_wp_error( $result ) ) {
        return $result;
    }
    if ( is_user_logged_in() ) {
        return $result;
    }

    $route = $GLOBALS['wp']->query_vars['rest_route'] ?? '';
    if ( empty( $route ) || $route === '/' ) {
        return new WP_Error( 'rest_no_index', 'Not available.', array( 'status' => 403 ) );
    }

    return $result;
} );

/**
 * 2. 匿名ユーザーに対してセンシティブなエンドポイントを削除する。
 */
add_filter( 'rest_endpoints', function ( $endpoints ) {
    if ( is_user_logged_in() ) {
        return $endpoints;
    }

    $restricted = array(
        '/wp/v2/users',
        '/wp/v2/users/(?P<id>[\\d]+)',
        '/wp/v2/users/me',
    );

    foreach ( $restricted as $route ) {
        unset( $endpoints[ $route ] );
    }

    return $endpoints;
} );

/**
 * 3. Contact Form 7のREST送信にReferer検証を適用する。
 */
add_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) {
    if ( $request->get_method() !== 'POST' ) {
        return $result;
    }

    $route = $request->get_route();
    if ( strpos( $route, '/contact-form-7/' ) === false ) {
        return $result;
    }

    $referer = $request->get_header( 'referer' );
    $home_host = parse_url( home_url(), PHP_URL_HOST );

    if ( empty( $referer ) || parse_url( $referer, PHP_URL_HOST ) !== $home_host ) {
        return new WP_Error(
            'rest_forbidden',
            'Direct API submissions are not allowed.',
            array( 'status' => 403 )
        );
    }

    return $result;
}, 10, 3 );

これは、おおよそ60行のコードで最もインパクトの大きい3つの堅牢化ステップをカバーしています。本番環境では、レイヤー4のHMACトークンシステムとレイヤー5のWebサーバーレベルのレート制限を追加するとよいでしょう。


この記事でカバーしていないこと、そしてカバーするもの

この記事の手法は、REST APIの攻撃対象領域に特化しています。ボットがフロントエンドの防御を迂回してエンドポイントに直接アクセスするのを防ぎます。しかし、フォームスパムの全範囲をカバーするものではありません:

  • ページを読み込むボット(PuppeteerやPlaywrightを実行するヘッドレスブラウザ)は、Refererチェックを通過し、フォームをレンダリングしてスパム対策トークンを取得する可能性があります。
  • AIエージェントボットはフォーム構造を読み取り、文脈に沿ったメッセージを生成し、失敗した送信に適応できます。
  • 分散型ボットネットは数千のIPアドレスをローテーションし、IPベースのレート制限を無効化します。

REST APIの堅牢化は一つのレイヤーです。低労力・大量の直接POSTボットをブロックします。しかし、送信方法に関係なく機能する行動分析、タイミング検証、サーバーサイドのチャレンジメカニズムと組み合わせる必要があります。

Contact Form 7を使用しているWordPressサイトには、Samurai Honeypot for Forms がこの目的のために設計されています。多態的Honeypot、Proof of Workチャレンジ、ステートレスHMACトークンを使用してアプリケーション層で送信を検証し、この記事で取り上げた直接APIボットと、それを突破するヘッドレスブラウザボットの両方を捕捉します。設定不要、外部依存なし、ユーザーに負荷をかけません。


主なポイント

  • WordPress REST APIは、デフォルトでサイトのエンドポイントを誰にでも公開しています。 ボットは/wp-json/のルートインデックスを偵察ツールとして利用し、攻撃対象を発見します。
  • ボットがAPIに直接POSTする場合、フロントエンドの防御だけでは不十分です。 Honeypot、JavaScriptチャレンジ、CAPTCHAは、ボットがページを読み込んだ場合にのみ機能します。多くのボットはページを読み込みません。
  • WordPress nonceはセッションに紐づくものであり、真のnonceではありません。 ログイン済みユーザーを認証しますが、エンドポイントのpermission callbackが認証を強制しない限り、匿名のAPIリクエストに対しては何もしません。
  • ステートレスHMACトークンは、匿名送信を受け付ける必要があるエンドポイントを保護するための最も強力な軽量防御です。 生成コストが低く、偽造が不可能で、データベースの状態を必要としません。
  • 防御を多層化してください。 ルートインデックスを無効化する。使用しないエンドポイントを削除する。Refererを検証する。サーバー生成トークンを要求する。Webサーバーでレート制限する。どれか一つの対策だけでは十分ではありませんが、組み合わせることで自動化された攻撃のコストが十分に高くなり、ボットは別のターゲットに移ります。

REST APIは、WordPressをモダンな開発の実用的なプラットフォームにした強力な機能です。同時に、ほとんどのサイトが完全に無防備のまま放置している攻撃対象でもあります。ボットに先を越される前に対策しましょう。

すべてのコラム