1
0

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

Last updated at Posted at 2024-08-31

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

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鯖で聞いてみた

返答「なぜ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の検証トークンが不正です。");
};

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

1
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
1
0