🔥

RemixでCloudflare Turnstileを使う

2024/07/26に公開

はじめに

個人開発でユーザー投稿型のサイトを運営しているのだけど、そのサイトが以前悪意のあるユーザーからHTTPフラッド攻撃を受けた。

https://www.cloudflare.com/ja-jp/learning/ddos/http-flood-ddos-attack/

実際に受けたのは上記記事の中で「HTTP POST攻撃」と呼ばれている攻撃であり、大量のPOSTリクエストを処理するためデータベース処理の負荷が増大した結果、一時的にサービスがダウンする被害を受けた(現在は正常稼働中)。

攻撃への対策としてBot由来のPOSTリクエストをはじく仕組みを導入する必要があり、Cloudflare Turnstileを導入することにした。完成系は以下のようになる。フロントエンド側でユーザーが人間あることの確認ができ次第、ボタンが押せるようになる。サーバーサイド側でも検証が入っており、Botからリクエストが飛んだ場合はサーバーサイドの処理を受け付けないようになっている。

Image from Gyazo

この記事では、Remixを利用したWebアプリケーションに対してCloudflare Turnstileを導入するにあたり、最低限のサンプルコードを利用して具体的な実装方法について書いていく。自分用のメモとして書き残しているが、誰かの参考になれば幸いである。

記事の前提

  • Remix 2.8.1を利用する
  • Remixの基本的な知識はあるものとする
  • クライアントサイドの処理には marsidev/react-turnstileを利用する
  • Cloudflareのアカウントは作成済みとする

Cloudflare Turnstileについて

  • Cloudflareの提供するCAPTCHAツール
  • ユーザー認証をスムーズに行うことが可能
  • 価格は割安(ほぼ無料)であり、個人開発にも向いている
  • 設定でinvisibleにもできるため、なじむUIが作れる

詳しくは以下参照
https://developers.cloudflare.com/turnstile/

基本的な仕組みは以下の通り

  1. フロントエンド側でChallengeを実施し、ユーザーが人間であることを確認する
  2. Challengeの結果ユーザーが人間であることを確認できた場合、tokenを発行する
  3. tokenをCloudflareが提供するAPIに送信し、トークンの有効性を検証する
  4. トークンが有効であることが確認できた場合、APIから成功のレスポンスが返る
  5. このレスポンスを利用し、アプリケーション側でユーザーが人間であることが確認できる

実装

コードの全体は以下から閲覧できる

https://github.com/sora32127/remix-cloudflare-test

実装方針

大まかな実装方針は以下の通り

  1. 事前準備:Cloudflareのコンソールから設定を実施し、サイト名・ドメイン名・ウィジェットモードを設定する
  2. 事前準備:サイトキーとSecret Keyを取得する
  3. クライアントサイド実装:以下の処理を書く
    1. ユーザーに対してChallengeを実施する
    2. トークンを取得する
    3. サーバーサイドにトークンを送信する
  4. サーバーサイド実装:受け取ったトークンをサーバーサイド側で処理し、siteverify APIに対してトークンを送信して有効性を検証する
  5. トークンが有効である場合、サーバーサイドの処理を実行する処理を書く

詳しくは以下参照

https://developers.cloudflare.com/turnstile/get-started/

事前準備

  • Cloudflareのコンソールから左メニュー>Turnstile>Add SiteからSite NameとDomainを入力
  • Widget Modeは何でもいいが、今回はManagedを選択
  • Would you like to opt for pre-clearance for this site?はNoを選択
  • Site KeyとSecret Keyをメモしておく

なお、ローカルで開発する際は以下の手順で開発環境をセットアップしておくと開発が楽にできる

  1. .env に以下のキーをセット
CF_TURNSTILE_SITE_KEY=1x00000000000000000000AA
CF_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
  1. ダッシュボードのdomainsに localhost を設定する

開発環境については以下の公式ドキュメントに詳しい

https://developers.cloudflare.com/turnstile/troubleshooting/testing/

クライアントサイド実装

npm i @marsidev/react-turnstile

を実行した後、以下のように書く

_index.tsx
import { ActionFunctionArgs, json } from "@remix-run/node";
import { useFetcher, useLoaderData } from "@remix-run/react";
import { Turnstile } from "@marsidev/react-turnstile";
import { useState } from "react";

export async function loader(){
  const CF_TURNSTILE_SITE_KEY = process.env.CF_TURNSTILE_SITE_KEY;
  return json({ CF_TURNSTILE_SITE_KEY });
}

export default function Index() {
  const { CF_TURNSTILE_SITE_KEY } = useLoaderData<typeof loader>();

  const [token, setToken] = useState<string>("");
  const [isValidUser, setIsValidUser] = useState<boolean>(false);

  const fetcher = useFetcher();

  const handleTurnstileSuccess = (token: string) => {
    setToken(token);
    setIsValidUser(true);
  };

  const handleSubmit = () => {
    const formData = new FormData();
    formData.append("token", token);
    fetcher.submit(formData, {
      method: "post",
      action: "/?index",
    });

  }

  if (!CF_TURNSTILE_SITE_KEY) {
    return <p>No CF_TURNSTILE_SITE_KEY found</p>;
  }

  return (
    <>
      <h1>React-Turnstile-Demo</h1>
      <Turnstile siteKey={CF_TURNSTILE_SITE_KEY} onSuccess={(token) => handleTurnstileSuccess(token)} />
      <button
        disabled={!isValidUser}
        onClick={handleSubmit}
        className={`bg-blue-500 text-white px-4 py-2 rounded-md ${!isValidUser ? "bg-gray-200 cursor-not-allowed" : ""}`}>Submit</button>
    </>
 
  );
}

実装ポイントは以下の通り:

  • <Turnstile />onSuccessに対して受け取ったトークンをstateにセットする関数を渡している
    • Turnstile側で行ったチャレンジの結果、Botではないと確認されたらトークンがステート token にセットされる
  • ステートisValidUserを利用してボタンのdisabled属性をコントロールする
    • チャレンジが完了していない場合はボタンが押せないようにする
  • tokenをフォームに付与し、action関数(後述)に対して引き渡している

サーバーサイド実装

上記コードに対して下記のaction関数を付け加える

_index.tsx
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();  
  const origin = new URL(request.url).origin;
  const response = await fetch(`${origin}/api/verify`, {
    method: "POST",
    body: formData,
  });
  const data = await response.json();
  if (data.success) {
    // ここに認証が完了した場合の処理を加える(今回は省略)
    return json({ message: "ok" });
  }

  return json({ message: "error" });
}

api.verify.tsxの実装は以下の通り

api.verify.tsx
import { ActionFunctionArgs, json } from "@remix-run/node";

const CF_TURNSTILE_SECRET_KEY = process.env.CF_TURNSTILE_SECRET_KEY;
const CF_TURNSTILE_VERIFY_ENDPOINT = "https://challenges.cloudflare.com/turnstile/v0/siteverify";


export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const token = formData.get("token") as string;
  if (!token) {
    return json({ success: false, message: "token is required" });
  }

  if (!CF_TURNSTILE_SECRET_KEY) {
    return json({ success: false, message: "CF_TURNSTILE_SECRET_KEY is required" });
  }

  const verifyResponse = await fetch(CF_TURNSTILE_VERIFY_ENDPOINT, {
    method: 'POST',
    body: `secret=${encodeURIComponent(CF_TURNSTILE_SECRET_KEY)}&response=${encodeURIComponent(token)}`,
    headers: {
      'content-type': 'application/x-www-form-urlencoded'
    }
  })
  const verifyData = await verifyResponse.json();
  if (verifyData.success) {
    return json({ success: true, message: "ok" });
  }
  return json({ success: false, message: "failed" });
}


おわりに

余談だが、まだCloudflare Turnstileに慣れていなかったころ、クライアントサイドで保護するだけでいいと思っていた。当然そんなことはなく、公式ドキュメントに以下のような注意書きがあるのを見つけた。

Rendering the client-side integration & validating the server-side response are both necessary to allow Turnstile to function properly.

結構あるあるの勘違いなのかもしれない。

Cloudflareが別で提供しているWAFも組み合わせれば大体の攻撃に対応できる気がする。Cloudflare便利だな~

Discussion