2026年7月27日
2026年7月27日
CSRF対策のヘッダー設計手順:SameSite・Fetch Metadata・CORSの組み合わせ方
CSRF(クロスサイトリクエストフォージェリ)対策の中心は、WordPress では今もフォームや Ajax に付ける nonce と権限チェックです。HTTP ヘッダーはその「外側の防壁」で、Cookie の SameSite 属性、Origin ヘッダーの確認、Fetch Metadata(Sec-Fetch-Site)による判定、そして CORS ヘッダーを誤って広げすぎないことの4点を組み合わせて設計します。この記事では、それぞれのヘッダーが何を防ぎ何を防がないのかを整理し、WordPress で安全に導入する順番を説明します。
先に整理:CORS は CSRF 対策ではない
いちばん多い誤解は「CORS を厳しくすれば CSRF を防げる」というものです。CORS(Access-Control-Allow-Origin など)は、別オリジンのスクリプトがレスポンスを「読む」ことを許すかどうかを決める仕組みで、リクエストの「送信」そのものは止めません。攻撃者のページに置かれたフォームが利用者のブラウザから POST を送ると、Cookie が付いた状態でサーバーに届き、処理はサーバー側で実行されてしまいます。CSRF の被害はこの「実行」なので、CORS だけでは防げません。
一方で、CORS を広げすぎると別の問題が起きます。任意の Origin をそのまま反射し、さらに Access-Control-Allow-Credentials: true を返すと、ログイン中の利用者の画面にしか出ない情報を他サイトのスクリプトが読めるようになります。つまり CORS は「CSRF を防ぐ道具」ではなく「漏えいを広げないために絞る設定」と位置づけるのが正確です。
各ヘッダーの役割
設計に入る前に、使うヘッダーを役割で分けておきます。
- SameSite 属性(Set-Cookie):別サイトから始まったリクエストに Cookie を付けるかどうかをブラウザが判断します。
Laxなら他サイトからの POST には Cookie が付かず、トップレベルの GET 遷移には付きます。Strictはより厳しく、外部リンクから来たときもログイン状態が引き継がれません。 - Origin ヘッダー:POST などでブラウザが送る、リクエスト元のオリジンです。自サイト以外の値であれば拒否する、という判定に使えます。古いブラウザや一部の状況では送られないため、単独では使いません。
- Sec-Fetch-Site(Fetch Metadata):
same-origin・same-site・cross-site・noneのいずれかで、リクエストがどこから始まったかを示します。対応ブラウザでは Origin より判定しやすく、「状態を変えるリクエストが cross-site なら拒否」という方針を立てやすくなります。 - CORS ヘッダー:前述のとおり、許可するオリジンを明示的に絞るために使います。
どれもブラウザの実装に依存するため、ブラウザが古い・ヘッダーを送らない場合に備えて、サーバー側の nonce 検証を残すことが前提です。
WordPress の現状を確認する
WordPress コアのログイン Cookie に SameSite 属性がどう付くかは、バージョンや環境(PHP の設定、プラグイン、リバースプロキシ)で変わる可能性があります。まずは実際のレスポンスを見て確認します。ブラウザの開発者ツールでログイン直後の Set-Cookie を見るか、次のように確認できます。
curl -sI https://example.com/wp-login.php | grep -i -E 'set-cookie|strict-transport|x-frame'
SameSite 属性が付いていない Cookie は、ブラウザごとの既定の扱いに任されます。Chrome 系は属性なしの Cookie を Lax として扱う方針を採っていますが、他のブラウザでは扱いが異なることがあるため(2026年10月現在)、「ブラウザが守ってくれる」前提で設計しないほうが安全です。
REST API については、WordPress は Cookie 認証のリクエストに X-WP-Nonce ヘッダー(wp_rest アクションの nonce)を要求します。nonce が無い Cookie 付きリクエストは未ログイン扱いになるため、カスタムヘッダーを要求する設計がすでに入っていることになります。独自のエンドポイントを作るときは permission_callback で権限を確認し、この仕組みを崩さないようにします。
手順1:nonce と権限チェックを先に固める
ヘッダー対策より先に、状態を変える処理すべてに nonce と権限チェックがあるかを確認します。管理画面のフォームなら wp_nonce_field() と check_admin_referer()、Ajax なら check_ajax_referer()、加えて current_user_can() で操作権限を確かめます。
add_action( 'wp_ajax_myplugin_save', function () {
check_ajax_referer( 'myplugin_save', 'nonce' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
}
$title = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '';
// 保存処理 …
wp_send_json_success( array( 'title' => esc_html( $title ) ) );
} );
ここが抜けていると、どれだけヘッダーを整えても対応していないブラウザ経由で破られます。順番を逆にしないことが大切です。
手順2:Fetch Metadata を「記録だけ」で導入する
Sec-Fetch-Site による拒否は効果が高い反面、正当な外部からの POST を止めてしまう危険があります。代表例は、決済サービスの画面から自サイトへフォーム POST で戻ってくる流れや、外部の SSO から戻る処理です。いきなり拒否せず、まず mu-plugin で cross-site の POST を記録して、どんなリクエストがあるかを把握します。作業前にファイルのバックアップを取り、ステージング環境で試してください。
<?php
/**
* Plugin Name: Fetch Metadata monitor (log only)
* 配置先: wp-content/mu-plugins/fetch-metadata-monitor.php
*/
add_action( 'init', function () {
$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
if ( 'POST' !== $method ) {
return;
}
$site = isset( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) : '';
if ( 'cross-site' !== $site ) {
return;
}
$uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
$path = (string) wp_parse_url( $uri, PHP_URL_PATH );
// クエリ文字列には個人情報が入ることがあるため、パスだけを記録する
error_log( '[fetch-metadata] cross-site POST: ' . sanitize_text_field( $path ) );
}, 1 );
WP_DEBUG_LOG を有効にしている場合は wp-content/debug.log に、そうでなければ PHP のエラーログに出ます。数日〜数週間記録し、正当な cross-site POST の宛先パスを一覧にしておきます。
手順3:例外を決めてから拒否に切り替える
記録で正当な宛先が把握できたら、それ以外の cross-site POST を 403 にする段階に進みます。判定は次の考え方で組み立てます。
Sec-Fetch-Siteが無いリクエスト(古いブラウザやサーバー間通信)は通し、nonce 検証に任せるsame-originとnone(アドレスバーへの直接入力など)は通すcross-siteの POST は、例外リストにあるパス以外を拒否するsame-site(サブドメイン間)は、サブドメインを信頼できるかで判断する
Webhook のようなサーバー間通信はブラウザを通らないため Fetch Metadata ヘッダーが付かず、1 の扱いで通ります。逆に言えば、この仕組みは「ブラウザ経由の攻撃」専用の防壁で、Webhook 側は署名検証など別の方法で守る必要があります。
手順4:REST API の CORS を許可リスト方式にする
WordPress コアは REST API へのリクエストに Origin ヘッダーがあると、その値を Access-Control-Allow-Origin に返し、資格情報付きの読み取りも許可するヘッダーを送ります。前述の nonce 要件があるため通常は未ログイン扱いになりますが、外部のフロントエンドから使う予定がないなら、許可するオリジンを明示的に絞っておくと設計が分かりやすくなります。コアが付けているフィルター rest_send_cors_headers を外し、許可リスト版に置き換える例です。
add_action( 'rest_api_init', function () {
remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
add_filter( 'rest_pre_serve_request', function ( $value ) {
$origin = get_http_origin();
$allowed = array( 'https://app.example.com' ); // 実際に使うオリジンだけ
if ( $origin && in_array( $origin, $allowed, true ) ) {
header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) );
header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
header( 'Access-Control-Allow-Credentials: true' );
}
header( 'Vary: Origin', false );
return $value;
} );
}, 15 );
コアの既定フィルターは rest_api_init の優先度10で登録されるため、15で外しています。コアが送るヘッダーの内容はバージョンで変わることがあるので、更新後は curl -sI -H 'Origin: https://evil.example' https://example.com/wp-json/ のように別オリジンを指定して、許可していないオリジンに Access-Control-Allow-Origin が返らないことを確認します。* と Access-Control-Allow-Credentials: true を同時に返す設定はブラウザに拒否されますし、任意の Origin を反射する設定は避けてください。
つまずきやすい点
- SameSite=Strict にしてログインが切れたように見える:外部リンクやメールのリンクから来たとき Cookie が付かないため、ログイン済みでも未ログイン画面が出ます。管理画面だけの運用なら許容できますが、会員サイトでは Lax が現実的です。
- CDN やプロキシが Set-Cookie を書き換える:サーバー側で属性を足しても、手前の CDN の設定で変わることがあります。確認は利用者側から見えるレスポンスで行います。
- キャッシュされたページの nonce が古い:ページキャッシュに nonce を含むフォームが残ると、期限切れで正当な送信が失敗します。フォームのあるページはキャッシュ対象から外すか、nonce を Ajax で取り直します。
- ヘッダーの重複:プラグインとサーバー設定の両方で同じヘッダーを送ると、ブラウザの解釈が予想と変わる場合があります。設定する場所を1か所に決めておきます。
まとめ
- CSRF 対策の本体は nonce と権限チェック。ヘッダーはその外側の防壁として重ねる
- CORS はレスポンスの読み取り制御で、CSRF の送信は止めない。広げすぎないよう許可リストで絞る
- SameSite・Origin・Sec-Fetch-Site はブラウザ依存なので、ヘッダーが無い場合も nonce で守れる状態を保つ
- Fetch Metadata の拒否は、記録→例外の洗い出し→拒否の順で段階的に入れる。決済や SSO の戻り先に注意
- 変更はバックアップを取り、ステージングで curl と開発者ツールを使って確認してから本番へ