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
4 changes: 4 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ CURSOR_APP_ID=
# Vercel AI Gateway (model picker on /chat).
AI_GATEWAY_API_KEY=
# AI_GATEWAY_BASE_URL=https://ai-gateway.vercel.sh/v1
# Cloudflare Turnstile (/login). Local dev uses Cloudflare's documented test keys (always pass):
TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
# TURNSTILE_HOSTNAMES=devstack.bid
3 changes: 3 additions & 0 deletions docs/reference/config-env.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
| `OPENAI_FALLBACK` | var | `true` / `false` | `false` | `false` | ให้ planner ถอยไปใช้ OpenAI key ของเซิร์ฟเวอร์เมื่อ HF ล้มหรือไม่ (ปิดไว้เพื่อสงวนเครดิตให้ Orchestrator) |
| `GITHUB_CLIENT_ID` | **secret** | `^[A-Za-z0-9.]{8,80}$` | ไม่มี → ปุ่ม GitHub พากลับ `/login?auth=missing&provider=github` | `wrangler secret put` (ไม่ใส่ใน `vars`) | OAuth App id สำหรับเข้าสู่ระบบ GitHub |
| `GITHUB_CLIENT_SECRET` | **secret** | `^[\w-]{16,200}$` | ไม่มี → เข้าสู่ระบบ GitHub ปิด | `wrangler secret put` (stdin) | OAuth App secret ใช้แลก code ครั้งเดียว ไม่ถูกเก็บใน D1 |
| `TURNSTILE_SECRET_KEY` | **secret** | ข้อความ (Turnstile secret) | ไม่มี → ปิดการตรวจ Turnstile บน /login (ฟอร์มยังใช้ได้) | `wrangler secret put` (stdin) | ใช้เรียก Siteverify สำหรับฟอร์มรหัสผ่าน (login/register); dev ใช้ test secret `1x0000000000000000000000000000000AA` |
| `TURNSTILE_SITE_KEY` | var (public) | `0x…` | ไม่มี → ไม่แสดง widget | `wrangler.jsonc` vars | site key สาธารณะของ widget; dev ใช้ `1x00000000000000000000AA` |
| `TURNSTILE_HOSTNAMES` | var | รายการคั่นด้วย `,` | ว่าง + secret จริง → ปฏิเสธทุกคำขอ | `wrangler.jsonc` vars | hostname ที่ Siteverify ต้องตอบกลับ (prod: `devstack.bid`); ไม่ใส่ localhost ใน prod |
| `GOOGLE_CLIENT_ID` | **secret** | `^[A-Za-z0-9][A-Za-z0-9._-]{7,180}$` | ไม่มี → ปุ่ม Google พากลับ `/login?auth=missing&provider=google` | `wrangler secret put` (stdin) | OAuth client id callback `{origin}/api/auth/google/callback` |
| `GOOGLE_CLIENT_SECRET` | **secret** | `^[\w-]{16,200}$` | ไม่มี → เข้าสู่ระบบ Google ปิด | `wrangler secret put` (stdin) | ใช้แลก code ครั้งเดียว ไม่ถูกเก็บใน D1 |
| `CURSOR_APP_ID` | **secret** | `^app_[A-Za-z0-9]{6,80}$` | ไม่มี → ปุ่ม Cursor พากลับ `/login?auth=missing&provider=cursor` | `wrangler secret put` (stdin) | รหัสแอป Origin ที่เป็นสาธารณะ ไม่เก็บ private key ตรวจใบเสร็จจาก JWKS ของ Origin บน production ตั้งแล้วที่เวอร์ชัน `ffbd53ae-957b-4b48-b411-3a882f14f8ae` (`app_01m4xk8788f7s941zg4cpjxcer`) |
Expand Down
8 changes: 8 additions & 0 deletions src/app/api/auth/password/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { chatRuntime, json } from "@/lib/chat/runtime";
import { findPasswordAccount, savePasswordAccount } from "@/lib/chat/store";
import { clientIp } from "@/lib/server/http";
import { checkRateLimit } from "@/lib/server/rate-limit";
import { verifyTurnstile } from "@/lib/auth/turnstile";

function back(origin: string, secure: boolean, code: string, next: string) {
const headers = new Headers({
Expand Down Expand Up @@ -54,6 +55,13 @@ export async function POST(req: Request) {
const password = acceptPassword(typeof passwordField === "string" ? passwordField : "");
if (!email) return back(url.origin, secure, "invalid", dest);
if (!password) return back(url.origin, secure, "weak", dest);
// Bot check after the cheap input checks (no Siteverify call for malformed posts) and before the
// per-account limiter, so bots cannot burn a real user's per-email attempts.
const human = await verifyTurnstile(form.get("cf-turnstile-response"), req.headers.get("cf-connecting-ip"));
if (!human.ok) {
console.warn(JSON.stringify({ at: "api/auth/password", event: "turnstile-failed", reason: human.reason }));
return back(url.origin, secure, "captcha", dest);
}
// Per-account throttle on top of the per-IP burst, so one email cannot be guessed from many IPs.
const perAccount = await checkRateLimit("NVX_CHAT_LIMITER", `pwacct:${await emailKey(email)}`, 5);
if (!perAccount.ok) return back(url.origin, secure, "error", dest);
Expand Down
5 changes: 3 additions & 2 deletions src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import type { Metadata } from "next";
import { LoginForm } from "@/components/login-form";
import { safeNext } from "@/lib/auth/github-policy";
import { isAuthProvider } from "@/lib/auth/oauth-flow";
import { turnstileSiteKey } from "@/lib/auth/turnstile";

export const metadata: Metadata = {
title: "Sign in",
description: "Sign in to DevStack with Cursor, Google, GitHub, or a DevStack password so chat history stays on your account.",
};

const NOTICES = new Set(["missing", "error", "denied", "invalid", "taken", "weak"]);
const NOTICES = new Set(["missing", "error", "denied", "invalid", "taken", "weak", "captcha"]);

type LoginQuery = { next?: string | string[]; auth?: string | string[]; provider?: string | string[] };

Expand All @@ -26,5 +27,5 @@ async function LoginFromQuery({ searchParams }: { searchParams: Promise<LoginQue
const nextRaw = typeof query.next === "string" ? query.next : null;
const authRaw = typeof query.auth === "string" ? query.auth : "";
const providerRaw = typeof query.provider === "string" ? query.provider : "";
return <LoginForm next={safeNext(nextRaw)} auth={NOTICES.has(authRaw) ? authRaw : ""} provider={isAuthProvider(providerRaw) ? providerRaw : ""} />;
return <LoginForm next={safeNext(nextRaw)} auth={NOTICES.has(authRaw) ? authRaw : ""} provider={isAuthProvider(providerRaw) ? providerRaw : ""} turnstileSiteKey={turnstileSiteKey()} />;
}
16 changes: 14 additions & 2 deletions src/components/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { useEffect, useState } from "react";
import { buttonClasses, Field, Input, Spinner } from "@/components/ui";
import type { DictKey } from "@/lib/i18n";
import { useLang } from "./prefs";
import { TurnstileWidget } from "./turnstile-widget";

/** Must match TURNSTILE_ACTION in src/lib/auth/turnstile.ts (checked by Siteverify). */
export const TURNSTILE_ACTION = "password_auth";

const PROVIDERS = [
{ id: "cursor", href: "cursor", label: "auth.withCursor" },
Expand All @@ -21,6 +25,7 @@ function notice(auth: string, provider: string): { title: DictKey; body: DictKey
if (auth === "denied") return { title: "auth.deniedTitle", body: "auth.denied" };
if (auth === "invalid") return { title: "auth.invalidTitle", body: "auth.invalid" };
if (auth === "taken") return { title: "auth.takenTitle", body: "auth.taken" };
if (auth === "captcha") return { title: "auth.captchaTitle", body: "auth.captcha" };
if (auth === "weak") return { title: "auth.weakTitle", body: "auth.weak" };
return null;
}
Expand All @@ -36,8 +41,9 @@ export function loginFormProblem(email: string, password: string): { field: "ema
}

/** Four sign-in methods: Cursor, Google, GitHub, and a DevStack email account. */
export function LoginForm({ next, auth, provider }: { next: string; auth: string; provider: string }) {
const { t } = useLang();
export function LoginForm({ next, auth, provider, turnstileSiteKey = "" }: { next: string; auth: string; provider: string; turnstileSiteKey?: string }) {
const { t, lang } = useLang();
const [captcha, setCaptcha] = useState<string | null>(null);
const alert = notice(auth, provider);
const [pending, setPending] = useState<string | null>(null);
const [problem, setProblem] = useState<DictKey | null>(null);
Expand Down Expand Up @@ -92,6 +98,11 @@ export function LoginForm({ next, auth, provider }: { next: string; auth: string
form.querySelector<HTMLInputElement>(`input[name="${issue.field}"]`)?.focus();
return;
}
if (turnstileSiteKey && !captcha) {
e.preventDefault();
setProblem("auth.captcha");
return;
}
setProblem(null);
const submitter = (e.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null;
setPending(submitter?.value ?? "login");
Expand All @@ -112,6 +123,7 @@ export function LoginForm({ next, auth, provider }: { next: string; auth: string
<Field label={t("auth.password")} hint={t("auth.passwordHint")}>
{(props) => <Input {...props} name="password" type="password" autoComplete="current-password" required minLength={10} maxLength={200} />}
</Field>
{turnstileSiteKey && <TurnstileWidget siteKey={turnstileSiteKey} action={TURNSTILE_ACTION} lang={lang} onToken={setCaptcha} />}
<div className="flex flex-col gap-2 sm:flex-row">
<button type="submit" name="action" value="login" className={buttonClasses("primary", "md", "w-full")} aria-disabled={pending !== null}>
{pending === "login" && <Spinner size="md" />}
Expand Down
80 changes: 80 additions & 0 deletions src/components/turnstile-widget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use client";

import { useEffect, useRef } from "react";

type TurnstileApi = {
render: (el: HTMLElement, opts: Record<string, unknown>) => string;
reset: (id: string) => void;
remove: (id: string) => void;
};
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}

const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

function loadScript(): Promise<void> {
if (window.turnstile) return Promise.resolve();
let s = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT_SRC}"]`);
if (!s) {
s = document.createElement("script");
s.src = SCRIPT_SRC;
s.async = true;
s.defer = true;
document.head.appendChild(s);
}
return new Promise((resolve, reject) => {
s!.addEventListener("load", () => resolve(), { once: true });
s!.addEventListener("error", () => reject(new Error("turnstile script failed")), { once: true });
});
}

/**
* Cloudflare Turnstile widget (explicit render). It adds the hidden `cf-turnstile-response` input to
* the surrounding form. Tokens are single-use: the widget is reset when the page is restored from
* the back/forward cache, so a retry always carries a fresh token.
*/
export function TurnstileWidget({ siteKey, action, lang, onToken }: { siteKey: string; action: string; lang: string; onToken: (token: string | null) => void }) {
const box = useRef<HTMLDivElement>(null);
const tokenCb = useRef(onToken);
useEffect(() => {
tokenCb.current = onToken;
}, [onToken]);

useEffect(() => {
let id: string | null = null;
let cancelled = false;
loadScript()
.then(() => {
if (cancelled || !box.current || !window.turnstile) return;
id = window.turnstile.render(box.current, {
sitekey: siteKey,
action,
theme: document.documentElement.classList.contains("dark") ? "dark" : "light",
size: "flexible",
language: lang === "th" ? "th" : "en",
appearance: "always",
callback: (t: string) => tokenCb.current(t),
"expired-callback": () => tokenCb.current(null),
"error-callback": () => tokenCb.current(null),
});
})
.catch(() => tokenCb.current(null));
const onShow = (e: PageTransitionEvent) => {
if (e.persisted && id && window.turnstile) {
tokenCb.current(null);
window.turnstile.reset(id);
}
};
window.addEventListener("pageshow", onShow);
return () => {
cancelled = true;
window.removeEventListener("pageshow", onShow);
if (id && window.turnstile) window.turnstile.remove(id);
};
}, [siteKey, action, lang]);

return <div ref={box} data-testid="turnstile" className="min-h-[65px] w-full" />;
}
73 changes: 73 additions & 0 deletions src/lib/auth/turnstile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import "server-only";

/**
* Cloudflare Turnstile server-side Siteverify for the password sign-in / sign-up form.
* Docs: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
*
* - Off when TURNSTILE_SECRET_KEY is unset (local dev without keys): the form keeps working.
* - When on, requires success, the expected action and an allowed hostname (TURNSTILE_HOSTNAMES).
* - Uses an idempotency_key so a retried call after a network error is not treated as a replay.
* - Cloudflare's documented test secrets (1x…/2x…/3x…) return a dummy hostname/action, so those
* two checks are skipped only for test secrets; a real secret always gets the full check.
*/

export const TURNSTILE_ACTION = "password_auth";
export const SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const TEST_SECRET = /^[123]x0{30,}A{2}$/;

export type TurnstileResult = { ok: true; skipped?: boolean } | { ok: false; reason: string };

export function isTestSecret(secret: string): boolean {
return TEST_SECRET.test(secret);
}

export function turnstileConfig(source: Record<string, string | undefined> = process.env) {
const secret = source.TURNSTILE_SECRET_KEY?.trim() || "";
const siteKey = source.TURNSTILE_SITE_KEY?.trim() || "";
const hostnames = (source.TURNSTILE_HOSTNAMES ?? "").split(",").map((h) => h.trim().toLowerCase()).filter(Boolean);
return { secret, siteKey, hostnames, enabled: secret.length > 0 };
}

/** Public site key for the widget, or "" when Turnstile is off (no secret configured). */
export function turnstileSiteKey(source: Record<string, string | undefined> = process.env): string {
const c = turnstileConfig(source);
return c.enabled ? c.siteKey : "";
}

export async function verifyTurnstile(
token: unknown,
remoteIp: string | null,
opts: { source?: Record<string, string | undefined>; fetchImpl?: typeof fetch; idempotencyKey?: string } = {}
): Promise<TurnstileResult> {
const cfg = turnstileConfig(opts.source);
if (!cfg.enabled) return { ok: true, skipped: true };
if (typeof token !== "string" || token.length === 0 || token.length > 2048) return { ok: false, reason: "missing-token" };
const test = isTestSecret(cfg.secret);
if (!test && cfg.hostnames.length === 0) return { ok: false, reason: "no-hostnames" };
const doFetch = opts.fetchImpl ?? fetch;
const idem = opts.idempotencyKey ?? crypto.randomUUID();
const body = new URLSearchParams({ secret: cfg.secret, response: token, idempotency_key: idem });
if (remoteIp) body.set("remoteip", remoteIp);
let data: { success?: boolean; action?: string; hostname?: string; "error-codes"?: string[] } | null = null;
for (let attempt = 0; attempt < 2 && !data; attempt++) {
try {
const r = await doFetch(SITEVERIFY_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
signal: AbortSignal.timeout(8_000),
});
if (r.ok) data = await r.json();
else if (r.status < 500) return { ok: false, reason: `http-${r.status}` };
} catch {
// network error or timeout: retry once with the same idempotency key
}
}
if (!data) return { ok: false, reason: "unavailable" };
if (data.success !== true) return { ok: false, reason: (data["error-codes"] ?? ["failed"]).join(",") };
if (!test) {
if (data.action !== TURNSTILE_ACTION) return { ok: false, reason: "action-mismatch" };
if (!data.hostname || !cfg.hostnames.includes(data.hostname.toLowerCase())) return { ok: false, reason: "hostname-mismatch" };
}
return { ok: true };
}
2 changes: 2 additions & 0 deletions src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,8 @@ const dict = {
"auth.invalid": { en: "Check the email and password, or create a DevStack account.", th: "ตรวจอีเมลกับรหัสผ่าน หรือสร้างบัญชี DevStack" },
"auth.takenTitle": { en: "That email already has a DevStack account", th: "อีเมลนี้มีบัญชี DevStack แล้ว" },
"auth.taken": { en: "Sign in with the password for this email. It is not linked to Google, GitHub or Cursor.", th: "เข้าสู่ระบบด้วยรหัสผ่านของอีเมลนี้ บัญชีนี้ไม่เชื่อมกับ Google, GitHub หรือ Cursor" },
"auth.captchaTitle": { en: "Verification failed", th: "การยืนยันไม่ผ่าน" },
"auth.captcha": { en: "We couldn't confirm you're human. Wait for the check to finish, then try again.", th: "ยืนยันไม่ได้ว่าคุณเป็นมนุษย์ รอให้การตรวจสอบเสร็จ แล้วลองอีกครั้ง" },
"auth.weakTitle": { en: "Use a longer password", th: "ใช้รหัสผ่านที่ยาวกว่านี้" },
"auth.emailInvalid": { en: "Enter a valid email address.", th: "กรอกอีเมลให้ถูกต้อง" },
"auth.weak": { en: "Passwords need 10 to 200 characters, with no control characters.", th: "รหัสผ่านต้องมี 10 ถึง 200 ตัวอักษร และไม่มีอักขระควบคุม" },
Expand Down
5 changes: 3 additions & 2 deletions src/lib/security-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function contentSecurityPolicy(dev = false): string {
return [
"default-src 'self'",
// Cloudflare Web Analytics is injected on the devstack.bid zone (beacon script + RUM endpoint).
`script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com${dev ? " 'unsafe-eval'" : ""}`,
`script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com https://challenges.cloudflare.com${dev ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://avatars.githubusercontent.com https://*.googleusercontent.com",
"font-src 'self' data:",
Expand All @@ -21,7 +21,8 @@ export function contentSecurityPolicy(dev = false): string {
"frame-ancestors 'none'",
// Home live example (Sandpack, Apache-2.0): its bundler runs in a cross-origin
// *.codesandbox.io iframe in the visitor's browser. The only third-party frame we allow.
"frame-src https://*.codesandbox.io",
// Cloudflare Turnstile widget on /login (script + challenge iframe).
"frame-src https://*.codesandbox.io https://challenges.cloudflare.com",
"worker-src 'self' blob:",
"manifest-src 'self'",
].join("; ");
Expand Down
2 changes: 1 addition & 1 deletion tests/production.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe("security headers", () => {
});
it("CSP allows exactly one third-party frame origin: the Sandpack bundler on home (plus its Open in CodeSandbox form)", () => {
const frame = contentSecurityPolicy(false).split("; ").find((d) => d.startsWith("frame-src"));
expect(frame).toBe("frame-src https://*.codesandbox.io");
expect(frame).toBe("frame-src https://*.codesandbox.io https://challenges.cloudflare.com");
expect(contentSecurityPolicy(false)).toContain("form-action 'self' https://codesandbox.io");
expect(contentSecurityPolicy(false)).not.toMatch(/Cross-Origin-Embedder/);
});
Expand Down
Loading
Loading