fix: protect public Auth flows from automated abuse (#1904)
* fix: add Turnstile to public auth flows * test: isolate Turnstile auth tests
This commit is contained in:
@@ -18,6 +18,11 @@ NEXT_PUBLIC_SELF_HOSTED=true
|
||||
# Optional dedicated HMAC secret; otherwise SUPABASE_SERVICE_ROLE_KEY is used.
|
||||
# SESSION_TIMEOUT_SECRET=
|
||||
|
||||
# Optional Cloudflare Turnstile site key for Supabase Auth bot protection.
|
||||
# The matching secret is configured in GoTrue/Supabase Auth, never here.
|
||||
# Enable provider-side enforcement only after this public key is deployed.
|
||||
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
|
||||
# Set to true when public signup is turned off in your GoTrue/Supabase auth
|
||||
# config (GOTRUE_DISABLE_SIGNUP / "Allow new users to sign up" off). GoTrue
|
||||
# offers no clean server-side read of that setting, so this flag mirrors it.
|
||||
|
||||
@@ -67,6 +67,13 @@ RECEIPT_HUNT_COMPANY_IDS=
|
||||
# The button stays hidden until this is true.
|
||||
# NEXT_PUBLIC_GOOGLE_AUTH_ENABLED=true
|
||||
|
||||
# Cloudflare Turnstile site key for Supabase Auth bot protection. This value is
|
||||
# public and is embedded in the browser bundle. Leave it unset until a widget
|
||||
# has been created for the deployment's exact hostnames. Deploy the site key
|
||||
# before enabling Turnstile with the matching SECRET in Supabase Auth, so the
|
||||
# existing login flow remains available throughout rollout.
|
||||
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
|
||||
# ── Optional: extension features (core runs without these) ─
|
||||
# AI features (document extraction + AI assistant). Three ways to provide a
|
||||
# backend; set one of them. AI_PROVIDER (bedrock|anthropic|openai-compatible)
|
||||
|
||||
@@ -1235,5 +1235,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-25] Proposal line-pattern settlement leg now takes the counterparty template's learned legacy pair (credit for expense, debit for income, mirror-swapped, || 1930), passed raw from QuickReviewDialog: two skeptics refuted the 1930 default (engine books e.g. 2440 from SIE-learned patterns; preview/prefill showed 1930). Declined CodeRabbit's two suggestions on #1894 deliberately: the 3740 rounding line keeps the engine's business-side placement for BOTH diff signs (parity contract; the engine's negative-diff imbalance cannot reach the ledger, commit_journal_entry rejects it; engine-side sign fix is a separate issue) and the naiveOreRound baseline stays raised to 622 (engineRound is a documented parity exception, not drift).
|
||||
[2026-08-25] The production-only _backfill_remaining_20260817 invoice repair snapshot is privilege-contained, not deleted or relocated: PR #1655 identifies it as the safety snapshot for the 337-row 2026-08-17 remaining_amount repair, but the repository establishes neither its retention classification nor approval to destroy financial evidence, so the migration enables RLS, revokes PUBLIC/anon/authenticated access, and limits service_role to read-only for an authorized follow-up review while postgres retains owner control. Neither PR #1655 nor repository history establishes the original repair's behandlingshistorik/rattelse traceability or whether any repaired invoice was linked to a posted voucher; verifying the repair's who/when/what trail and its relation to booked entries is an explicit compliance follow-up, not inferred or altered by this access fix. Default privileges stay unchanged in this scoped fix because Supabase's platform transition and the application's many existing implicit grants require a separate compatibility audit.
|
||||
[2026-08-25] Risk ID RISK-2026-08-25-INVOICE-BACKFILL-SNAPSHOT treatment record (PR #1901): Risk Owner and follow-up owner Emil; classification restricted financial remediation evidence pending BFL review; treatment preserves all 337 rows and merges only the anonymous-access containment; BFL retention/rattelse review deadline 2026-09-25; residual risk after containment Low, explicitly including postgres-owner bypass until that review. Retention or deletion requires the separate reviewed follow-up, and this PR must not delete or alter snapshot rows. This entry and PR #1901 are the repository-native Risk Treatment Plan reference because the repository has no risk register.
|
||||
[2026-08-25] Supabase Auth bot protection uses dependency-free Cloudflare Turnstile with a two-step rollout: a missing public site key keeps existing Auth flows available, while a configured client fails closed until it has a token; provider enforcement is enabled only after the client deploy is verified. The generic Docker image always permits Cloudflare's script and frame origins because its optional site key is substituted at runtime, after the CSP has been built.
|
||||
[2026-08-25] Plugin distribution goes through the Claude plugin directory (public GitHub link, claude plugin validate, submit from claude.ai admin-settings or Console), not an organisation marketplace: org marketplaces accept private/internal repos only and require the Claude GitHub App, so a public monorepo can never pass that dialog (the 'Repository not accessible' error is misleading). Install-time guidance is a /accounted:setup slash command, the convention Anthropic's own plugins use (commands/*-setup.md); no SETUP.md mechanism exists in the plugin spec.
|
||||
[2026-08-25] The marketplace entry for the Accounted plugin is a git-subdir source (public repo URL + path claude-plugin), not the relative path ./claude-plugin: relative sources only resolve when the whole marketplace repo is cloned (Claude Code), while Claude.ai's Add-marketplace backend fetches the manifest and resolves each plugin source as a repository, which surfaced as 'Repository not accessible' on a public repo. git-subdir is also the form the plugin-directory catalog uses for monorepos.
|
||||
|
||||
@@ -42,6 +42,9 @@ ENV NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS=__NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS__
|
||||
ENV NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS=__NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS__
|
||||
ENV NEXT_PUBLIC_SESSION_WARNING_MS=__NEXT_PUBLIC_SESSION_WARNING_MS__
|
||||
ENV NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=__NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL__
|
||||
# Optional Supabase Auth bot protection. The site key is public; the matching
|
||||
# secret belongs in GoTrue/Supabase Auth and must never be baked into this image.
|
||||
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=__NEXT_PUBLIC_TURNSTILE_SITE_KEY__
|
||||
# Keep the branding placeholder intact through prebuild's inject script so
|
||||
# docker-entrypoint.sh can substitute the runtime value into public/sw.js.
|
||||
ENV NEXT_PUBLIC_BRANDING_APP_NAME=__NEXT_PUBLIC_BRANDING_APP_NAME__
|
||||
|
||||
@@ -35,7 +35,15 @@ import {
|
||||
import { buildPasswordResetRedirectTo } from '@/lib/domains/trusted-app-origin'
|
||||
import { AuthFormError } from '@/components/auth/AuthFormError'
|
||||
import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton'
|
||||
import {
|
||||
TurnstileChallenge,
|
||||
type TurnstileChallengeHandle,
|
||||
} from '@/components/auth/TurnstileChallenge'
|
||||
import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth'
|
||||
import {
|
||||
captchaTokenOptions,
|
||||
isTurnstileSubmissionBlocked,
|
||||
} from '@/lib/auth/turnstile'
|
||||
import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error'
|
||||
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
|
||||
import { persistLoginMethodHint, type LoginMethod } from '@/lib/auth/login-method'
|
||||
@@ -77,8 +85,12 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
|
||||
// Consecutive credential failures; from the second one on, the error line
|
||||
// grows a reset-password action (extra help on repeated errors).
|
||||
const [failedAttempts, setFailedAttempts] = useState(0)
|
||||
const [passwordCaptchaToken, setPasswordCaptchaToken] = useState<string | null>(null)
|
||||
const [resetCaptchaToken, setResetCaptchaToken] = useState<string | null>(null)
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||
const passwordTurnstileRef = useRef<TurnstileChallengeHandle>(null)
|
||||
const resetTurnstileRef = useRef<TurnstileChallengeHandle>(null)
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
@@ -145,11 +157,13 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
|
||||
|
||||
const openResetForm = () => {
|
||||
setFormError(null)
|
||||
setResetCaptchaToken(null)
|
||||
setShowResetPassword(true)
|
||||
}
|
||||
|
||||
const closeResetForm = () => {
|
||||
setFormError(null)
|
||||
setResetCaptchaToken(null)
|
||||
setShowResetPassword(false)
|
||||
}
|
||||
|
||||
@@ -248,6 +262,12 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
|
||||
const handlePasswordLogin = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setFormError(null)
|
||||
|
||||
if (isTurnstileSubmissionBlocked(passwordCaptchaToken)) {
|
||||
setFormError({ kind: 'unknown', message: tAuth('turnstile_required') })
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
@@ -258,6 +278,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email: emailValue,
|
||||
password: passwordValue,
|
||||
options: captchaTokenOptions(passwordCaptchaToken),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
@@ -317,6 +338,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
|
||||
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
|
||||
})
|
||||
} finally {
|
||||
passwordTurnstileRef.current?.reset()
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -324,6 +346,12 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
|
||||
const handleResetPassword = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setFormError(null)
|
||||
|
||||
if (isTurnstileSubmissionBlocked(resetCaptchaToken)) {
|
||||
setFormError({ kind: 'unknown', message: tAuth('turnstile_required') })
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
@@ -332,6 +360,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
|
||||
try {
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(emailValue, {
|
||||
redirectTo: buildPasswordResetRedirectTo(window.location.origin),
|
||||
...captchaTokenOptions(resetCaptchaToken),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
@@ -357,6 +386,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
|
||||
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
|
||||
})
|
||||
} finally {
|
||||
resetTurnstileRef.current?.reset()
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -465,7 +495,20 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11" disabled={isLoading || !!resetCooldownUntil}>
|
||||
<TurnstileChallenge
|
||||
ref={resetTurnstileRef}
|
||||
action="accounted_password_reset"
|
||||
onTokenChange={setResetCaptchaToken}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!!resetCooldownUntil ||
|
||||
isTurnstileSubmissionBlocked(resetCaptchaToken)
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@@ -653,7 +696,16 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11" disabled={isLoading}>
|
||||
<TurnstileChallenge
|
||||
ref={passwordTurnstileRef}
|
||||
action="accounted_login"
|
||||
onTokenChange={setPasswordCaptchaToken}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11"
|
||||
disabled={isLoading || isTurnstileSubmissionBlocked(passwordCaptchaToken)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -25,8 +25,16 @@ import {
|
||||
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
|
||||
import { AuthFormError } from '@/components/auth/AuthFormError'
|
||||
import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton'
|
||||
import {
|
||||
TurnstileChallenge,
|
||||
type TurnstileChallengeHandle,
|
||||
} from '@/components/auth/TurnstileChallenge'
|
||||
import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth'
|
||||
import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error'
|
||||
import {
|
||||
captchaTokenOptions,
|
||||
isTurnstileSubmissionBlocked,
|
||||
} from '@/lib/auth/turnstile'
|
||||
import { persistLoginMethodHint, type LoginMethod } from '@/lib/auth/login-method'
|
||||
import { safeReturnTo } from '@/lib/auth/safe-return-to'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -82,9 +90,11 @@ function RegisterPageContent() {
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null)
|
||||
const [confirmError, setConfirmError] = useState<string | null>(null)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||
const confirmInputRef = useRef<HTMLInputElement>(null)
|
||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||
const turnstileRef = useRef<TurnstileChallengeHandle>(null)
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
@@ -316,6 +326,11 @@ function RegisterPageContent() {
|
||||
return
|
||||
}
|
||||
|
||||
if (isTurnstileSubmissionBlocked(captchaToken)) {
|
||||
setFormError({ kind: 'unknown', message: tAuth('turnstile_required') })
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
@@ -328,6 +343,7 @@ function RegisterPageContent() {
|
||||
password: passwordValue,
|
||||
options: {
|
||||
emailRedirectTo: confirmationCallback.toString(),
|
||||
...captchaTokenOptions(captchaToken),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -405,6 +421,7 @@ function RegisterPageContent() {
|
||||
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
|
||||
})
|
||||
} finally {
|
||||
turnstileRef.current?.reset()
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -755,7 +772,16 @@ function RegisterPageContent() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11" disabled={isLoading}>
|
||||
<TurnstileChallenge
|
||||
ref={turnstileRef}
|
||||
action="accounted_signup"
|
||||
onTokenChange={setCaptchaToken}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11"
|
||||
disabled={isLoading || isTurnstileSubmissionBlocked(captchaToken)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
+34
-5
@@ -1,24 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
TurnstileChallenge,
|
||||
type TurnstileChallengeHandle,
|
||||
} from '@/components/auth/TurnstileChallenge'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { BrandWordmark } from '@/components/branding/BrandWordmark'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
captchaTokenOptions,
|
||||
isTurnstileSubmissionBlocked,
|
||||
} from '@/lib/auth/turnstile'
|
||||
|
||||
const branding = getBranding()
|
||||
|
||||
export default function SandboxPage() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null)
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileChallengeHandle>(null)
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
const tAuth = useTranslations('auth')
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getUser().then(({ data: { user } }) => {
|
||||
@@ -27,17 +39,27 @@ export default function SandboxPage() {
|
||||
}, [supabase.auth])
|
||||
|
||||
const handleStartSandbox = async () => {
|
||||
if (isTurnstileSubmissionBlocked(captchaToken)) {
|
||||
toast({
|
||||
title: 'Kunde inte starta sandlådan',
|
||||
description: tAuth('turnstile_required'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInAnonymously()
|
||||
const { error } = await supabase.auth.signInAnonymously({
|
||||
options: captchaTokenOptions(captchaToken),
|
||||
})
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Kunde inte starta sandlådan',
|
||||
description: getUserErrorMessage(error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,7 +73,6 @@ export default function SandboxPage() {
|
||||
description: 'Försök igen om en stund.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -63,6 +84,8 @@ export default function SandboxPage() {
|
||||
description: 'Försök igen om en stund.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
turnstileRef.current?.reset()
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -126,10 +149,16 @@ export default function SandboxPage() {
|
||||
kräver ett riktigt konto.
|
||||
</p>
|
||||
|
||||
<TurnstileChallenge
|
||||
ref={turnstileRef}
|
||||
action="accounted_sandbox"
|
||||
onTokenChange={setCaptchaToken}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
onClick={handleStartSandbox}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isTurnstileSubmissionBlocked(captchaToken)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
import Script from 'next/script'
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
getTurnstileRolloutState,
|
||||
resolveTurnstileSiteKey,
|
||||
} from '@/lib/auth/turnstile'
|
||||
|
||||
const TURNSTILE_SCRIPT_URL =
|
||||
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
|
||||
export type TurnstileAction =
|
||||
| 'accounted_login'
|
||||
| 'accounted_password_reset'
|
||||
| 'accounted_signup'
|
||||
| 'accounted_sandbox'
|
||||
|
||||
type TurnstileWidgetState = 'loading' | 'ready' | 'verified' | 'error'
|
||||
|
||||
type TurnstileRenderOptions = {
|
||||
sitekey: string
|
||||
action: TurnstileAction
|
||||
appearance: 'interaction-only'
|
||||
execution: 'render'
|
||||
size: 'compact'
|
||||
theme: 'auto'
|
||||
callback: (token: string) => void
|
||||
'error-callback': () => void
|
||||
'expired-callback': () => void
|
||||
'timeout-callback': () => void
|
||||
}
|
||||
|
||||
type TurnstileApi = {
|
||||
render: (
|
||||
container: HTMLElement,
|
||||
options: TurnstileRenderOptions,
|
||||
) => string | undefined
|
||||
remove: (widgetId: string) => void
|
||||
reset: (widgetId: string) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileApi
|
||||
}
|
||||
}
|
||||
|
||||
export type TurnstileChallengeHandle = {
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
type TurnstileChallengeProps = {
|
||||
action: TurnstileAction
|
||||
onTokenChange: (token: string | null) => void
|
||||
}
|
||||
|
||||
export const TurnstileChallenge = forwardRef<
|
||||
TurnstileChallengeHandle,
|
||||
TurnstileChallengeProps
|
||||
>(function TurnstileChallenge({ action, onTokenChange }, ref) {
|
||||
const t = useTranslations('auth')
|
||||
const siteKey = resolveTurnstileSiteKey()
|
||||
const rolloutState = getTurnstileRolloutState()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const widgetIdRef = useRef<string | null>(null)
|
||||
const onTokenChangeRef = useRef(onTokenChange)
|
||||
const [widgetState, setWidgetState] = useState<TurnstileWidgetState>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
onTokenChangeRef.current = onTokenChange
|
||||
}, [onTokenChange])
|
||||
|
||||
const clearToken = useCallback((state: TurnstileWidgetState) => {
|
||||
onTokenChangeRef.current(null)
|
||||
setWidgetState(state)
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
clearToken('ready')
|
||||
const widgetId = widgetIdRef.current
|
||||
if (!widgetId || !window.turnstile) return
|
||||
|
||||
try {
|
||||
window.turnstile.reset(widgetId)
|
||||
} catch {
|
||||
clearToken('error')
|
||||
}
|
||||
}, [clearToken])
|
||||
|
||||
useImperativeHandle(ref, () => ({ reset }), [reset])
|
||||
|
||||
const renderWidget = useCallback(() => {
|
||||
if (!siteKey || !containerRef.current || !window.turnstile) return
|
||||
if (widgetIdRef.current) return
|
||||
|
||||
try {
|
||||
setWidgetState('ready')
|
||||
const widgetId = window.turnstile.render(containerRef.current, {
|
||||
sitekey: siteKey,
|
||||
action,
|
||||
appearance: 'interaction-only',
|
||||
execution: 'render',
|
||||
size: 'compact',
|
||||
theme: 'auto',
|
||||
callback: (token) => {
|
||||
onTokenChangeRef.current(token)
|
||||
setWidgetState('verified')
|
||||
},
|
||||
'error-callback': () => clearToken('error'),
|
||||
'expired-callback': reset,
|
||||
'timeout-callback': reset,
|
||||
})
|
||||
if (!widgetId) {
|
||||
clearToken('error')
|
||||
return
|
||||
}
|
||||
widgetIdRef.current = widgetId
|
||||
} catch {
|
||||
clearToken('error')
|
||||
}
|
||||
}, [action, clearToken, reset, siteKey])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const widgetId = widgetIdRef.current
|
||||
if (!widgetId || !window.turnstile) return
|
||||
try {
|
||||
window.turnstile.remove(widgetId)
|
||||
} catch {
|
||||
// The provider may already have removed an expired widget.
|
||||
}
|
||||
widgetIdRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!siteKey) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="hidden"
|
||||
data-turnstile-rollout-state={rolloutState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-2"
|
||||
data-turnstile-rollout-state={rolloutState}
|
||||
data-turnstile-widget-state={widgetState}
|
||||
>
|
||||
<Script
|
||||
id="cloudflare-turnstile"
|
||||
src={TURNSTILE_SCRIPT_URL}
|
||||
strategy="afterInteractive"
|
||||
onReady={renderWidget}
|
||||
onError={() => clearToken('error')}
|
||||
/>
|
||||
<div ref={containerRef} />
|
||||
{(widgetState === 'loading' || widgetState === 'ready') && (
|
||||
<p className="sr-only" role="status">
|
||||
{t('turnstile_checking')}
|
||||
</p>
|
||||
)}
|
||||
{widgetState === 'error' && (
|
||||
<p className="text-center text-xs text-destructive" role="alert">
|
||||
{t('turnstile_error')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -96,6 +96,7 @@ if [ -n "$SUBST_PATHS" ]; then
|
||||
E_SESSION_ABSOLUTE_TIMEOUT_MS=$(sed_esc "${NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS:-}")
|
||||
E_SESSION_WARNING_MS=$(sed_esc "${NEXT_PUBLIC_SESSION_WARNING_MS:-}")
|
||||
E_SESSION_TIMEOUT_FORCE_ALL=$(sed_esc "${NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL:-}")
|
||||
E_TURNSTILE_SITE_KEY=$(sed_esc "${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-}")
|
||||
E_BRANDING_APP_NAME=$(sed_esc "${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}")
|
||||
|
||||
# File-type coverage:
|
||||
@@ -121,6 +122,7 @@ if [ -n "$SUBST_PATHS" ]; then
|
||||
-e "s|__NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS__|${E_SESSION_ABSOLUTE_TIMEOUT_MS}|g" \
|
||||
-e "s|__NEXT_PUBLIC_SESSION_WARNING_MS__|${E_SESSION_WARNING_MS}|g" \
|
||||
-e "s|__NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL__|${E_SESSION_TIMEOUT_FORCE_ALL}|g" \
|
||||
-e "s|__NEXT_PUBLIC_TURNSTILE_SITE_KEY__|${E_TURNSTILE_SITE_KEY}|g" \
|
||||
-e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${E_BRANDING_APP_NAME}|g"
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Auth Bot Protection Runbook
|
||||
|
||||
Accounted's password login, email registration, password recovery, and
|
||||
anonymous sandbox signup support Cloudflare Turnstile through Supabase Auth.
|
||||
The browser integration is dependency-free and loads Cloudflare's official
|
||||
`api.js` directly. Supabase Auth validates each token with the Turnstile
|
||||
secret configured in the project.
|
||||
|
||||
The Turnstile site key is public. The matching secret must exist only in
|
||||
Cloudflare and Supabase Auth. Do not add the secret to this repository,
|
||||
Vercel application variables, Docker images, browser code, logs, or analytics.
|
||||
|
||||
## Rollout states
|
||||
|
||||
The browser exposes the current client state on every protected form through
|
||||
`data-turnstile-rollout-state`:
|
||||
|
||||
- `disabled`: `NEXT_PUBLIC_TURNSTILE_SITE_KEY` is absent. The widget is not
|
||||
loaded and existing Auth calls continue without a CAPTCHA token.
|
||||
- `client-enabled`: the public site key is present. Protected submit buttons
|
||||
remain disabled until the widget returns a token, and the token is passed to
|
||||
Supabase Auth. This state does not prove that provider-side enforcement is
|
||||
active.
|
||||
|
||||
When client-enabled, `data-turnstile-widget-state` reports `loading`, `ready`,
|
||||
`verified`, or `error` without exposing the token. Never record or inspect the
|
||||
token value itself.
|
||||
|
||||
This split keeps login available during rollout. It also means merging this
|
||||
code alone does not activate bot protection.
|
||||
|
||||
The hosted rollout in this runbook is scoped to Accounted's main Supabase
|
||||
project. A separate tenant backend needs its own reviewed widget, hostname
|
||||
allowlist, keys, test identities, and activation record. Do not copy the main
|
||||
project's secret into another project.
|
||||
|
||||
## Hosted activation order
|
||||
|
||||
Use separate Turnstile widgets and key pairs for staging and production.
|
||||
|
||||
1. In Cloudflare, create a managed Turnstile widget and allow only the exact
|
||||
hostnames served by that environment. Include each approved white-label
|
||||
hostname explicitly. Do not allow arbitrary hostnames.
|
||||
2. Set `NEXT_PUBLIC_TURNSTILE_SITE_KEY` for the matching Vercel environment and
|
||||
redeploy. The CSP is widened to `https://challenges.cloudflare.com` only in
|
||||
hosted builds that have this variable.
|
||||
3. Verify all four protected forms reach `client-enabled` and `verified` on
|
||||
staging with a controlled test account. Confirm Auth requests include a
|
||||
CAPTCHA token, but do not copy or log it. Confirm the canonical callback
|
||||
URL and every approved white-label callback URL still complete Auth.
|
||||
4. In the matching Supabase project, open Authentication, Bot and Abuse
|
||||
Protection. Select Cloudflare Turnstile, enter the matching secret, and
|
||||
enable CAPTCHA protection.
|
||||
5. Verify on staging that requests without a token, with an invalid token,
|
||||
with an expired token, and with an already-used token are rejected. Verify
|
||||
normal login, registration, recovery, and sandbox entry still work.
|
||||
6. Repeat the same ordered rollout for production. Do not use real customer
|
||||
addresses for verification and do not run password or mail-volume tests
|
||||
against production.
|
||||
|
||||
Rollback uses the reverse safety order: disable Supabase CAPTCHA enforcement
|
||||
first, then remove the public site key and redeploy. Removing the site key
|
||||
while provider enforcement remains active would block every protected flow.
|
||||
|
||||
## Self-hosted activation
|
||||
|
||||
The Docker image accepts the same optional
|
||||
`NEXT_PUBLIC_TURNSTILE_SITE_KEY`. Its entrypoint substitutes the public value
|
||||
into the built client. The generic image always permits the Cloudflare
|
||||
Turnstile script and frame origins in CSP so the optional runtime key can work.
|
||||
|
||||
Configure the matching Turnstile secret and provider in the installation's
|
||||
GoTrue or Supabase Auth service. Keep the same activation and rollback order as
|
||||
hosted. Leaving the public key unset preserves the existing self-hosted flows.
|
||||
|
||||
## Controlled testing
|
||||
|
||||
Cloudflare publishes dummy site keys and secrets for automated tests. Use the
|
||||
official always-pass or always-fail pair only in a non-production environment.
|
||||
A production secret rejects dummy tokens.
|
||||
|
||||
Turnstile tokens expire after five minutes and are single-use. Every Auth
|
||||
attempt resets the widget, including failed attempts, so a retry obtains a new
|
||||
token.
|
||||
|
||||
## Remaining operational controls
|
||||
|
||||
These settings are not controlled by repository code and remain deployment
|
||||
tasks:
|
||||
|
||||
- Record the current Supabase Auth rate-limit values from Authentication,
|
||||
Rate Limits before activation. Exercise sustained limits only on staging and
|
||||
record the expected `429` threshold.
|
||||
- Enable leaked-password protection in Supabase Auth when the project plan
|
||||
supports it. A read-only Security Advisor check on 2026-08-25 reported
|
||||
`Leaked Password Protection Disabled` for the main project.
|
||||
- Monitor Supabase Auth logs and Turnstile Analytics for challenge failures,
|
||||
Auth `429` responses, recovery or signup volume, and anonymous-user growth.
|
||||
Alerting must not include email addresses, passwords, CAPTCHA tokens, or
|
||||
other credentials.
|
||||
- Anonymous-user retention and deletion require a separate approved retention
|
||||
policy. This integration does not delete Auth users.
|
||||
|
||||
## References
|
||||
|
||||
- [Supabase CAPTCHA protection](https://supabase.com/docs/guides/auth/auth-captcha)
|
||||
- [Supabase Auth rate limits](https://supabase.com/docs/guides/auth/rate-limits)
|
||||
- [Cloudflare explicit rendering](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/)
|
||||
- [Cloudflare CSP requirements](https://developers.cloudflare.com/turnstile/reference/content-security-policy/)
|
||||
- [Cloudflare test keys](https://developers.cloudflare.com/turnstile/troubleshooting/testing/)
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import {
|
||||
captchaTokenOptions,
|
||||
getTurnstileRolloutState,
|
||||
isTurnstileSubmissionBlocked,
|
||||
resolveTurnstileSiteKey,
|
||||
} from '../turnstile'
|
||||
|
||||
const readRepoFile = (file: string) =>
|
||||
readFileSync(path.join(process.cwd(), file), 'utf8')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('Turnstile rollout state', () => {
|
||||
it('keeps Auth available while the public site key is absent', () => {
|
||||
expect(resolveTurnstileSiteKey(undefined)).toBeNull()
|
||||
expect(resolveTurnstileSiteKey('')).toBeNull()
|
||||
expect(resolveTurnstileSiteKey(' ')).toBeNull()
|
||||
expect(getTurnstileRolloutState(undefined)).toBe('disabled')
|
||||
expect(isTurnstileSubmissionBlocked(null, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an unsubstituted Docker sentinel as disabled', () => {
|
||||
const sentinel = '__NEXT_PUBLIC_TURNSTILE_SITE_KEY__'
|
||||
expect(resolveTurnstileSiteKey(sentinel)).toBeNull()
|
||||
expect(getTurnstileRolloutState(sentinel)).toBe('disabled')
|
||||
expect(isTurnstileSubmissionBlocked(null, sentinel)).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed after the client site key is configured', () => {
|
||||
const siteKey = ' public-site-key '
|
||||
expect(resolveTurnstileSiteKey(siteKey)).toBe('public-site-key')
|
||||
expect(getTurnstileRolloutState(siteKey)).toBe('client-enabled')
|
||||
expect(isTurnstileSubmissionBlocked(null, siteKey)).toBe(true)
|
||||
expect(isTurnstileSubmissionBlocked('', siteKey)).toBe(true)
|
||||
expect(isTurnstileSubmissionBlocked('verified-token', siteKey)).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the runtime-substituted environment value at call time', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY', '')
|
||||
expect(getTurnstileRolloutState()).toBe('disabled')
|
||||
|
||||
vi.stubEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY', 'runtime-site-key')
|
||||
expect(getTurnstileRolloutState()).toBe('client-enabled')
|
||||
})
|
||||
|
||||
it('forwards only a non-empty token to Supabase Auth', () => {
|
||||
expect(captchaTokenOptions(null)).toEqual({})
|
||||
expect(captchaTokenOptions(undefined)).toEqual({})
|
||||
expect(captchaTokenOptions(' ')).toEqual({})
|
||||
expect(captchaTokenOptions(' token-value ')).toEqual({
|
||||
captchaToken: 'token-value',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile integration contract', () => {
|
||||
it('protects every public Supabase Auth flow in scope', () => {
|
||||
const login = readRepoFile('app/(auth)/login/login-client.tsx')
|
||||
const register = readRepoFile('app/(auth)/register/page.tsx')
|
||||
const sandbox = readRepoFile('app/sandbox/page.tsx')
|
||||
|
||||
expect(login).toMatch(
|
||||
/signInWithPassword\([\s\S]*?options: captchaTokenOptions\(passwordCaptchaToken\)/,
|
||||
)
|
||||
expect(login).toMatch(
|
||||
/resetPasswordForEmail\([\s\S]*?captchaTokenOptions\(resetCaptchaToken\)/,
|
||||
)
|
||||
expect(login).toContain('action="accounted_login"')
|
||||
expect(login).toContain('action="accounted_password_reset"')
|
||||
|
||||
expect(register).toMatch(
|
||||
/signUp\([\s\S]*?captchaTokenOptions\(captchaToken\)/,
|
||||
)
|
||||
expect(register).toContain('action="accounted_signup"')
|
||||
|
||||
expect(sandbox).toMatch(
|
||||
/signInAnonymously\([\s\S]*?captchaTokenOptions\(captchaToken\)/,
|
||||
)
|
||||
expect(sandbox).toContain('action="accounted_sandbox"')
|
||||
})
|
||||
|
||||
it('keeps the public key, CSP, and Docker runtime contract in sync', () => {
|
||||
const envExample = readRepoFile('.env.example')
|
||||
const dockerEnvExample = readRepoFile('.env.docker.example')
|
||||
const dockerfile = readRepoFile('Dockerfile')
|
||||
const entrypoint = readRepoFile('docker-entrypoint.sh')
|
||||
const nextConfig = readRepoFile('next.config.ts')
|
||||
|
||||
expect(envExample).toContain('NEXT_PUBLIC_TURNSTILE_SITE_KEY=')
|
||||
expect(dockerEnvExample).toContain('NEXT_PUBLIC_TURNSTILE_SITE_KEY=')
|
||||
expect(dockerfile).toContain(
|
||||
'NEXT_PUBLIC_TURNSTILE_SITE_KEY=__NEXT_PUBLIC_TURNSTILE_SITE_KEY__',
|
||||
)
|
||||
expect(entrypoint).toContain('__NEXT_PUBLIC_TURNSTILE_SITE_KEY__')
|
||||
expect(nextConfig).toContain('https://challenges.cloudflare.com')
|
||||
expect(nextConfig).toMatch(/script-src[\s\S]*?turnstileOrigin/)
|
||||
expect(nextConfig).toMatch(/frame-src[\s\S]*?turnstileOrigin/)
|
||||
expect(envExample).not.toContain('TURNSTILE_SECRET_KEY')
|
||||
expect(dockerEnvExample).not.toContain('TURNSTILE_SECRET_KEY')
|
||||
})
|
||||
|
||||
it('ships matching Swedish and English challenge messages', () => {
|
||||
const swedish = JSON.parse(readRepoFile('messages/sv.json')).auth
|
||||
const english = JSON.parse(readRepoFile('messages/en.json')).auth
|
||||
const keys = [
|
||||
'turnstile_checking',
|
||||
'turnstile_required',
|
||||
'turnstile_error',
|
||||
]
|
||||
|
||||
for (const key of keys) {
|
||||
expect(swedish[key]).toBeTypeOf('string')
|
||||
expect(swedish[key]).not.toBe('')
|
||||
expect(english[key]).toBeTypeOf('string')
|
||||
expect(english[key]).not.toBe('')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Client-side Cloudflare Turnstile rollout state for Supabase Auth.
|
||||
*
|
||||
* The public site key is intentionally safe to ship to the browser. The
|
||||
* matching secret stays in Supabase Auth and must never be added to this app.
|
||||
* A generic Docker image contains the sentinel below until its entrypoint
|
||||
* substitutes the operator's runtime value, so an unsubstituted sentinel must
|
||||
* fail open as "disabled" rather than render a broken widget.
|
||||
*/
|
||||
|
||||
export const TURNSTILE_SITE_KEY_ENV = 'NEXT_PUBLIC_TURNSTILE_SITE_KEY'
|
||||
|
||||
const UNCONFIGURED_SITE_KEYS = new Set([
|
||||
'',
|
||||
'__NEXT_PUBLIC_TURNSTILE_SITE_KEY__',
|
||||
])
|
||||
|
||||
export type TurnstileRolloutState = 'disabled' | 'client-enabled'
|
||||
|
||||
export function resolveTurnstileSiteKey(
|
||||
value: string | undefined = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
|
||||
): string | null {
|
||||
const siteKey = value?.trim() ?? ''
|
||||
return UNCONFIGURED_SITE_KEYS.has(siteKey) ? null : siteKey
|
||||
}
|
||||
|
||||
export function getTurnstileRolloutState(
|
||||
value: string | undefined = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
|
||||
): TurnstileRolloutState {
|
||||
return resolveTurnstileSiteKey(value) ? 'client-enabled' : 'disabled'
|
||||
}
|
||||
|
||||
export function captchaTokenOptions(
|
||||
token: string | null | undefined,
|
||||
): { captchaToken?: string } {
|
||||
const captchaToken = token?.trim()
|
||||
return captchaToken ? { captchaToken } : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing CAPTCHA configuration is an intentional rollout state: existing
|
||||
* Auth flows keep working until the public site key is deployed. Once a site
|
||||
* key exists, every protected form fails closed until Turnstile supplies a
|
||||
* token.
|
||||
*/
|
||||
export function isTurnstileSubmissionBlocked(
|
||||
token: string | null | undefined,
|
||||
siteKeyValue: string | undefined = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
|
||||
): boolean {
|
||||
return resolveTurnstileSiteKey(siteKeyValue) !== null && !captchaTokenOptions(token).captchaToken
|
||||
}
|
||||
@@ -288,6 +288,9 @@
|
||||
"method_email_chip": "Email",
|
||||
"show_password": "Show password",
|
||||
"hide_password": "Hide password",
|
||||
"turnstile_checking": "Checking that you are not a robot...",
|
||||
"turnstile_required": "Complete the security check and try again.",
|
||||
"turnstile_error": "The security check could not load. Reload the page and try again.",
|
||||
"login_failed_title": "Sign in failed",
|
||||
"login_failed_bankid": "Could not complete BankID sign in.",
|
||||
"login_invalid_credentials": "Wrong email address or password.",
|
||||
|
||||
@@ -288,6 +288,9 @@
|
||||
"method_email_chip": "E-post",
|
||||
"show_password": "Visa lösenord",
|
||||
"hide_password": "Dölj lösenord",
|
||||
"turnstile_checking": "Verifierar att du inte är en robot...",
|
||||
"turnstile_required": "Slutför säkerhetskontrollen och försök igen.",
|
||||
"turnstile_error": "Säkerhetskontrollen kunde inte laddas. Ladda om sidan och försök igen.",
|
||||
"login_failed_title": "Inloggning misslyckades",
|
||||
"login_failed_bankid": "Kunde inte slutföra BankID-inloggningen.",
|
||||
"login_invalid_credentials": "Fel e-postadress eller lösenord.",
|
||||
|
||||
+9
-2
@@ -12,6 +12,13 @@ const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
|
||||
|
||||
// Hosted builds only widen the CSP when Turnstile is prepared. The generic
|
||||
// Docker image builds with a site-key sentinel, so it always includes this
|
||||
// origin and can safely enable Turnstile later through runtime substitution.
|
||||
const turnstileOrigin = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
? " https://challenges.cloudflare.com"
|
||||
: "";
|
||||
|
||||
// WebSocket origin for Supabase Realtime. Hosted projects are covered by the
|
||||
// wss://*.supabase.co wildcard below, but a SELF-HOSTED Supabase URL is not:
|
||||
// Realtime opens wss://<supabase-host>/realtime/v1/websocket, and WebKit
|
||||
@@ -36,7 +43,7 @@ const cspDirectives = [
|
||||
// re-widen the policy for no benefit and undo the ad-blocker resistance.
|
||||
`connect-src 'self' ${supabaseUrl} ${supabaseWsUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com`,
|
||||
`style-src 'self' 'unsafe-inline' https://*.enablebanking.com`,
|
||||
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com`,
|
||||
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com${turnstileOrigin}`,
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self'",
|
||||
"worker-src 'self' blob:",
|
||||
@@ -47,7 +54,7 @@ const cspDirectives = [
|
||||
// "Det här innehållet har blockerats" in Chrome. Firefox uses PDF.js and
|
||||
// Edge uses its own viewer, so neither hits this. See crbug.com/271452.
|
||||
"object-src 'self' blob:",
|
||||
`frame-src 'self' blob: ${supabaseUrl}`,
|
||||
`frame-src 'self' blob: ${supabaseUrl}${turnstileOrigin}`,
|
||||
"frame-ancestors 'none'",
|
||||
].join("; ");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user