🛡️

Next.js - Cloudflare Turnstileの導入 と CAPTCHA回避(2captcha)対策

2024/08/27に公開
2

Cloudflare Turnstileとは

簡単に説明するなら

Cloudflareが提供するCAPTCHA代替ツールで、reCAPTCHAなどのCAPTCHAとは異なり、ユーザーが画像を選択したり文字を入力したりする必要がない代わりに、ユーザーの行動や環境情報を基にリスク評価を行い、人間かボットかを判断する。

導入

Cloudflare Turnstileを導入するための基本的な手順は以下の通りです。今回は、Next.jsを使用した例で解説します。

Cloudflareアカウント作成

  1. サインイン - Cloudflare で作成してください

  2. 「Let’s make your website or app fast & secure」って画面が出たら、左上のCloudflareのロゴをクリック

Cloudflare Turnstileのサイトキーとシークレットキーの発行

  1. そしたら、ダッシュボードのホームに移動するので、左のサイドバーから「Turnstile」をクリック

  1. 「サイトを追加」をクリック

  1. 指定に沿って入力し、「作成」をクリック

「サイト名」: 自分に取ってわかりやすい好きな名前
「ドメイン」: Cloudflare Turnstileを置きたいサイトのもの (サブドメインも対象になる)
「ウィジェット モード」: デフォルトの「管理対象」が一番無難。詳しくは、Turnstileのウィジェット モードとは何者か
「事前クリアランス」: 選択肢の上に書いてある説明の通り。なんか、厳格にしておきたいから自分は、デフォルトの「いいえ」にした

  1. この画面になったら、後ででもキーの確認はできるので、Next.jsの方へ

クライアントコンポーネントの作成とサーバーサイドの処理

コンポーネント

※なぜ環境変数名に「NEXT_PUBLIC_」をつけるかについては、Next.js公式ドキュメントの Bundling Environment Variables for the Browser を参照 (要するに、クライアント(ブラウザ)側で処理する際に使う環境変数につける)

/components/turnstile.tsx
"use client";

import Script from "next/script";

export const Turnstile = () => (
  <>
    <div
      className="cf-turnstile"
      data-sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY}
      style={{
        margin: "20px"
      }}
    />
    <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />
  </>
);
  • 使い方の例
/app/login/page.tsx
import { Turnstile } from "@/components/turnstile";
import { useTransition } from "react";
import { Login } from "./actions";
import { toast } from "sonner"; // これについては、https://sonner.emilkowal.ski/ を参照

export default function LoginForm() {
  const [isPending, startTransition] = useTransition();
  const action = async (formData: FormData) => {
    if (isPending) return;

    startTransition(async () => {
      try {
        const result = await Login(formData);

        if (result?.error) throw new Error(result.error);
      } catch(e: unknown) {
        if (e instanceof Error) {
          toast.error(e.message);
          console.error(e);

          /* Cloudflare Turnstile 再検証 */
          window.turnstile.reset();
        } else {
          toast.error("異常なエラーを検知しました");
        }
      }
    })
  }

  return (
    <form action={Login}>
      <input
        type="email"
        name="email"
        autoComplete="email"
      />
      <input
        type="password"
        name="password"
        autoComplete="current-password"
      />

      <Turnstile />

      <button disabled={isPending} type="submit">
        {isPending ? "処理中..." : "ログイン"}
      </button>
    </form>
  )
}

Cloudflare Turnstileのトークン検証関数 (サーバーサイド)

「Server Actions」で使うことを前提としてるため「Route handler」ではテストしてません

※「Server Actions」については、サーバアクション - ReactServer Actions and Mutations を参照

/lib/verify/turnstile.ts
type TurnstileErrorCodes = "missing-input-secret" | "invalid-input-secret" | "missing-input-response" | "invalid-input-response" | "bad-request" | "timeout-or-duplicate" | "internal-error";

type TurnstileResult = {
  success: false;
  "error-codes": TurnstileErrorCodes[];
  messages: []
} | {
  success: true;
  "error-codes": [];
  challenge_ts: string;
  hostname: string;
  action: string;
  cdata: string;
  idempotency_key?: string;
  metadata?: {
    interaction?: boolean
  }
}

/**
 * check Cloudflare Turnstile's Verify Token
 * @param token 検証トークン
 * @returns
 */
export const verifyTurnstileToken = async (token: string): Promise<void> => {
  if (!token) throw new Error("Cloudflare Turnstileの検証が完了していません。");

  const formData = new FormData();

  formData.append("secret", process.env.TURNSTILE_SECRETKEY);
  formData.append("response", token);

  const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    body: formData,
  });

  const { success }: TurnstileResult = await res.json();

  if (!success) throw new Error("Cloudflare Turnstileの検証トークンが不正です。");
};

Server Actions側の処理

/app/login/actions.ts
"use server";

import { verifyTurnstileToken } from "@/lib/verify/turnstile.ts"

export const Login = async (formData: FormData) => {
  try {
    const {
      email, password
      "cf-turnstile-response": turnstile_token
    } = Object.fromEntries(formData.entries()) as { [k: string]: string };

    await verifyTurnstileToken(turnstile_token);

    // ...ログイン処理...

  } catch(e: unknown) {
    // ログインできなかった際のエラー
    if (e instanceof Error) {
      console.error(e);
      return {
        error: e.message
      }
    }
  }
}

最後に.env(環境変数)をセットする

  1. 先程のCloudflareのダッシュボードの「Trunstile」のページに戻る

  1. 「Settings」をクリック

  1. スクロールして、サイトキーとシークレットキーをコピー

  2. ".env"ファイルに以下のように書き込む

NEXT_PUBLIC_TURNSTILE_SITEKEY=ここにサイトキー
TURNSTILE_SECRETKEY=ここにシークレットキー

以上。

CAPTCHA回避対策

「あれ?でも、「2captcha」などのCAPTCHA回避サービスがあるから、回避されちゃって意味ないんじゃ...」って思った人のために書く

経緯

remoteipパラメータを指定すれば、検証者と訪問者の整合性が保たれる的なこと書いてあるけど、ホントかな?
でも、指定しても厳格には検証されないとも書いてある

気になったので、テスト用コードを書いてみた

import { Solver } from "2captcha";

const solver = new Solver(process.env.CAPTCHA_APITOKEN);
const result = await solver.turnstile(process.env.CLOUDFLARE_TURNSTILE_SITEKEY, process.env.TEST_HOSTNAME);

const formData = new FormData();
formData.append("secret", process.env.CLOUDFLARE_TURNSTILE_SECRET);
formData.append("response", result.data);
formData.append("idempotency_key", crypto.randomUUID());

const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
  method: "POST",
  body: formData,
});

console.log(await res.json())

最初は、remoteipなしでテストしてみた

{
  success: true,
  "error-codes": [],
  challenge_ts: "2024-08-27T04:10:09.988Z",
  hostname: "verbose-goldfish-jjrqqq5qrj354r-3000.app.github.dev",
  action: "",
  cdata: "",
  metadata: {
    interactive: true,
  },
}

さすがは、2captcha見事回避して見せる

次は、テストとして、remoteipパラメータに適当な文字列を指定してみる

formData.append("remoteip", "test");

結果、

{
  success: true,
  "error-codes": [],
  challenge_ts: "2024-08-27T04:12:16.508Z",
  hostname: "verbose-goldfish-jjrqqq5qrj354r-3000.app.github.dev",
  action: "",
  cdata: "",
  metadata: {
    interactive: true,
  },
}

ん...?通過できちゃってる?
なんか、remoteipヘッダー機能してないような?

自分のIPやプライベートIPを入れてみたが変わらず

これじゃあ、何を検証してんのかわかんねえ...
ってことで、Cloudflare公式discord鯖で聞いてみた

https://discord.gg/cloudflaredev

返答「なぜremoteipパラメータの指定が効かないかはわからないけど、これを試してみて」

remoteip_leniencyパラメータ

提案してくれたものを試してみることに

「まだ、ドキュメント化してないけど、remoteip_leniencyパラメーターを使ってみて」
要するにこの方法は、まだ最終決定されていないため変更が加えられる可能性がある

でも、使ってみたいので試してみることに

formData.append("remoteip", ipAddress); // remoteipパラメーターと併用するものらしい
formData.append("remoteip_leniency", "strict");

まずは、厳格モードから

✅: 検証通過
❌: 検証エラー

通常 2captcha

うぇえ...
厳格すぎん?いきなり、全部ブロックしてきた

これについては、認証時とサイト訪問時に取得されたIPが、ipv4とipv6かの問題が関係してくるらしい

でも、remoteip_leniencyが機能することが確認できた!

次は、リラックス(relaxed)モード?

formData.append("remoteip", ipAddress); // remoteipパラメーターと併用するものらしい
formData.append("remoteip_leniency", "relaxed");
通常 2captcha

わぉ...
うまく、検証できてるっぽい

さっき上で書いた「Cloudflare Turnstileのトークン検証関数 (サーバーサイド)」の置き換えとして、CAPTCHA回避対策されたものを書いていく

/lib/verify/turnstile.ts
// CAPTCHA回避対策付き
import { headers } from 'next/headers'

type TurnstileErrorCodes = "missing-input-secret" | "invalid-input-secret" | "missing-input-response" | "invalid-input-response" | "bad-request" | "timeout-or-duplicate" | "internal-error";

type TurnstileResult = {
  success: false;
  "error-codes": TurnstileErrorCodes[];
  messages: []
} | {
  success: true;
  "error-codes": [];
  challenge_ts: string;
  hostname: string;
  action: string;
  cdata: string;
  idempotency_key?: string;
  metadata?: {
    interaction?: boolean
  }
}

/**
 * check Cloudflare Turnstile's Verify Token
 * @param token 検証トークン
 * @returns
 */
export const verifyTurnstileToken = async (token: string): Promise<void> => {
  if (!token) throw new Error("Cloudflare Turnstileの検証が完了していません。");

  const header = headers();
  const ipAddress = headers().get("x-real-ip");

  const formData = new FormData();

  formData.append("secret", process.env.TURNSTILE_SECRETKEY);
  formData.append("response", token);
  formData.append("remoteip", ipAddress);
  formData.append("remoteip_leniency", "relaxed");

  const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    body: formData,
  });

  const { success }: TurnstileResult = await res.json();

  if (!success) throw new Error("Cloudflare Turnstileの検証トークンが不正です。");
};

早く正式に実装してほしい。
(正式に決まらなくても、有能すぎて一旦でもドキュメントに書いてほしいわ...)
以上。

Discussion