Fix hosted Turnstile captcha fail-open config (#350)

This commit is contained in:
Ben Senescu 2026-07-05 18:06:19 -04:00 committed by GitHub
parent e39de22f96
commit 86407c97e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 82 additions and 13 deletions

View File

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
getHostedTurnstileSecretKey,
hasHostedTurnstileConfig,
} from "@/lib/auth-turnstile";
describe("hosted Turnstile auth config", () => {
it("enforces captcha from the hosted server secret even when the runtime site key is absent", () => {
expect(
getHostedTurnstileSecretKey({
AUTH_MODE: "hosted",
TURNSTILE_SECRET_KEY: " server-secret ",
}),
).toBe("server-secret");
});
it("does not install captcha outside hosted mode", () => {
expect(
getHostedTurnstileSecretKey({
AUTH_MODE: "local_noauth",
TURNSTILE_SECRET_KEY: "server-secret",
}),
).toBeUndefined();
});
it("fails hosted config when a runtime site key is configured without a secret", () => {
expect(
hasHostedTurnstileConfig({
AUTH_MODE: "hosted",
TURNSTILE_SITE_KEY: "site-key",
}),
).toBe(false);
});
it("allows hosted config with no runtime site key so build/runtime divergence can still enforce from the secret", () => {
expect(
hasHostedTurnstileConfig({
AUTH_MODE: "hosted",
TURNSTILE_SECRET_KEY: "server-secret",
}),
).toBe(true);
});
});

26
src/lib/auth-turnstile.ts Normal file
View File

@ -0,0 +1,26 @@
import { isHostedAuthMode } from "@/lib/auth-mode";
type TurnstileAuthEnv = {
AUTH_MODE?: string;
TURNSTILE_SECRET_KEY?: string;
TURNSTILE_SITE_KEY?: string;
};
export function getHostedTurnstileSecretKey(env: TurnstileAuthEnv) {
if (!isHostedAuthMode(env.AUTH_MODE)) {
return undefined;
}
return env.TURNSTILE_SECRET_KEY?.trim() || undefined;
}
export function hasHostedTurnstileConfig(env: TurnstileAuthEnv) {
const siteKey = env.TURNSTILE_SITE_KEY?.trim();
const secretKey = env.TURNSTILE_SECRET_KEY?.trim();
// No runtime site key means the Worker is allowed to rely on a client build
// value while still enforcing with the server secret when it is present. If a
// runtime site key is configured, require the matching secret so the hosted
// auth route fails closed instead of showing a non-enforced captcha.
return !siteKey || Boolean(secretKey);
}

View File

@ -13,6 +13,10 @@ import { getDatabaseProvider } from "@/db/provider";
import { z } from "zod"; import { z } from "zod";
import { isHostedAuthMode } from "@/lib/auth-mode"; import { isHostedAuthMode } from "@/lib/auth-mode";
import { createBaseAuthConfig } from "@/lib/auth-config"; import { createBaseAuthConfig } from "@/lib/auth-config";
import {
getHostedTurnstileSecretKey,
hasHostedTurnstileConfig,
} from "@/lib/auth-turnstile";
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization"; import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
import { import {
sendHostedPasswordResetEmail, sendHostedPasswordResetEmail,
@ -41,17 +45,12 @@ function createAuth() {
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true"; const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
const baseAuthConfig = createBaseAuthConfig(); const baseAuthConfig = createBaseAuthConfig();
// Turnstile captcha on signup — hosted only, and only when BOTH keys are set. // Turnstile captcha on signup — hosted only. Enforcement is driven by the
// Requiring the site key too (not just the secret) keeps the server in // server-side secret alone so a client build/runtime site-key mismatch cannot
// lockstep with the client widget, which renders only when the site key is // silently omit the Better Auth captcha plugin. Hosted deployments that expose
// present: a secret-only deploy would otherwise fail closed and reject every // the client widget without the matching server secret fail configuration
// signup (client sends no token). Left off entirely when unconfigured so // checks instead of presenting a bypassable captcha.
// local/self-hosted builds are unaffected. Relies on the same const turnstileSecretKey = getHostedTurnstileSecretKey(env);
// build-env == runtime-env contract as AUTH_MODE.
const turnstileSecretKey =
isHostedAuthMode(env.AUTH_MODE) && env.TURNSTILE_SITE_KEY?.trim()
? env.TURNSTILE_SECRET_KEY?.trim()
: undefined;
const database = const database =
getDatabaseProvider() === "postgres" getDatabaseProvider() === "postgres"
@ -274,8 +273,9 @@ export function hasHostedAuthConfig() {
getHostedSecret(); getHostedSecret();
getGoogleSocialProviderConfig(); getGoogleSocialProviderConfig();
return ( return (
Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true" || hasHostedTurnstileConfig(env) &&
hasHostedAuthEmailConfig() (Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true" ||
hasHostedAuthEmailConfig())
); );
} catch { } catch {
return false; return false;