fix(captcha): log Turnstile verification failures (#11286)

Co-authored-by: Taesu <166604494+bytaesu@users.noreply.github.com>
This commit is contained in:
better-release[bot]
2026-09-15 03:32:33 +09:00
committed by GitHub
co-authored by Taesu
parent bb2b9ef45d
commit a17efd7cf0
4 changed files with 130 additions and 20 deletions
+5
View File
@@ -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.
@@ -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 () => {
@@ -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,
});
@@ -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<string, unknown>) => {
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;