From a17efd7cf0ce29bea02dfe5924f477e2e12c6466 Mon Sep 17 00:00:00 2001 From: "better-release[bot]" <273320539+better-release[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 03:32:33 +0900 Subject: [PATCH] fix(captcha): log Turnstile verification failures (#11286) Co-authored-by: Taesu <166604494+bytaesu@users.noreply.github.com> --- .changeset/bright-badgers-observe.md | 5 + .../src/plugins/captcha/captcha.test.ts | 112 +++++++++++++++--- .../better-auth/src/plugins/captcha/index.ts | 1 + .../verify-handlers/cloudflare-turnstile.ts | 32 ++++- 4 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 .changeset/bright-badgers-observe.md diff --git a/.changeset/bright-badgers-observe.md b/.changeset/bright-badgers-observe.md new file mode 100644 index 0000000000..4677980b3e --- /dev/null +++ b/.changeset/bright-badgers-observe.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Log Cloudflare Turnstile error codes and binding mismatches on the server so CAPTCHA verification failures can be diagnosed. diff --git a/packages/better-auth/src/plugins/captcha/captcha.test.ts b/packages/better-auth/src/plugins/captcha/captcha.test.ts index a4762baae1..f806950c74 100644 --- a/packages/better-auth/src/plugins/captcha/captcha.test.ts +++ b/packages/better-auth/src/plugins/captcha/captcha.test.ts @@ -224,24 +224,82 @@ describe("captcha", async () => { expect(res.error?.status).toBe(500); }); - it("Should return 403 in case of a validation failure", async () => { - mockBetterFetch.mockResolvedValue({ - data: { - success: false, - "error-codes": ["invalid-input-response"], - }, - }); - const res = await client.signIn.email({ - email: "test@test.com", - password: "test123456", - fetchOptions: { - headers: { - "x-captcha-response": "captcha-token", + /** + * @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#best-practices + */ + describe("Siteverify failures", () => { + it("returns a generic 403", async () => { + mockBetterFetch.mockResolvedValue({ + data: { + success: false, + "error-codes": ["invalid-input-response"], }, - }, + }); + const res = await client.signIn.email({ + email: "test@test.com", + password: "test123456", + fetchOptions: { + headers: { + "x-captcha-response": "captcha-token", + }, + }, + }); + + expect(res.error?.status).toBe(403); + expect(res.error?.code).toBe("VERIFICATION_FAILED"); + expect(res.error?.message).toBe("Captcha verification failed"); }); - expect(res.error?.status).toBe(403); + it("logs diagnostics without sensitive response fields", async () => { + const log = vi.fn(); + const { client } = await getTestInstance({ + logger: { log }, + plugins: [ + captcha({ + provider: "cloudflare-turnstile", + secretKey: "xx-secret-key", + }), + ], + }); + mockBetterFetch.mockResolvedValue({ + data: { + success: false, + "error-codes": ["invalid-input-response"], + hostname: "example.com", + action: "login", + cdata: "private-custom-data", + metadata: { + ephemeral_id: "private-device-id", + }, + }, + }); + + await client.signIn.email({ + email: "test@test.com", + password: "test123456", + fetchOptions: { + headers: { + "x-captcha-response": "captcha-token", + }, + }, + }); + + expect(log).toHaveBeenCalledWith( + "warn", + "Cloudflare Turnstile verification failed", + expect.objectContaining({ + provider: "cloudflare-turnstile", + reason: "siteverify_rejected", + errorCodes: ["invalid-input-response"], + hostname: "example.com", + action: "login", + }), + ); + + const loggedDetails = log.mock.calls[0]?.[2]; + expect(loggedDetails).not.toHaveProperty("cdata"); + expect(loggedDetails).not.toHaveProperty("metadata"); + }); }); }); @@ -638,7 +696,9 @@ describe("captcha", async () => { describe("action and hostname binding", async () => { it("rejects a Turnstile token whose action does not match expectedAction", async () => { + const log = vi.fn(); const { client } = await getTestInstance({ + logger: { log }, plugins: [ captcha({ provider: "cloudflare-turnstile", @@ -656,10 +716,22 @@ describe("captcha", async () => { fetchOptions: { headers: { "x-captcha-response": "token" } }, }); expect(res.error?.status).toBe(403); + expect(log).toHaveBeenCalledWith( + "warn", + "Cloudflare Turnstile verification failed", + expect.objectContaining({ + provider: "cloudflare-turnstile", + reason: "action_mismatch", + expectedAction: "login", + actualAction: "signup", + }), + ); }); it("rejects a Turnstile token from a hostname outside allowedHostnames", async () => { + const log = vi.fn(); const { client } = await getTestInstance({ + logger: { log }, plugins: [ captcha({ provider: "cloudflare-turnstile", @@ -677,6 +749,16 @@ describe("captcha", async () => { fetchOptions: { headers: { "x-captcha-response": "token" } }, }); expect(res.error?.status).toBe(403); + expect(log).toHaveBeenCalledWith( + "warn", + "Cloudflare Turnstile verification failed", + expect.objectContaining({ + provider: "cloudflare-turnstile", + reason: "hostname_mismatch", + allowedHostnames: ["myapp.com"], + actualHostname: "untrusted.example", + }), + ); }); it("rejects a reCAPTCHA v3 token whose action does not match expectedAction", async () => { diff --git a/packages/better-auth/src/plugins/captcha/index.ts b/packages/better-auth/src/plugins/captcha/index.ts index e997d71a7d..6a6f5e5d40 100644 --- a/packages/better-auth/src/plugins/captcha/index.ts +++ b/packages/better-auth/src/plugins/captcha/index.ts @@ -88,6 +88,7 @@ export const captcha = (options: CaptchaOptions) => if (options.provider === Providers.CLOUDFLARE_TURNSTILE) { return await verifyHandlers.cloudflareTurnstile({ ...handlerParams, + logger: ctx.logger, expectedAction: options.expectedAction, allowedHostnames: options.allowedHostnames, }); diff --git a/packages/better-auth/src/plugins/captcha/verify-handlers/cloudflare-turnstile.ts b/packages/better-auth/src/plugins/captcha/verify-handlers/cloudflare-turnstile.ts index 0a75466bfd..2709c04184 100644 --- a/packages/better-auth/src/plugins/captcha/verify-handlers/cloudflare-turnstile.ts +++ b/packages/better-auth/src/plugins/captcha/verify-handlers/cloudflare-turnstile.ts @@ -1,3 +1,4 @@ +import type { InternalLogger } from "@better-auth/core/env"; import { betterFetch } from "@better-fetch/fetch"; import { middlewareResponse } from "../../../utils/middleware-response"; import { CAPTCHA_VERIFY_TIMEOUT_MS } from "../constants"; @@ -7,6 +8,7 @@ type Params = { siteVerifyURL: string; secretKey: string; captchaResponse: string; + logger: InternalLogger; remoteIP?: string | undefined; expectedAction?: string | undefined; allowedHostnames?: string[] | undefined; @@ -31,6 +33,7 @@ export const cloudflareTurnstile = async ({ siteVerifyURL, captchaResponse, secretKey, + logger, remoteIP, expectedAction, allowedHostnames, @@ -50,22 +53,37 @@ export const cloudflareTurnstile = async ({ throw new Error(INTERNAL_ERROR_CODES.SERVICE_UNAVAILABLE.message); } - const verificationFailed = () => - middlewareResponse({ + const verificationFailed = (details: Record) => { + logger.warn("Cloudflare Turnstile verification failed", { + provider: "cloudflare-turnstile", + ...details, + }); + + return middlewareResponse({ message: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.message, code: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.code, status: 403, }); + }; if (!response.data.success) { - return verificationFailed(); + return verificationFailed({ + reason: "siteverify_rejected", + errorCodes: response.data["error-codes"] ?? [], + ...(response.data.hostname && { hostname: response.data.hostname }), + ...(response.data.action && { action: response.data.action }), + }); } // When configured, bind the token to the expected action and to an // allow-list of hostnames so a token issued for a different action or host // (e.g. under a shared widget or "Any Hostname") cannot be reused here. if (expectedAction && response.data.action !== expectedAction) { - return verificationFailed(); + return verificationFailed({ + reason: "action_mismatch", + expectedAction, + actualAction: response.data.action, + }); } if ( allowedHostnames && @@ -75,7 +93,11 @@ export const cloudflareTurnstile = async ({ allowedHostnames.includes(response.data.hostname) ) ) { - return verificationFailed(); + return verificationFailed({ + reason: "hostname_mismatch", + allowedHostnames, + actualHostname: response.data.hostname, + }); } return undefined;