Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pr-build-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/preview-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@

# 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

Check warning on line 24 in Dockerfile

View workflow job for this annotation

GitHub Actions / build-web

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "BETTER_AUTH_URL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
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
Expand All @@ -33,7 +36,10 @@
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

Check warning on line 39 in Dockerfile

View workflow job for this annotation

GitHub Actions / build-web

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "BETTER_AUTH_URL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
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
Expand Down
13 changes: 9 additions & 4 deletions apps/web/src/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -13,12 +14,16 @@ async function guardEmailAbuse(request: Request): Promise<NextResponse | null> {
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
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/app/api/beta/feedback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/app/api/early-access/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ 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 {
const rl = await checkRateLimit(request, "sensitiveWrite")
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()

Expand Down
25 changes: 22 additions & 3 deletions apps/web/src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<TurnstileStatus>("loading")
const [turnstileResetKey, setTurnstileResetKey] = useState(0)
const turnstileRequired = Boolean(process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY)
const [cooldown, setCooldown] = useState(0)

useEffect(() => {
Expand All @@ -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("")

Expand All @@ -55,14 +66,22 @@ 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)
}
} 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)
}
}
Expand Down Expand Up @@ -129,7 +148,7 @@ export default function LoginPage() {
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<TurnstileWidget onToken={setTurnstileToken} />
<TurnstileWidget onToken={setTurnstileToken} onStatus={setTurnstileStatus} resetKey={turnstileResetKey} />
<Button type="submit" className="w-full" disabled={loading || cooldown > 0}>
{loading ? "Sending..." : cooldown > 0 ? `Resend in ${cooldown}s` : "Send OTP"}
</Button>
Expand Down
88 changes: 75 additions & 13 deletions apps/web/src/components/turnstile-widget.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,119 @@
"use client"

import { useEffect, useRef } from "react"
import { useEffect, useRef, useState } from "react"

declare global {
interface Window {
turnstile?: { render: (el: HTMLElement, opts: Record<string, unknown>) => string; reset: (id?: string) => void }
turnstile?: {
render: (el: HTMLElement, opts: Record<string, unknown>) => 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<HTMLDivElement>(null)
const widgetId = useRef<string | undefined>(undefined)
const onTokenRef = useRef(onToken)
const onStatusRef = useRef(onStatus)
const [status, setStatus] = useState<TurnstileStatus>(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
}
}, [siteKey])

if (!siteKey) return null
return <div ref={ref} />
return (
<>
<div ref={ref} />
{status === "blocked" && (
<p className="text-sm text-amber-500">
Bot check failed to load. Disable your ad-blocker or Brave Shields for this site, then reload.
</p>
)}
</>
)
}
29 changes: 27 additions & 2 deletions apps/web/src/lib/turnstile.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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)
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading