From d13c7dcf5169d5620e17a7a35869cd484c3c042e Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Sat, 19 Sep 2026 12:41:51 +0530 Subject: [PATCH 1/2] fix(web): resolve Turnstile OTP lockout from missing site-key build arg Server now enforces bot check only when both secret and public site key are present (fail-open with warning otherwise), widget detects blocked scripts and supports reset, login shows actionable errors. Fixes #113 --- Dockerfile | 6 ++ apps/web/src/app/api/auth/[...all]/route.ts | 13 ++- apps/web/src/app/api/beta/feedback/route.ts | 6 +- apps/web/src/app/api/early-access/route.ts | 6 +- apps/web/src/app/login/page.tsx | 25 +++++- apps/web/src/components/turnstile-widget.tsx | 88 +++++++++++++++++--- apps/web/src/lib/turnstile.ts | 29 ++++++- docker-compose.yml | 6 ++ 8 files changed, 151 insertions(+), 28 deletions(-) diff --git a/Dockerfile b/Dockerfile index 857dc59..4a4c770 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,9 @@ WORKDIR /app # Build args for public env vars (read at build time by Next.js) ARG NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000 ARG BETTER_AUTH_URL=http://localhost:3000 +ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY= +ARG NEXT_PUBLIC_APP_URL= +ARG NEXT_PUBLIC_SENTRY_DSN= COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules @@ -34,6 +37,9 @@ ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production ENV NEXT_PUBLIC_BETTER_AUTH_URL=$NEXT_PUBLIC_BETTER_AUTH_URL ENV BETTER_AUTH_URL=$BETTER_AUTH_URL +ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY +ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL +ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN # Build Next.js app RUN pnpm --filter @crosscode/web build diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts index 75ff758..c8aa3e9 100644 --- a/apps/web/src/app/api/auth/[...all]/route.ts +++ b/apps/web/src/app/api/auth/[...all]/route.ts @@ -2,7 +2,8 @@ import { NextResponse } from "next/server" import { auth } from "@/lib/auth" import { toNextJsHandler } from "better-auth/next-js" import { checkRateLimit, getClientIp, rateLimitedResponse } from "@/lib/rate-limit" -import { isTurnstileConfigured, verifyTurnstileToken } from "@/lib/turnstile" +import { isTurnstileEnforced, verifyTurnstileToken } from "@/lib/turnstile" +import { logger } from "@/lib/logger" const { GET: authGET, POST: authPOST } = toNextJsHandler(auth) @@ -13,12 +14,16 @@ async function guardEmailAbuse(request: Request): Promise { const rl = await checkRateLimit(request, "authEmail") if (!rl.success) return rateLimitedResponse(rl) - if (isTurnstileConfigured()) { + if (isTurnstileEnforced()) { const url = new URL(request.url) const needsCaptcha = request.method === "POST" && EMAIL_SENDING_PATHS.some((p) => url.pathname.endsWith(p)) if (needsCaptcha) { - const ok = await verifyTurnstileToken(request.headers.get("x-turnstile-token"), getClientIp(request)) - if (!ok) return NextResponse.json({ error: "Bot verification failed" }, { status: 403 }) + const token = request.headers.get("x-turnstile-token") + const ok = await verifyTurnstileToken(token, getClientIp(request)) + if (!ok) { + logger.warn("Auth", `Blocked email send without valid bot token: ${url.pathname} hasToken=${Boolean(token)} ip=${getClientIp(request)}`) + return NextResponse.json({ error: "Bot verification failed. Complete the captcha or disable your ad-blocker and retry." }, { status: 403 }) + } } } return null diff --git a/apps/web/src/app/api/beta/feedback/route.ts b/apps/web/src/app/api/beta/feedback/route.ts index de567d7..cb74d65 100644 --- a/apps/web/src/app/api/beta/feedback/route.ts +++ b/apps/web/src/app/api/beta/feedback/route.ts @@ -5,7 +5,7 @@ import { NextResponse } from "next/server" import crypto from "crypto" import { logger } from "@/lib/logger" import { checkRateLimit, getClientIp, rateLimitedResponse } from "@/lib/rate-limit" -import { turnstileTokenFromBody, verifyTurnstileToken } from "@/lib/turnstile" +import { isTurnstileEnforced, turnstileTokenFromBody, verifyTurnstileToken } from "@/lib/turnstile" const FLOWS = [ "onboarding", @@ -49,8 +49,8 @@ export async function POST(request: Request) { if (!rl.success) return rateLimitedResponse(rl) const body = await request.json() - if (!(await verifyTurnstileToken(turnstileTokenFromBody(body), getClientIp(request)))) { - return NextResponse.json({ error: "Bot verification failed" }, { status: 403 }) + if (isTurnstileEnforced() && !(await verifyTurnstileToken(turnstileTokenFromBody(body), getClientIp(request)))) { + return NextResponse.json({ error: "Bot verification failed. Complete the captcha or disable your ad-blocker and retry." }, { status: 403 }) } const email = body?.email?.trim().toLowerCase() const appVersion = body?.appVersion?.trim().slice(0, 50) diff --git a/apps/web/src/app/api/early-access/route.ts b/apps/web/src/app/api/early-access/route.ts index 1be126e..5387a72 100644 --- a/apps/web/src/app/api/early-access/route.ts +++ b/apps/web/src/app/api/early-access/route.ts @@ -5,7 +5,7 @@ import { NextResponse } from "next/server" import crypto from "crypto" import { logger } from "@/lib/logger" import { checkRateLimit, getClientIp, rateLimitedResponse } from "@/lib/rate-limit" -import { turnstileTokenFromBody, verifyTurnstileToken } from "@/lib/turnstile" +import { isTurnstileEnforced, turnstileTokenFromBody, verifyTurnstileToken } from "@/lib/turnstile" export async function POST(request: Request) { try { @@ -13,8 +13,8 @@ export async function POST(request: Request) { if (!rl.success) return rateLimitedResponse(rl) const body = await request.json() - if (!(await verifyTurnstileToken(turnstileTokenFromBody(body), getClientIp(request)))) { - return NextResponse.json({ error: "Bot verification failed" }, { status: 403 }) + if (isTurnstileEnforced() && !(await verifyTurnstileToken(turnstileTokenFromBody(body), getClientIp(request)))) { + return NextResponse.json({ error: "Bot verification failed. Complete the captcha or disable your ad-blocker and retry." }, { status: 403 }) } const email = body?.email?.trim().toLowerCase() diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index 2875064..fc8e692 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { authClient } from "@/lib/auth-client" -import { TurnstileWidget } from "@/components/turnstile-widget" +import { TurnstileWidget, type TurnstileStatus } from "@/components/turnstile-widget" import { GlyphMatrix } from "@/components/ui/glyph-matrix" import { BrandLogo } from "@/components/brand-logo" import { LoaderCircle } from "lucide-react" @@ -20,6 +20,9 @@ export default function LoginPage() { const [error, setError] = useState("") const [checkingSession, setCheckingSession] = useState(true) const [turnstileToken, setTurnstileToken] = useState("") + const [turnstileStatus, setTurnstileStatus] = useState("loading") + const [turnstileResetKey, setTurnstileResetKey] = useState(0) + const turnstileRequired = Boolean(process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY) const [cooldown, setCooldown] = useState(0) useEffect(() => { @@ -43,6 +46,14 @@ export default function LoginPage() { const handleSendOTP = async (e: React.FormEvent) => { e.preventDefault() if (cooldown > 0) return + if (turnstileRequired && !turnstileToken) { + setError( + turnstileStatus === "blocked" + ? "Bot check is blocked. Disable your ad-blocker or Brave Shields for this site, then reload." + : "Please complete the bot check and try again." + ) + return + } setLoading(true) setError("") @@ -55,7 +66,12 @@ export default function LoginPage() { }) if (error) { - setError(error.message || "Failed to send OTP") + const msg = error.message || "Failed to send OTP" + setError( + /bot|captcha|turnstile/i.test(msg) + ? "Bot verification failed. Complete the captcha or disable your ad-blocker and retry." + : msg + ) } else { setStep("otp") setCooldown(60) @@ -63,6 +79,9 @@ export default function LoginPage() { } catch { setError("Failed to send OTP") } finally { + // Tokens are single-use: force a fresh challenge for the next attempt. + setTurnstileToken("") + setTurnstileResetKey((k) => k + 1) setLoading(false) } } @@ -129,7 +148,7 @@ export default function LoginPage() { /> {error &&

{error}

} - + diff --git a/apps/web/src/components/turnstile-widget.tsx b/apps/web/src/components/turnstile-widget.tsx index 16c2750..567be23 100644 --- a/apps/web/src/components/turnstile-widget.tsx +++ b/apps/web/src/components/turnstile-widget.tsx @@ -1,51 +1,104 @@ "use client" -import { useEffect, useRef } from "react" +import { useEffect, useRef, useState } from "react" declare global { interface Window { - turnstile?: { render: (el: HTMLElement, opts: Record) => string; reset: (id?: string) => void } + turnstile?: { + render: (el: HTMLElement, opts: Record) => string + reset: (id?: string) => void + remove?: (id?: string) => void + } onTurnstileLoad?: () => void } } -export function TurnstileWidget({ onToken }: { onToken: (token: string) => void }) { +export type TurnstileStatus = "unconfigured" | "loading" | "ready" | "blocked" + +export function TurnstileWidget({ + onToken, + onStatus, + resetKey = 0, +}: { + onToken: (token: string) => void + onStatus?: (status: TurnstileStatus) => void + resetKey?: number +}) { const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY const ref = useRef(null) + const widgetId = useRef(undefined) const onTokenRef = useRef(onToken) + const onStatusRef = useRef(onStatus) + const [status, setStatus] = useState(siteKey ? "loading" : "unconfigured") useEffect(() => { onTokenRef.current = onToken }, [onToken]) + useEffect(() => { + onStatusRef.current = onStatus + }, [onStatus]) + + useEffect(() => { + onStatusRef.current?.(status) + }, [status]) + + // Reset the widget when the parent bumps resetKey (tokens are single-use). + useEffect(() => { + if (resetKey > 0 && widgetId.current && window.turnstile) { + try { + window.turnstile.reset(widgetId.current) + } catch {} + onTokenRef.current("") + } + }, [resetKey]) + useEffect(() => { if (!siteKey || !ref.current) return - let widgetId: string | undefined let cancelled = false const render = () => { - if (cancelled || !ref.current || !window.turnstile || widgetId) return - widgetId = window.turnstile.render(ref.current, { - sitekey: siteKey, - callback: (token: string) => onTokenRef.current(token), - "expired-callback": () => onTokenRef.current(""), - "error-callback": () => onTokenRef.current(""), - }) + if (cancelled || !ref.current || !window.turnstile || widgetId.current) return + try { + widgetId.current = window.turnstile.render(ref.current, { + sitekey: siteKey, + callback: (token: string) => onTokenRef.current(token), + "expired-callback": () => onTokenRef.current(""), + "error-callback": () => { + onTokenRef.current("") + setStatus("blocked") + }, + }) + setStatus("ready") + } catch { + setStatus("blocked") + } } if (window.turnstile) { render() } else { window.onTurnstileLoad = render - const script = document.querySelector('script[data-turnstile]') as HTMLScriptElement | null + const script = document.querySelector("script[data-turnstile]") as HTMLScriptElement | null if (!script) { const s = document.createElement("script") s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad" s.async = true s.defer = true s.dataset.turnstile = "true" + s.onerror = () => { + if (!cancelled) setStatus("blocked") + } document.head.appendChild(s) } + // Ad-blockers silently swallow the script: detect it never arriving. + const timer = setTimeout(() => { + if (!cancelled && !window.turnstile) setStatus("blocked") + }, 8000) + return () => { + cancelled = true + clearTimeout(timer) + } } return () => { cancelled = true @@ -53,5 +106,14 @@ export function TurnstileWidget({ onToken }: { onToken: (token: string) => void }, [siteKey]) if (!siteKey) return null - return
+ return ( + <> +
+ {status === "blocked" && ( +

+ Bot check failed to load. Disable your ad-blocker or Brave Shields for this site, then reload. +

+ )} + + ) } diff --git a/apps/web/src/lib/turnstile.ts b/apps/web/src/lib/turnstile.ts index 6e9c1f4..fa88a93 100644 --- a/apps/web/src/lib/turnstile.ts +++ b/apps/web/src/lib/turnstile.ts @@ -1,16 +1,38 @@ import { logger } from "./logger" +export function turnstileSiteKey(): string | null { + const key = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY + return key && key.length > 0 ? key : null +} + export function isTurnstileConfigured(): boolean { return Boolean(process.env.TURNSTILE_SECRET_KEY) } +// Enforcement requires BOTH keys. NEXT_PUBLIC_* is baked at build time, so a +// production image built without the site-key ARG would otherwise lock out all +// logins with 403s. Fail open (rate limits still apply) and warn loudly. +export function isTurnstileEnforced(): boolean { + const enforced = Boolean(process.env.TURNSTILE_SECRET_KEY && turnstileSiteKey()) + if (process.env.TURNSTILE_SECRET_KEY && !turnstileSiteKey()) { + logger.warn( + "Turnstile", + "TURNSTILE_SECRET_KEY is set but NEXT_PUBLIC_TURNSTILE_SITE_KEY is missing - skipping verification (fail-open). Rebuild with the site-key build arg." + ) + } + return enforced +} + export async function verifyTurnstileToken(token: string | null | undefined, remoteIp?: string): Promise { const secret = process.env.TURNSTILE_SECRET_KEY if (!secret) { logger.warn("Turnstile", "TURNSTILE_SECRET_KEY not set - skipping verification (fail-open). Set it in production.") return true } - if (!token) return false + if (!token) { + logger.warn("Turnstile", "Verification failed: missing token (widget blocked, expired, or site key not baked into build)") + return false + } try { const ctrl = new AbortController() const timeout = setTimeout(() => ctrl.abort(), 5000) @@ -21,7 +43,10 @@ export async function verifyTurnstileToken(token: string | null | undefined, rem signal: ctrl.signal, }) clearTimeout(timeout) - const data = (await res.json()) as { success?: boolean } + const data = (await res.json()) as { success?: boolean; "error-codes"?: string[] } + if (data.success !== true) { + logger.warn("Turnstile", `Verification rejected: ${data["error-codes"]?.join(",") || "unknown"}`) + } return data.success === true } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) diff --git a/docker-compose.yml b/docker-compose.yml index 64bdeb9..e5a9475 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,12 @@ services: build: context: . dockerfile: Dockerfile + args: + - NEXT_PUBLIC_BETTER_AUTH_URL=${NEXT_PUBLIC_BETTER_AUTH_URL:-http://localhost:3000} + - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000} + - NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-} + - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-} + - NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN:-} container_name: crosscode-web restart: always ports: From 415e17689251e7b1e26915e4d9eeebdef892f020 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Sat, 19 Sep 2026 12:44:58 +0530 Subject: [PATCH 2/2] ci(web): pass Turnstile site key as build arg --- .github/workflows/deploy.yml | 1 + .github/workflows/pr-build-check.yml | 1 + .github/workflows/preview-deploy.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8a43d43..108469a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -49,6 +49,7 @@ jobs: build-args: | NEXT_PUBLIC_BETTER_AUTH_URL=https://crosscode.site BETTER_AUTH_URL=https://crosscode.site + NEXT_PUBLIC_TURNSTILE_SITE_KEY=${{ vars.NEXT_PUBLIC_TURNSTILE_SITE_KEY }} deploy: needs: build-and-push diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index d9af7c5..4bda24c 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -60,6 +60,7 @@ jobs: build-args: | NEXT_PUBLIC_BETTER_AUTH_URL=https://crosscode.site BETTER_AUTH_URL=https://crosscode.site + NEXT_PUBLIC_TURNSTILE_SITE_KEY=${{ vars.NEXT_PUBLIC_TURNSTILE_SITE_KEY }} build-tunnel: needs: changes diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index d4e82d2..38c3be7 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -70,6 +70,7 @@ jobs: build-args: | NEXT_PUBLIC_BETTER_AUTH_URL=https://crosscode.site BETTER_AUTH_URL=https://crosscode.site + NEXT_PUBLIC_TURNSTILE_SITE_KEY=${{ vars.NEXT_PUBLIC_TURNSTILE_SITE_KEY }} - name: Deploy web preview uses: appleboy/ssh-action@v1