CSRF

bear/csrfはResourceObjectのメソッドをクロスサイトリクエストフォージェリ(CSRF)から守ります。守る対象は、ブラウザから呼ばれる状態変更メソッド(POST / PUT / PATCH / DELETE)です。属性を付けたメソッドに、同一オリジン検査(#[SameOrigin])とトークン検査(#[CsrfToken])の2つのインターセプターをAOPで束縛します。検査に失敗するとBEAR\Resourceの4xx例外が投げられ、エラーレスポンスはフレームワークのエラー処理が返します。

CSRFトークンはリソースメソッドの引数には現れません。リクエストスキーマやAPIドキュメントにも出てきません。CSRFはトランスポート層の関心事であり、リソースの引数ではないからです。

Ray.WebFormModuleはAbstractForm::apply()の実行時にフォームのトークンを検証します。bear/csrfはリクエストの経路によらず、ResourceObjectのメソッド呼び出し時に検証します。

インストール

composer require bear/csrf

モジュール

アプリケーションモジュールでCsrfModuleをインストールします。どの検査を有効にするかは名前付きコンストラクタで選びます。引数を省略したために無防備な設定になることはありません。

use BEAR\Csrf\CsrfModule;

$this->install(CsrfModule::withSameOriginCheck('https://example.com'));

allowedOriginはhttps://example.comやhttp://localhost:8080のように、スキームとホスト(必要ならポート)だけで指定します。パスを含めると、保護されたメソッドを呼ぶたびにLogicExceptionになります。

CLIやサーバー間APIのように、比較するブラウザオリジンが無い環境では、同一オリジン検査はすべてのリクエストを拒否します。そのような環境では、同一オリジン検査を行わないことを明示します。トークン検査はどちらでも有効です。

$this->install(CsrfModule::withoutSameOriginCheck());

tokenFieldの既定値は_csrf_token、sessionKeyの既定値はbear_csrf_tokenです。既存のフィールド名やセッション構成に合わせる必要があるときだけ変更します。

$this->install(CsrfModule::withSameOriginCheck(
    allowedOrigin: 'https://example.com',
    tokenField: 'csrfToken',
    sessionKey: 'cms_csrf_token',
));

フィールド名は、ブラウザの送信と$_POSTの解析を経てもそのまま届く名前にします。使えるのはASCIIの英数字、_、-だけです。空文字や_charset_(大文字小文字を問わず)など条件を満たさない名前は、インジェクターの構築時にInvalidArgumentExceptionになります。_charset_を除外するのは、ブラウザがそのhiddenフィールドの値をフォームの文字コードで上書きするためです。

リソース属性

use BEAR\Csrf\Attribute\CsrfToken;
use BEAR\Csrf\Attribute\SameOrigin;
use BEAR\Resource\ResourceObject;

class Article extends ResourceObject
{
    #[SameOrigin]
    #[CsrfToken]
    public function onPost(string $title, string $body): static
    {
        return $this;
    }
}

属性はメソッドにだけ付けられます。

#[SameOrigin]はwithSameOriginCheck()のときだけ動き、ブラウザが送るオリジン情報を検査します。現行のブラウザがすべて送るSec-Fetch-Siteがある場合は、値がsame-originのときだけ通します。same-site、cross-site、none、未知の値は403です。このとき許可オリジンとは比較しません。Sec-Fetch-Siteを送らない古いクライアントや非ブラウザでは、Origin、無ければRefererを許可オリジンと比較します。3つのどれも無いリクエストは拒否します。

#[CsrfToken]はシンクロナイザートークンを検査します。2つの検査は独立しており、withoutSameOriginCheck()はトークン検査に影響しません。属性は引数を取りません。フィールド名を決めるのはモジュールだけなので、テンプレートが描画する名前とインターセプターが読む名前が食い違うことはありません。

トークンが無いリクエストは、インターセプターがその場で拒否するのではなく、''としてCsrfTokenInterface::verify()に渡されます。何を受け入れるかは束縛された実装が決めます。CSRFが主題でないテストでは、インターセプターを差し替えるのではなく、verify()が常にtrueを返す実装をCsrfTokenInterfaceに束縛します。

検査に失敗するとBEAR\Csrf\Exception\ForbiddenExceptionのサブクラス(403)が投げられます。OriginやRefererの形式が不正な場合はBEAR\Resource\Exception\BadRequestException(400)です。

トークンの取得元

送信されたトークンは次の順で探し、最初に見つかった空でない値を使います。

  1. X-CSRF-Tokenヘッダー
  2. ResourceObject->uri->query[$field]
  3. $_POST[$field]

複数の取得元を組み合わせることはありません。ヘッダーのトークンが間違っていれば、$_POSTに正しいトークンがあっても拒否されます。

ヘッダー名は固定で、HeaderRequestToken::HEADER_NAMEとして公開されています。tokenFieldが変えるのはuri->queryと$_POSTで探すキーだけです。

BEAR.SundayのWebルーターは、POSTでは$_POSTを、PUT / PATCH / DELETEではフォームエンコードまたはJSONのリクエストボディをuri->queryに入れます。ブラウザのフォーム送信やJSONボディのトークンは、2番目の取得元で読まれます。URLのクエリ文字列にトークンを載せないでください。アクセスログ、Refererヘッダー、ブラウザ履歴、共有されたリンクから漏れます。

テンプレート

CsrfHiddenInputは、束縛されたフィールド名のhiddenフィールドをエスケープ済みのHTML文字列として返す呼び出し可能オブジェクトです。テンプレートヘルパーに注入し、保護されたメソッドに送信するすべてのフォームの中で呼びます。

QiqではHtmlHelpersのサブクラスにヘルパーメソッドを追加し、Helpersの束縛を差し替えます。

use BEAR\Csrf\Html\CsrfHiddenInput;
use Qiq\Helper\Html\HtmlHelpers;

class AppHelpers extends HtmlHelpers
{
    public function __construct(private readonly CsrfHiddenInput $csrfHiddenInput)
    {
        parent::__construct();
    }

    public function csrfField(): string
    {
        return ($this->csrfHiddenInput)();
    }
}
use Qiq\Helpers;

$this->install(new QiqModule($templateDir));
$this->bind(Helpers::class)->to(AppHelpers::class);
<form method="post" action="/admin/article">
  {{= csrfField() }}

Twigではcsrf_field関数として登録します。戻り値はHTMLなので、Twigがエスケープしないようis_safeを宣言します。登録する場所はTwigの拡張と同じく、Twig_EnvironmentのProviderです。

use Twig\TwigFunction;

$twig->addFunction(new TwigFunction('csrf_field', $csrfHiddenInput, ['is_safe' => ['html']]));
<form method="post" action="/admin/article">
  {{ csrf_field() }}

出力は<input type="hidden" name="_csrf_token" value="…">で、属性値はエスケープ済みです。そのまま出力してください。重ねてエスケープするとマークアップが壊れます。リソース側はこのフィールドの存在を知る必要がありません。

ヘルパーを取得しただけでは何も起きません。トークンの発行とセッションの開始は、フィールドを描画したときに行われます。描画されたページにはトークンが含まれるので、#[CacheableResponse]や#[DonutCache]のキャッシュ対象やCDNに載せず、Cache-Control: private, no-storeで返してください(キャッシュの「プライベートキャッシュ」を参照)。bear/csrf自身はキャッシュヘッダーを設定しません。

JavaScriptから送る場合は、同じトークンを<meta>タグで1度だけ公開し、X-CSRF-Tokenヘッダーに載せて送り返します。ヘッダーを付けるのは同一オリジンへのリクエストだけにします。$csrfは注入されたCsrfTokenInterfaceです。

<meta name="csrf-token" content="<?= htmlspecialchars($csrf->issue(), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8') ?>">
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/admin/article', {method: 'POST', headers: {'X-CSRF-Token': token}, body});

Qiqは上の<?= ?>をそのまま受け付けます。Twigでは$csrf->issue()を返す関数を登録し、属性値のエスケープはTwigに任せます。

トークンのライフサイクル

トークンはセッション単位で保存され、リクエストをまたいで同じ値が使われます。フォームごとに発行し直すことはありません。

ログインCSRFとセッション固定を緩和するため、認証状態が変わるたびにトークンを更新します。ログインとログアウトで古いトークンを捨ててセッションIDを再生成すると、次のissue()が新しいセッションに紐づいたトークンを発行します。clear()はセッションが未開始なら開始するので、この順で呼べばsession_regenerate_id()は必ず開始済みのセッションに対して動きます。

// $csrfは注入されたCsrfTokenInterface
$csrf->clear();                 // 古いトークンを捨てる
session_regenerate_id(true);
$csrf->issue();                 // 新しいセッション用のトークンを発行する

セッションストアと動作環境

同梱のSessionCsrfStoreはトークンをPHPの$_SESSIONに保存します。セッションの設定はアプリケーションの責任です。ストアが最初に呼ばれる前に、クッキーのHttpOnly、SameSite、Secureを設定しておきます。session_start()に失敗した場合(出力開始後など)は、SessionStartFailedExceptionを投げます。BEAR.Sundayはテンプレートを文字列に描画してから送信するため、テンプレート内でissue()を呼ぶ分には問題ありません。

$_SESSIONはプロセス単位なので、同梱のストアは1つのリクエストが1つのプロセスを占有する環境(PHP-FPM、mod_php、ビルトインサーバー、CLI)でのみ正しく動きます。同梱のストアは、現在のバージョンではSwooleなどの常駐ワーカーをサポートしていません。1つのワーカーが複数のリクエストを処理すると$_SESSIONがリクエストを超えて残り、訪問者全員が同じトークンを共有するため、トークン検査が機能しません。コルーチン内ではCoroutineUnsafeStoreExceptionを投げますが、enable_coroutineを切ったSwooleワーカーは検出できません。

常駐ワーカーで使う場合は、コルーチンコンテキストやセッションIDをキーにした共有バックエンドなど、リクエストに閉じた保存先でCsrfStoreInterfaceを実装して束縛します。

use BEAR\Csrf\CsrfStoreInterface;

$this->bind(CsrfStoreInterface::class)->to(MyRequestScopedStore::class);