Fix hosted Turnstile captcha fail-open config (#350)
This commit is contained in:
parent
e39de22f96
commit
86407c97e1
43
src/lib/auth-turnstile.test.ts
Normal file
43
src/lib/auth-turnstile.test.ts
Normal 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
26
src/lib/auth-turnstile.ts
Normal 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);
|
||||
}
|
||||
@ -13,6 +13,10 @@ import { getDatabaseProvider } from "@/db/provider";
|
||||
import { z } from "zod";
|
||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||
import { createBaseAuthConfig } from "@/lib/auth-config";
|
||||
import {
|
||||
getHostedTurnstileSecretKey,
|
||||
hasHostedTurnstileConfig,
|
||||
} from "@/lib/auth-turnstile";
|
||||
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||
import {
|
||||
sendHostedPasswordResetEmail,
|
||||
@ -41,17 +45,12 @@ function createAuth() {
|
||||
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
||||
const baseAuthConfig = createBaseAuthConfig();
|
||||
|
||||
// Turnstile captcha on signup — hosted only, and only when BOTH keys are set.
|
||||
// Requiring the site key too (not just the secret) keeps the server in
|
||||
// lockstep with the client widget, which renders only when the site key is
|
||||
// present: a secret-only deploy would otherwise fail closed and reject every
|
||||
// signup (client sends no token). Left off entirely when unconfigured so
|
||||
// local/self-hosted builds are unaffected. Relies on the same
|
||||
// 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;
|
||||
// Turnstile captcha on signup — hosted only. Enforcement is driven by the
|
||||
// server-side secret alone so a client build/runtime site-key mismatch cannot
|
||||
// silently omit the Better Auth captcha plugin. Hosted deployments that expose
|
||||
// the client widget without the matching server secret fail configuration
|
||||
// checks instead of presenting a bypassable captcha.
|
||||
const turnstileSecretKey = getHostedTurnstileSecretKey(env);
|
||||
|
||||
const database =
|
||||
getDatabaseProvider() === "postgres"
|
||||
@ -274,8 +273,9 @@ export function hasHostedAuthConfig() {
|
||||
getHostedSecret();
|
||||
getGoogleSocialProviderConfig();
|
||||
return (
|
||||
Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true" ||
|
||||
hasHostedAuthEmailConfig()
|
||||
hasHostedTurnstileConfig(env) &&
|
||||
(Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true" ||
|
||||
hasHostedAuthEmailConfig())
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user