mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 23:53:17 -05:00
fix(captcha): log Turnstile verification failures (#11286)
Co-authored-by: Taesu <166604494+bytaesu@users.noreply.github.com>
This commit is contained in:
co-authored by
Taesu
parent
bb2b9ef45d
commit
a17efd7cf0
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user