Cloudflare Turnstileの導入 と CAPTCHA回避(2captcha)対策

:::note
Cloudflare Turnstileの導入は既にできてて、「2captcha」などのCAPTCHA回避サービスで回避されるのを対策する方法だけを知りたいって人は、[こちら](#captcha回避対策)
この投稿は、zennに投稿したものと同じものです
:::

# Cloudflare Turnstileとは
簡単に説明するなら

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

# 導入

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

## Cloudflareアカウント作成

1. [サインイン - Cloudflare](https://dash.cloudflare.com/sign-up) で作成してください

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

![](https://storage.googleapis.com/zenn-user-upload/87cc5837ecae-20240827.png)

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

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

![](https://storage.googleapis.com/zenn-user-upload/91693d7e452b-20240827.png)

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

![](https://storage.googleapis.com/zenn-user-upload/656ab0e1cfce-20240827.png)

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

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

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

![](https://storage.googleapis.com/zenn-user-upload/259ea181b644-20240827.png)

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

### コンポーネント

※なぜ環境変数名に「NEXT_PUBLIC_」をつけるかについては、Next.js公式ドキュメントの [Bundling Environment Variables for the Browser](https://nextjs.org/docs/app/building-your-application/configuring/environment-variables#bundling-environment-variables-for-the-browser) を参照 (要するに、クライアント(ブラウザ)側で処理する際に使う環境変数につける)

```ts:/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 />
  </>
);
```

- 使い方の例
```ts:/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」については、[サーバアクション - React](https://ja.react.dev/reference/rsc/server-actions) と [Server Actions and Mutations](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations) を参照

```ts:/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側の処理

```ts:/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」のページに戻る

![](https://storage.googleapis.com/zenn-user-upload/dceb2a976015-20240827.png)

2. 「Settings」をクリック

![](https://storage.googleapis.com/zenn-user-upload/938fe18ca0bb-20240827.png)

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

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

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

以上。

# CAPTCHA回避対策

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

## 経緯

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

![](https://storage.googleapis.com/zenn-user-upload/5e49cf43bc31-20240827.png)

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

```ts
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なしでテストしてみた

```JSON
{
  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パラメータに適当な文字列を指定してみる

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

結果、

```JSON
{
  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

![](https://storage.googleapis.com/zenn-user-upload/3fb31de5c1d7-20240827.png)

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

![](https://storage.googleapis.com/zenn-user-upload/e4d86a732287-20240827.png)



## remoteip_leniencyパラメータ

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

![](https://storage.googleapis.com/zenn-user-upload/c99123b76f80-20240827.png)

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

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

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

まずは、厳格モードから

✅: 検証通過
❌: 検証えらー

| 通常 | 2captcha |
| ---- | ---- |
| ❌ | ❌ |

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

これについては、認証時とサイト訪問時に取得されたIPが、ipv4とipv6かの問題が関係してくるらしい
![](https://storage.googleapis.com/zenn-user-upload/a1eb125e39f7-20240828.png)

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

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

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

| 通常 | 2captcha |
| ---- | ---- |
| ✅ | ❌ |

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

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

```ts:/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の検証トークンが不正です。");
};
```

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