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 { 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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user