0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

htmx で CSRF トークンをどう扱うか

0
Posted at

レスポンスボディ由来とレスポンスヘッダー由来で整理する

はじめに

htmx で hx-posthx-delete を使うと、HTML から手軽に HTTP リクエストを送信できます。

<button hx-post="/items/1/publish">
  公開する
</button>

ただし、ログイン済みユーザーの状態を変更する処理では CSRF 対策が必要です。

CSRF トークンの扱い方で迷いやすいのは、次の違いです。

1. レスポンスボディに CSRF トークンを含める
2. レスポンスヘッダーに CSRF トークンを含める

この記事では、htmx でこの2つをどう扱えばよいのかを整理します。

対象読者

この記事は、次のような人を対象にしています。

  • htmx で hx-post / hx-delete を使い始めた人
  • CSRF トークンを hidden input で送るか、HTTP ヘッダーで送るか迷っている人
  • サーバーサイドとフロントエンドの責務分担を整理したい人
  • htmx を使ったフォーム送信や管理画面の実装を考えている人
  • CSRF 対策を、HTML 側に寄せるべきか JavaScript 側に寄せるべきか判断したい人

この記事では、CSRF の基本概念そのものよりも、htmx における CSRF トークンの置き場所と扱い方を整理します。

そのため、HTML フォーム、HTTP リクエスト、HTTP ヘッダー、JavaScript の基本的な役割をある程度知っている読者を想定します。

結論

フォーム中心なら、まずは hidden input がわかりやすいです。

<form hx-post="/profile" hx-target="#result">
  <input type="hidden" name="_csrf" value="{{ csrf_token }}">
  <input name="display_name">
  <button>保存</button>
</form>

<div id="result"></div>

一方で、フォーム外の hx-posthx-delete が増える場合は、CSRF トークンを JavaScript 側で扱い、htmx のリクエストヘッダーに自動で付けるほうが管理しやすくなります。

レスポンスボディ由来:
  hidden input / meta から読む

レスポンスヘッダー由来:
  htmx:afterRequest で読む
  htmx:configRequest で次回リクエストに付ける

1. レスポンスボディ由来:hidden input で送る

もっとも HTML フォームらしい方法は、フォーム内に hidden input を入れることです。

<form hx-post="/settings" hx-target="#message">
  <input type="hidden" name="_csrf" value="{{ csrf_token }}">

  <label>
    サイト名
    <input name="site_name">
  </label>

  <button>保存</button>
</form>

<div id="message"></div>

この場合、CSRF トークンはフォームの送信値としてサーバーに送られます。

_csrf=...&site_name=...

通常のフォーム送信と同じ考え方なので、フォーム中心の画面ではこの方法が単純です。

2. レスポンスボディ由来:meta から読んでヘッダーに付ける

フォーム外のボタンから hx-post する場合は、meta に置いた CSRF トークンを JavaScript で読んで、リクエストヘッダーに付ける方法もあります。

<meta name="csrf-token" content="{{ csrf_token }}">

<button hx-post="/items/1/publish" hx-target="#status">
  公開する
</button>

<div id="status"></div>

<script>
  document.body.addEventListener("htmx:configRequest", (event) => {
    const token = document.querySelector('meta[name="csrf-token"]')?.content;

    if (token) {
      event.detail.headers["X-CSRF-Token"] = token;
    }
  });
</script>

この方式では、htmx のリクエストに次のようなヘッダーが付きます。

X-CSRF-Token: ...

フォームではなく、ボタン単体で状態変更する UI に向いています。

3. レスポンスボディ由来:hidden input を hx-include で含める

ページ共通の hidden input を使いたい場合は、hx-include で含めることもできます。

<input type="hidden" id="csrf-token" name="_csrf" value="{{ csrf_token }}">

<button
  hx-post="/items/1/like"
  hx-include="#csrf-token"
  hx-target="#like-result">
  いいね
</button>

<div id="like-result"></div>

この場合、CSRF トークンはリクエストボディのパラメーターとして送られます。

_csrf=...

ただし、ボタンが増えると hx-include の指定漏れが起きやすくなります。

小さな画面では十分ですが、状態変更ボタンが増える画面では、共通 JavaScript でヘッダーに付ける方式のほうが管理しやすいです。

4. レスポンスヘッダー由来:htmx:afterRequest で受け取る

サーバーが CSRF トークンをレスポンスヘッダーで返す場合を考えます。

X-CSRF-Token: next-token

この値は、htmx のリクエスト完了後に htmx:afterRequest で受け取ります。

<script>
  let csrfToken = null;

  document.body.addEventListener("htmx:afterRequest", (event) => {
    const token = event.detail.xhr.getResponseHeader("X-CSRF-Token");

    if (token) {
      csrfToken = token;
    }
  });
</script>

ここで読めるのは、htmx が送信した HTTP リクエストのレスポンスヘッダーです。

注意点として、通常のページ表示で返された HTML のレスポンスヘッダーを、あとから JavaScript で直接読むことはできません。

そのため、初回トークンもレスポンスヘッダーで取得したい場合は、ページ読み込み後に専用エンドポイントを叩きます。

<div
  hx-get="/csrf-token"
  hx-trigger="load"
  hx-swap="none">
</div>

サーバー側は、たとえば次のように返します。

HTTP/1.1 204 No Content
X-CSRF-Token: initial-token

hx-swap="none" にしておけば、画面は更新せず、レスポンスヘッダーだけを受け取れます。

5. レスポンスヘッダー由来:htmx:configRequest で送る

受け取った CSRF トークンは、次回以降の htmx リクエストに付けます。

<script>
  let csrfToken = null;

  document.body.addEventListener("htmx:afterRequest", (event) => {
    const token = event.detail.xhr.getResponseHeader("X-CSRF-Token");

    if (token) {
      csrfToken = token;
    }
  });

  document.body.addEventListener("htmx:configRequest", (event) => {
    if (csrfToken) {
      event.detail.headers["X-CSRF-Token"] = csrfToken;
    }
  });
</script>

これで、hx-posthx-delete のリクエストに CSRF トークンを自動で付けられます。

6. GET には付けない形にする

CSRF トークンは、通常は状態変更リクエストに必要です。

そのため、GET ではなく、POSTPUTPATCHDELETE のときだけ付けるようにすると意図が明確になります。

<script>
  let csrfToken = null;
  const unsafeMethods = new Set(["post", "put", "patch", "delete"]);

  document.body.addEventListener("htmx:afterRequest", (event) => {
    const token = event.detail.xhr.getResponseHeader("X-CSRF-Token");

    if (token) {
      csrfToken = token;
    }
  });

  document.body.addEventListener("htmx:configRequest", (event) => {
    const method = event.detail.verb?.toLowerCase();

    if (csrfToken && unsafeMethods.has(method)) {
      event.detail.headers["X-CSRF-Token"] = csrfToken;
    }
  });
</script>

この形にしておくと、単なる表示更新の hx-get と、状態変更の hx-post / hx-delete を分けて考えやすくなります。

7. 初回トークン取得前の送信を防ぐ

レスポンスヘッダー由来にする場合、初回トークンを取得する前に hx-post されると困ります。

そのため、状態変更ボタンは最初は無効化しておきます。

<div
  hx-get="/csrf-token"
  hx-trigger="load"
  hx-swap="none">
</div>

<button
  data-requires-csrf
  disabled
  hx-post="/settings"
  hx-target="#result">
  保存
</button>

<div id="result"></div>

<script>
  let csrfToken = null;
  const unsafeMethods = new Set(["post", "put", "patch", "delete"]);

  document.body.addEventListener("htmx:afterRequest", (event) => {
    const token = event.detail.xhr.getResponseHeader("X-CSRF-Token");

    if (!token) return;

    csrfToken = token;

    document.querySelectorAll("[data-requires-csrf]").forEach((el) => {
      el.disabled = false;
    });
  });

  document.body.addEventListener("htmx:configRequest", (event) => {
    const method = event.detail.verb?.toLowerCase();

    if (csrfToken && unsafeMethods.has(method)) {
      event.detail.headers["X-CSRF-Token"] = csrfToken;
    }
  });
</script>

これが、レスポンスヘッダー由来の CSRF トークンを扱うシンプルな構成です。

8. 使い分け

シンプルにまとめると、次のようになります。

状況 おすすめ
通常フォーム中心 hidden input
フォーム外の hx-post / hx-delete が多い meta + htmx:configRequest
サーバーがレスポンスヘッダーでトークンを返す htmx:afterRequest + htmx:configRequest
初回トークンもヘッダーで取得したい /csrf-tokenhx-trigger="load" で取得
JavaScript をなるべく書きたくない hidden input
CSRF 処理をエンジニア管理に寄せたい 共通 JavaScript でヘッダー付与

まとめ

CSRF トークンは、どこに置くかで扱い方が変わります。

hidden input:
  HTMLフォームに近い
  フォーム中心なら単純
  ただし、フォームごとに配置が必要

meta:
  HTMLに初回トークンを置きつつ、JavaScriptで送信制御できる
  フォーム外の htmx 操作に向いている

レスポンスヘッダー:
  JavaScriptで受け取り、JavaScriptで送る
  エンジニア責務として切り出しやすい
  トークン更新を共通処理にしやすい

htmx は HTML から HTTP リクエストを送れる便利な道具です。

その一方で、hx-posthx-delete を使うと、HTML が単なる表示ではなく、状態変更リクエストを発行する入口になります。

そのため、CSRF トークンの扱いは「hidden input を置くかどうか」だけではなく、次のように考えると整理しやすくなります。

フォームに閉じた処理:
  hidden input

フォーム外の操作:
  meta + htmx:configRequest

レスポンスヘッダーでトークンを更新する処理:
  htmx:afterRequest + htmx:configRequest

フォーム中心なら hidden input。
操作 UI が増えるなら htmx:configRequest
レスポンスヘッダーでトークンを扱うなら htmx:afterRequest と組み合わせる。

この3段階で考えると、htmx での CSRF トークンの扱いを整理しやすくなります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?