JavaScript の Fetch API のリクエストに Cookie の情報を含め、Craft CMS の CSRF Protection を有効にしたままクロスサイトリクエストを送受信する方法

JavaScript の Fetch API でクロスドメインのリクエストに Cookie の情報を含めることで、Craft CMS の CSRF Protection を有効にしたまま安全にクロスサイトリクエストを送る方法についてご紹介します。

Craft CMS Logo

Craft CMS を Headless CMS っぽく使って React でサイトを構築しています。Craft CMS は本体が GraphQL にも対応していますし、自由にREST API のエンドポイントを定義できる Element API というプラグインもあるのですが、今回はカスタムプラグインで API 的なコントローラーを作りながら実装した方が楽そうだったので、そちらも作りながらの開発になっています。したがって、開発中はローカル(localhost)から Craft に直接リクエストを送りたいのですが、クロスドメインと CSRF トークンの扱いで少し時間を食ってしまったので、今後のためにも対応方法をメモしておきます。

クロスドメインへの対応

まずは localhost:3000 のようなポート番号付きの localhost から Ajax でリクエストを送信します。したがって、Craft CMS がインストールしてあるサーバー側でクロスドメインのリクエストを許可する必要があります。

それについては下記を .htaccess に追記して対応します。

<IfModule mod_headers.c>
    Header set Access-Control-Allow-Origin "http://localhost:3000"
    Header set Access-Control-Allow-Credentials true
    Header set Access-Control-Allow-Methods "POST, GET, OPTIONS, DELETE, PUT"
</IfModule>

これで一応 GET リクエストなどは問題無く取得できるようになりますが、ログインするときなどは POST リクエストを送信する必要がありますので、これでは不十分です。不十分という点について混乱しないように整理すると、サーバー側の設定としては上記の設定で POST などのクロスサイトリクエストにも対応できていますが、Craft CMS はもともとセキュリティにとても力を入れている CMS で、CSRF 防止の機能が備わっているので、そこで防御されてしまう、という意味です。

なお、Header set Access-Control-Allow-Origin "*" を設定してすべてのドメインからのリクエストを許可することもできますが、それはたとえ開発中であってもやめておいた方が良いと思いますし、モダンブラウザだと以下で説明する設定をしても "*" にしてある場合は意図したとおりには動きません。

Craft CMS の CSRF プロテクションの仕組み

Craft CMS には CSRF プロテクションの機能があって、これは Craft 3 から初期状態で有効になっています。

Enabling CSRF Protection | Craft CMS

CSRF というのは、Cross-Site Request Forgery(クロスサイトリクエストフォージェリ)の略で、クロスサイトリクエストを利用して、本人が知らないうちに、ユーザーがログイン状態の別のサイトから情報を盗み取ったり改ざんされたりしてしまう脆弱性です。

Craft CMS では、この対応として、Craft CMS が発行する CSRF トークンと、サーバーのレスポンスにセットされている Cookie との値を検証して、一致しない POST リクエストはすべて 400 エラー(Bad Request)を返すようになっています。したがって、開発中に localhost など Craft の Twig テンプレート以外から POST リクエストを送信すると、この CSRF プロテクションの仕組みに引っかかることになります。

ちなみに、オプションの設定で CSRF トークンによるプロテクションを無効にする方法もありますが、これは選択肢に入れて考えない方がいいでしょう。

Craft CMS の CSRF トークンへの対応

Craft CMS の場合、POST リクエストのときは CSRF トークンの値を一緒に送信する必要があります。Twig のテンプレートを使っている場合は {{ csrfInput() }} という Function で簡単にセットできます。

しかし、今回はテンプレートを使わないで実装しているので、Ajax なりでサーバーから CSRF トークンを取得する必要があります。Element API プラグインでも CSRF トークンを返すエンドポイントを作れますが、今回は自分のプラグインで実装するので、下記のようなコードで取得することにします。

public function actionDoSomething()
{
    //(略)
    return $this->asJson([
        'tokenName' => Craft::$app->getConfig()->general->csrfTokenName,
        'token' => Craft::$app->getRequest()->getCsrfToken(),
    ]);
}

そして、ログインフォームなど POST リクエストを送信する際は、この CSRF トークンを General Config Settings の csrfTokenName で設定される名前(初期値は CRAFT_CSRF_TOKEN)で送信する必要があります。

通常、Craft CMS と同一サーバーで実装する場合は、例えばログインフォームを表示した時点で、サーバーからのレスポンス・ヘッダーの set-cookie に含まれる値で Cookie が自動でセットされ、その Cookie の値と送信される CRAFT_CSRF_TOKEN の値が検証されるので、フォームに CRAFT_CSRF_TOKEN を埋め込むだけで済むのですが、クロスドメインの場合はそう簡単にはいきません。

上記のような CSRF を取得できるエンドポイントを作り、そこへ Ajax でリクエストを送信すると、レスポンスのヘッダーの Set-Cookie の値にはちゃんと CRAFT_CSRF_TOKEN に関する情報が返ってきます。しかし、クロスサイトリクエストの Ajax リクエストの場合、この Set-Cookie の値はクッキーにセットされても、次のリクエストでサーバーに送信されることはありません。理由は、レスポンスで返される Cookie の値にはデフォルトで SameSite=Lax が設定されているためで、この Cookie の値はクロスサイトリクエストでは送信されないのです。

【参考】SameSite cookies - HTTP | MDN

ブラウザの開発者ツールのネットワークタブで確認すると、こういった Cookie には下図のようにワーニングのアイコンが付いているようです。

Clean Shot 2021 08 31 07 19 36

クロスドメインのリクエストに含まれる Cookie は SameSite=none が設定されている必要があります。Craft CMS では、General Config の設定で sameSiteCookieValue に None を設定してあげるだけでOKです(2021/09/13:追記)。

その他の一般的な場合には、下記のようにして .htaccess で強制的に SameSite=none を付けてあげることができます。

<IfModule mod_headers.c>
    Header set Access-Control-Allow-Origin "http://localhost:3000"
    Header set Access-Control-Allow-Credentials true
    Header set Access-Control-Allow-Methods "POST, GET, OPTIONS, DELETE, PUT"
    # 以下を追加
    Header append Set-Cookie "; SameSite=none"
</IfModule>

今回のケースのように SameSite の値が何も付与されていなかったので append しましたが、strict など別の値がセットされている場合は Header edit などで書き換えてあげる必要があります。

【参考】mod_headers - Apache HTTP Server Version 2.4

SameSite=none にすることで、この Cookie の値もクロスサイトリクエストで送信されるようになります。先ほどの Warning のアイコンも消えます。

Clean Shot 2021 08 31 07 30 14

Fetch API でのクロスサイトリクエストに認証情報を含める

これでサーバー側の環境は整いましたが、下記のようにして Fetch API を利用して POST リクエストを送信しても、Craft CMS は相変わらず 400 Bad Request となります。

fetch('https://example.com', {
  method: 'POST',
  body: formData,
  headers: {
    'Accept': 'application/json',
  }
})...(略)

Fetch API はデフォルトでは Cookie などの認証情報を含めないでリクエストを送信するので、認証情報を含めてリクエストを送信するために、下記のように credentials: 'include' オプションを追加します。

fetch('https://example.com', {
  method: 'POST',
  body: formData,
  headers: {
    'Accept': 'application/json',
  },
  credentials: 'include'
})...(略)

【参考】Fetch の使用 - Web API | MDN

これで Craft CMS 側は、POST された CRAFT_CSRF_TOKEN の値と Cookie の CRAFT_CSRF_TOKEN の値を検証して、正当なリクエストとして判断してくれるようになります。

以上です。

Published 2021-08-30
Updated 2023-03-17