From 4bdb2147bdfcadcdb8b97a2766ab132eddc157d5 Mon Sep 17 00:00:00 2001 From: B SIMAK AHMED <1NZ24CS030@newhorizonindia.edu> Date: Tue, 22 Sep 2026 19:56:15 +0530 Subject: [PATCH 01/39] security: 20260922230000_complete_security_hardening.sql --- ...0922230000_complete_security_hardening.sql | 471 ++++++++++++++++++ 1 file changed, 471 insertions(+) create mode 100644 supabase/migrations/20260922230000_complete_security_hardening.sql diff --git a/supabase/migrations/20260922230000_complete_security_hardening.sql b/supabase/migrations/20260922230000_complete_security_hardening.sql new file mode 100644 index 0000000..1430102 --- /dev/null +++ b/supabase/migrations/20260922230000_complete_security_hardening.sql @@ -0,0 +1,471 @@ +-- Complete security hardening follow-up. +-- +-- Keeps production and migration history aligned and closes the remaining +-- repository-audit findings: +-- * atomic, per-IP login lockouts instead of one global lockout +-- * atomic contact-form rate limiting +-- * explicit removal of the legacy guest-comment INSERT policy +-- * mandatory AAL2 for admin data/storage writes and admin sessions +-- * owner-only recovery-code storage +-- +-- All SECURITY DEFINER functions below pin search_path and are restricted +-- to the roles that actually need to call them. + +-- --------------------------------------------------------------------------- +-- Login lockout: scope the lockout to a hashed client IP. +-- --------------------------------------------------------------------------- + +ALTER TABLE public.login_lockouts + DROP CONSTRAINT IF EXISTS login_lockouts_lockout_key_check; + +ALTER TABLE public.login_lockouts + ADD CONSTRAINT login_lockouts_lockout_key_check + CHECK (lockout_key ~ '^admin_password:[0-9a-f]{64}$'); + +DELETE FROM public.login_lockouts +WHERE lockout_key = 'admin_password'; + +DROP FUNCTION IF EXISTS public.login_lockout_get(); +DROP FUNCTION IF EXISTS public.login_lockout_record_failure(); +DROP FUNCTION IF EXISTS public.login_lockout_clear(); + +CREATE OR REPLACE FUNCTION public.login_lockout_get(p_lockout_key text) +RETURNS TABLE (failed_attempts smallint, locked_until timestamptz) +LANGUAGE sql +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT failed_attempts, locked_until + FROM public.login_lockouts + WHERE lockout_key = p_lockout_key; +$$; + +CREATE OR REPLACE FUNCTION public.login_lockout_record_failure(p_lockout_key text) +RETURNS TABLE (failed_attempts smallint, locked_until timestamptz) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF p_lockout_key IS NULL OR p_lockout_key !~ '^admin_password:[0-9a-f]{64}$' THEN + RAISE EXCEPTION 'Invalid lockout key'; + END IF; + + PERFORM pg_advisory_xact_lock(hashtext(p_lockout_key)); + + INSERT INTO public.login_lockouts (lockout_key, failed_attempts, locked_until, updated_at) + VALUES (p_lockout_key, 1, NULL, now()) + ON CONFLICT (lockout_key) DO UPDATE + SET + failed_attempts = CASE + WHEN public.login_lockouts.locked_until IS NOT NULL + AND public.login_lockouts.locked_until > now() + THEN public.login_lockouts.failed_attempts + WHEN public.login_lockouts.locked_until IS NOT NULL + AND public.login_lockouts.locked_until <= now() + THEN 1 + ELSE LEAST(public.login_lockouts.failed_attempts + 1, 4) + END, + locked_until = CASE + WHEN public.login_lockouts.locked_until IS NOT NULL + AND public.login_lockouts.locked_until > now() + THEN public.login_lockouts.locked_until + WHEN public.login_lockouts.locked_until IS NOT NULL + AND public.login_lockouts.locked_until <= now() + THEN NULL + WHEN public.login_lockouts.failed_attempts + 1 >= 4 + THEN now() + interval '30 minutes' + ELSE NULL + END, + updated_at = now() + RETURNING public.login_lockouts.failed_attempts, public.login_lockouts.locked_until; +END; +$$; + +CREATE OR REPLACE FUNCTION public.login_lockout_clear(p_lockout_key text) +RETURNS void +LANGUAGE sql +SECURITY DEFINER +SET search_path = public +AS $$ + DELETE FROM public.login_lockouts + WHERE lockout_key = p_lockout_key; +$$; + +REVOKE ALL ON FUNCTION public.login_lockout_get(text) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.login_lockout_record_failure(text) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.login_lockout_clear(text) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.login_lockout_get(text) TO service_role; +GRANT EXECUTE ON FUNCTION public.login_lockout_record_failure(text) TO service_role; +GRANT EXECUTE ON FUNCTION public.login_lockout_clear(text) TO service_role; + +-- --------------------------------------------------------------------------- +-- Guest comments: remove any legacy direct-insert policy explicitly. +-- --------------------------------------------------------------------------- + +REVOKE INSERT ON public.guest_comments FROM PUBLIC, anon, authenticated; +DROP POLICY IF EXISTS guest_comments_insert ON public.guest_comments; + +-- --------------------------------------------------------------------------- +-- Contact form: serialize rate-limit check + insert for each hashed IP. +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.submit_contact_message( + p_name text, + p_email text, + p_subject text, + p_message text, + p_ip_hash text +) +RETURNS uuid +LANGUAGE plpgsql +VOLATILE +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_id uuid; + v_count integer; +BEGIN + IF p_name IS NULL OR char_length(trim(p_name)) NOT BETWEEN 1 AND 200 + OR p_email IS NULL OR char_length(trim(p_email)) NOT BETWEEN 3 AND 320 + OR p_subject IS NOT NULL AND char_length(trim(p_subject)) > 200 + OR p_message IS NULL OR char_length(trim(p_message)) NOT BETWEEN 1 AND 5000 + OR p_ip_hash IS NULL OR char_length(p_ip_hash) <> 64 + THEN + RAISE EXCEPTION 'Invalid contact submission'; + END IF; + + PERFORM pg_advisory_xact_lock(hashtext(p_ip_hash)); + + SELECT count(*)::integer + INTO v_count + FROM public.contact_messages + WHERE ip_hash = p_ip_hash + AND created_at >= now() - interval '1 hour'; + + IF v_count >= 5 THEN + RAISE EXCEPTION 'Contact rate limit exceeded'; + END IF; + + INSERT INTO public.contact_messages (name, email, subject, message, ip_hash) + VALUES ( + trim(p_name), + trim(p_email), + NULLIF(trim(p_subject), ''), + trim(p_message), + p_ip_hash + ) + RETURNING id INTO v_id; + + RETURN v_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.submit_contact_message(text, text, text, text, text) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.submit_contact_message(text, text, text, text, text) + TO service_role; + +-- --------------------------------------------------------------------------- +-- Admin authorization: every admin write/session-management operation +-- requires an AAL2 session, not merely an enrolled factor. +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.current_user_admin_write_allowed() +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public, auth +AS $$ + SELECT + auth.uid() IS NOT NULL + AND public.has_role(auth.uid(), 'admin') + AND public.session_is_aal2() +$$; + +REVOKE ALL ON FUNCTION public.current_user_admin_write_allowed() FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.current_user_admin_write_allowed() TO authenticated; + +-- Sensitive admin reads are AAL2-gated as well. +DROP POLICY IF EXISTS contact_messages_admin_read ON public.contact_messages; +CREATE POLICY contact_messages_admin_read ON public.contact_messages + FOR SELECT + TO authenticated + USING (public.current_user_admin_write_allowed()); + +DROP POLICY IF EXISTS contact_attachments_admin_read ON public.contact_attachments; +CREATE POLICY contact_attachments_admin_read ON public.contact_attachments + FOR SELECT + TO authenticated + USING (public.current_user_admin_write_allowed()); + +-- Site media is public content, but its management surface is admin-only. +DROP POLICY IF EXISTS "Admins can list site media" ON storage.objects; +CREATE POLICY "Admins can list site media" +ON storage.objects +FOR SELECT +TO authenticated +USING ( + bucket_id = 'site-media' + AND public.current_user_admin_write_allowed() +); + +DROP POLICY IF EXISTS "Admins can upload site media" ON storage.objects; +CREATE POLICY "Admins can upload site media" +ON storage.objects +FOR INSERT +TO authenticated +WITH CHECK ( + bucket_id = 'site-media' + AND public.current_user_admin_write_allowed() +); + +DROP POLICY IF EXISTS "Admins can update site media" ON storage.objects; +CREATE POLICY "Admins can update site media" +ON storage.objects +FOR UPDATE +TO authenticated +USING ( + bucket_id = 'site-media' + AND public.current_user_admin_write_allowed() +) +WITH CHECK ( + bucket_id = 'site-media' + AND public.current_user_admin_write_allowed() +); + +DROP POLICY IF EXISTS "Admins can delete site media" ON storage.objects; +CREATE POLICY "Admins can delete site media" +ON storage.objects +FOR DELETE +TO authenticated +USING ( + bucket_id = 'site-media' + AND public.current_user_admin_write_allowed() +); + +DROP POLICY IF EXISTS admin_audit_select ON public.admin_audit_log; +CREATE POLICY admin_audit_select ON public.admin_audit_log +FOR SELECT TO authenticated +USING (public.current_user_admin_write_allowed()); + +-- --------------------------------------------------------------------------- +-- Admin device/session functions: require AAL2. +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.admin_register_session( + p_session_id uuid, + p_user_agent text, + p_device_type text, + p_browser text, + p_operating_system text +) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + IF (select auth.uid()) IS NULL + OR (select auth.jwt() ->> 'aal') <> 'aal2' + OR NOT EXISTS ( + SELECT 1 + FROM public.user_roles + WHERE user_id = (select auth.uid()) + AND role = 'admin' + ) + THEN + RETURN false; + END IF; + + INSERT INTO public.admin_sessions ( + session_id, user_id, user_agent, device_type, browser, operating_system, last_seen_at + ) + VALUES ( + p_session_id, + (select auth.uid()), + left(coalesce(p_user_agent, ''), 1000), + left(coalesce(p_device_type, 'Desktop'), 50), + left(coalesce(p_browser, 'Unknown'), 100), + left(coalesce(p_operating_system, 'Unknown'), 100), + now() + ) + ON CONFLICT (session_id) DO UPDATE + SET user_agent = excluded.user_agent, + device_type = excluded.device_type, + browser = excluded.browser, + operating_system = excluded.operating_system, + last_seen_at = now() + WHERE public.admin_sessions.user_id = (select auth.uid()); + + RETURN true; +END; +$$; + +CREATE OR REPLACE FUNCTION public.admin_touch_session(p_session_id uuid) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + touched integer; +BEGIN + UPDATE public.admin_sessions + SET last_seen_at = now() + WHERE session_id = p_session_id + AND user_id = (select auth.uid()) + AND (select auth.jwt() ->> 'aal') = 'aal2' + AND EXISTS ( + SELECT 1 + FROM public.user_roles + WHERE user_id = (select auth.uid()) + AND role = 'admin' + ) + AND EXISTS ( + SELECT 1 + FROM auth.sessions + WHERE id = p_session_id + AND user_id = (select auth.uid()) + ); + + GET DIAGNOSTICS touched = ROW_COUNT; + RETURN touched = 1; +END; +$$; + +CREATE OR REPLACE FUNCTION public.admin_list_sessions() +RETURNS TABLE ( + session_id uuid, + device_type text, + browser text, + operating_system text, + user_agent text, + created_at timestamptz, + last_seen_at timestamptz, + is_current boolean +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + current_session_id uuid; +BEGIN + IF (select auth.uid()) IS NULL + OR (select auth.jwt() ->> 'aal') <> 'aal2' + OR NOT EXISTS ( + SELECT 1 + FROM public.user_roles + WHERE user_id = (select auth.uid()) + AND role = 'admin' + ) + THEN + RETURN; + END IF; + + BEGIN + current_session_id := ((select auth.jwt() ->> 'session_id')::uuid); + EXCEPTION WHEN others THEN + current_session_id := null; + END; + + DELETE FROM public.admin_sessions a + WHERE a.user_id = (select auth.uid()) + AND NOT EXISTS ( + SELECT 1 + FROM auth.sessions s + WHERE s.id = a.session_id + AND s.user_id = a.user_id + ); + + RETURN QUERY + SELECT a.session_id, a.device_type, a.browser, a.operating_system, + a.user_agent, s.created_at, a.last_seen_at, + a.session_id = current_session_id + FROM public.admin_sessions a + JOIN auth.sessions s + ON s.id = a.session_id + AND s.user_id = a.user_id + WHERE a.user_id = (select auth.uid()) + ORDER BY a.last_seen_at DESC; +END; +$$; + +CREATE OR REPLACE FUNCTION public.admin_revoke_session(p_session_id uuid) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + current_session_id uuid; + removed integer; +BEGIN + IF (select auth.uid()) IS NULL + OR (select auth.jwt() ->> 'aal') <> 'aal2' + OR NOT EXISTS ( + SELECT 1 + FROM public.user_roles + WHERE user_id = (select auth.uid()) + AND role = 'admin' + ) + THEN + RETURN false; + END IF; + + current_session_id := ((select auth.jwt() ->> 'session_id')::uuid); + + IF p_session_id = current_session_id THEN + RAISE EXCEPTION 'The current session cannot be revoked from another-device logout.'; + END IF; + + DELETE FROM auth.sessions + WHERE id = p_session_id + AND user_id = (select auth.uid()); + + GET DIAGNOSTICS removed = ROW_COUNT; + + IF removed = 1 THEN + DELETE FROM public.admin_sessions + WHERE session_id = p_session_id + AND user_id = (select auth.uid()); + RETURN true; + END IF; + + RETURN false; +END; +$$; + +-- Recovery-code storage is user-private. The application still writes only +-- hashes, never plaintext codes. +CREATE TABLE IF NOT EXISTS public.recovery_codes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + code_hash text NOT NULL CHECK (char_length(code_hash) = 64), + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.recovery_codes ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.recovery_codes FROM anon; +GRANT SELECT, INSERT, DELETE ON public.recovery_codes TO authenticated; + +DROP POLICY IF EXISTS recovery_codes_self_select ON public.recovery_codes; +CREATE POLICY recovery_codes_self_select ON public.recovery_codes +FOR SELECT TO authenticated +USING (user_id = auth.uid()); + +DROP POLICY IF EXISTS recovery_codes_self_insert ON public.recovery_codes; +CREATE POLICY recovery_codes_self_insert ON public.recovery_codes +FOR INSERT TO authenticated +WITH CHECK (user_id = auth.uid()); + +DROP POLICY IF EXISTS recovery_codes_self_delete ON public.recovery_codes; +CREATE POLICY recovery_codes_self_delete ON public.recovery_codes +FOR DELETE TO authenticated +USING (user_id = auth.uid()); + +CREATE INDEX IF NOT EXISTS recovery_codes_user_id_idx + ON public.recovery_codes (user_id); From ee1b4651cc44394f2207b0382e5be5c2fd9a48cb Mon Sep 17 00:00:00 2001 From: B SIMAK AHMED <1NZ24CS030@newhorizonindia.edu> Date: Tue, 22 Sep 2026 19:56:17 +0530 Subject: [PATCH 02/39] security: security.yml --- .github/workflows/security.yml | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/security.yml diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..3ae1e27 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,35 @@ +name: Security checks + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + application: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + + - name: Dependency audit + run: npm audit --audit-level=high From ee17c30d6b6dbc6a7f3dc3dc552f22fe3a54a173 Mon Sep 17 00:00:00 2001 From: B SIMAK AHMED <1NZ24CS030@newhorizonindia.edu> Date: Tue, 22 Sep 2026 19:56:28 +0530 Subject: [PATCH 03/39] security: remove hard-coded admin identity and scope login lockout by IP --- src/lib/login.functions.ts | 117 ++++++++++++++++++++----------------- 1 file changed, 65 insertions(+), 52 deletions(-) diff --git a/src/lib/login.functions.ts b/src/lib/login.functions.ts index d18de75..b8f95f3 100644 --- a/src/lib/login.functions.ts +++ b/src/lib/login.functions.ts @@ -1,8 +1,8 @@ import { createServerFn } from "@tanstack/react-start"; +import { createHash } from "node:crypto"; import { verifyTurnstile, getClientIp } from "@/lib/turnstile.server"; -const ADMIN_EMAIL = "simakahmed002@gmail.com"; -const LOCKOUT_MINUTES = 10; +const LOCKOUT_MINUTES = 30; const MAX_FAILED_ATTEMPTS = 4; type LockoutRow = { @@ -25,11 +25,19 @@ function validateInput(data: unknown) { throw new Error("Invalid login request."); } - return { email: email.trim(), password, turnstileToken }; + return { email: email.trim().toLowerCase(), password, turnstileToken }; } -async function getLockout(supabaseAdmin: any): Promise { - const { data, error } = await supabaseAdmin.rpc("login_lockout_get"); +function getLockoutKey(ip: string): string { + const salt = process.env.LOGIN_IP_HASH_SALT ?? "codeforge-login"; + const hash = createHash("sha256").update(`${salt}:${ip}`).digest("hex"); + return `admin_password:${hash}`; +} + +async function getLockout(supabaseAdmin: any, lockoutKey: string): Promise { + const { data, error } = await supabaseAdmin.rpc("login_lockout_get", { + p_lockout_key: lockoutKey, + }); if (error) { console.error("[login] lockout lookup failed", error); throw new Error("Unable to prepare login."); @@ -37,6 +45,17 @@ async function getLockout(supabaseAdmin: any): Promise { return (data?.[0] ?? null) as LockoutRow | null; } +async function recordFailure(supabaseAdmin: any, lockoutKey: string) { + const { data, error } = await supabaseAdmin.rpc("login_lockout_record_failure", { + p_lockout_key: lockoutKey, + }); + if (error) { + console.error("[login] failed-attempt recording failed", error); + throw new Error("Unable to record login attempt."); + } + return (data?.[0] ?? null) as LockoutRow | null; +} + export const getLoginLockout = createServerFn({ method: "POST" }) .inputValidator((data: unknown) => { if (!data || typeof data !== "object") throw new Error("Invalid login request."); @@ -44,7 +63,8 @@ export const getLoginLockout = createServerFn({ method: "POST" }) }) .handler(async () => { const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - const row = await getLockout(supabaseAdmin); + const lockoutKey = getLockoutKey(getClientIp()); + const row = await getLockout(supabaseAdmin, lockoutKey); const lockedUntil = row?.locked_until ?? null; const locked = !!lockedUntil && new Date(lockedUntil).getTime() > Date.now(); @@ -60,16 +80,17 @@ export const getLoginLockout = createServerFn({ method: "POST" }) export const loginWithProtection = createServerFn({ method: "POST" }) .inputValidator(validateInput) .handler(async ({ data }) => { - const email = data.email.toLowerCase(); + const ip = getClientIp(); + const lockoutKey = getLockoutKey(ip); // Turnstile must pass before an authentication attempt can affect lockout state. - const verified = await verifyTurnstile(data.turnstileToken, getClientIp()); + const verified = await verifyTurnstile(data.turnstileToken, ip, "login"); if (!verified) { throw new Error("Human verification failed. Please try again."); } const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - const lockRow = await getLockout(supabaseAdmin); + const lockRow = await getLockout(supabaseAdmin, lockoutKey); if (lockRow?.locked_until && new Date(lockRow.locked_until).getTime() > Date.now()) { return { @@ -81,38 +102,6 @@ export const loginWithProtection = createServerFn({ method: "POST" }) }; } - const isAdminEmail = email === ADMIN_EMAIL.toLowerCase(); - - // This remains an admin-only login endpoint. Non-admin accounts are not authenticated - // or granted a session; their attempts are recorded by the existing login lockout. - if (!isAdminEmail) { - const { data: failureRows, error: failureError } = await supabaseAdmin.rpc( - "login_lockout_record_failure", - ); - if (failureError) { - console.error("[login] non-admin attempt recording failed", failureError); - throw new Error("Unable to record login attempt."); - } - - const failureRow = (failureRows?.[0] ?? null) as LockoutRow | null; - const lockedUntil = failureRow?.locked_until ?? null; - const locked = !!lockedUntil && new Date(lockedUntil).getTime() > Date.now(); - - return { - success: false, - reason: locked ? ("locked" as const) : ("not_admin" as const), - locked, - lockedUntil: locked ? lockedUntil : null, - failedAttempts: failureRow?.failed_attempts ?? 1, - error: locked - ? `Security lockout. Please try again after ${LOCKOUT_MINUTES} minutes.` - : "Only admins can access this page", - }; - } - - // Password authentication must use the same Supabase project/key pair as the browser client. - // Prefer the VITE_* values because they are the canonical public Supabase configuration - // used by src/integrations/supabase/client.ts, with the server-side names as fallback. const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; const publishableKey = process.env.VITE_SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_PUBLISHABLE_KEY; @@ -130,20 +119,12 @@ export const loginWithProtection = createServerFn({ method: "POST" }) }); const { data: authData, error: authError } = await userClient.auth.signInWithPassword({ - email, + email: data.email, password: data.password, }); if (authError || !authData.user || !authData.session) { - const { data: failureRows, error: failureError } = await supabaseAdmin.rpc( - "login_lockout_record_failure", - ); - if (failureError) { - console.error("[login] failed-attempt recording failed", failureError); - throw new Error("Unable to record login attempt."); - } - - const failureRow = (failureRows?.[0] ?? null) as LockoutRow | null; + const failureRow = await recordFailure(supabaseAdmin, lockoutKey); const lockedUntil = failureRow?.locked_until ?? null; const locked = !!lockedUntil && new Date(lockedUntil).getTime() > Date.now(); @@ -163,7 +144,39 @@ export const loginWithProtection = createServerFn({ method: "POST" }) }; } - const { error: clearError } = await supabaseAdmin.rpc("login_lockout_clear"); + // Authorization is role-based, not based on a hard-coded email address. + const { data: roleRow, error: roleError } = await supabaseAdmin + .from("user_roles") + .select("role") + .eq("user_id", authData.user.id) + .eq("role", "admin") + .maybeSingle(); + + if (roleError || !roleRow) { + await userClient.auth.signOut(); + const failureRow = await recordFailure(supabaseAdmin, lockoutKey); + const lockedUntil = failureRow?.locked_until ?? null; + const locked = !!lockedUntil && new Date(lockedUntil).getTime() > Date.now(); + + return { + success: false, + reason: locked ? ("locked" as const) : ("invalid_credentials" as const), + locked, + lockedUntil: locked ? lockedUntil : null, + failedAttempts: failureRow?.failed_attempts ?? 1, + remainingAttempts: Math.max( + 0, + MAX_FAILED_ATTEMPTS - (failureRow?.failed_attempts ?? 1), + ), + error: locked + ? `Security lockout. Please try again after ${LOCKOUT_MINUTES} minutes.` + : "Invalid username or password.", + }; + } + + const { error: clearError } = await supabaseAdmin.rpc("login_lockout_clear", { + p_lockout_key: lockoutKey, + }); if (clearError) console.error("[login] lockout clear failed", clearError); return { From 82b75f63631f0ce08e864d80be6cd3a2180a2676 Mon Sep 17 00:00:00 2001 From: B SIMAK AHMED <1NZ24CS030@newhorizonindia.edu> Date: Tue, 22 Sep 2026 19:56:47 +0530 Subject: [PATCH 04/39] security: require MFA setup after admin password login --- src/routes/auth.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/routes/auth.tsx b/src/routes/auth.tsx index d963ea3..8916b8d 100644 --- a/src/routes/auth.tsx +++ b/src/routes/auth.tsx @@ -2,10 +2,10 @@ * /auth — Admin Login * * Flow after successful password login: - * - Only the configured admin email can continue - * - Four invalid admin password or non-admin login attempts trigger a 30-minute server-side lockout + * - Authorization is role-based on the server; no admin email is hard-coded here + * - Four invalid login attempts per client IP trigger a 30-minute server-side lockout * - If the admin has a verified TOTP factor → /mfa-verify - * - Otherwise → /dashboard + * - Otherwise → /mfa-setup */ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; @@ -19,8 +19,6 @@ import { getLoginLockout, loginWithProtection } from "@/lib/login.functions"; import { supabase } from "@/integrations/supabase/client"; import { toast } from "sonner"; -const ADMIN_EMAIL = "simakahmed002@gmail.com"; - function formatRemaining(seconds: number) { const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; @@ -143,8 +141,8 @@ function AuthPage() { // Must verify MFA before accessing dashboard. navigate({ to: "/mfa-verify" }); } else { - // No MFA enrolled → go straight to dashboard. - navigate({ to: "/dashboard" }); + // MFA is mandatory for the admin account. + navigate({ to: "/mfa-setup" }); } } catch (error) { resetTurnstile(); @@ -206,6 +204,7 @@ function AuthPage() { {!isLocked && ( )} From 00ba51bb3f16497558b7ff736f55b943bfe7532b Mon Sep 17 00:00:00 2001 From: B SIMAK AHMED <1NZ24CS030@newhorizonindia.edu> Date: Tue, 22 Sep 2026 19:56:53 +0530 Subject: [PATCH 05/39] security: support mandatory MFA setup flow --- src/components/SecurityTab.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/SecurityTab.tsx b/src/components/SecurityTab.tsx index 72a88ed..7ef6065 100644 --- a/src/components/SecurityTab.tsx +++ b/src/components/SecurityTab.tsx @@ -34,7 +34,7 @@ type Step = // ─── Main Component ─────────────────────────────────────────────────────────── -export function SecurityTab() { +export function SecurityTab({ requiredSetup = false, onRequiredComplete }: { requiredSetup?: boolean; onRequiredComplete?: () => void }) { const { user } = useAuth(); const [mfaEnabled, setMfaEnabled] = useState(false); const [factorId, setFactorId] = useState(""); @@ -305,7 +305,7 @@ export function SecurityTab() {
{mfaEnabled ? ( - <> + {!requiredSetup && <> - + } ) : ( From d598eea885678e9f61c35aae4fa20899061d7ff4 Mon Sep 17 00:00:00 2001 From: B SIMAK AHMED <1NZ24CS030@newhorizonindia.edu> Date: Tue, 22 Sep 2026 19:56:59 +0530 Subject: [PATCH 06/39] security: add mandatory MFA setup route --- src/routes/mfa-setup.tsx | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/routes/mfa-setup.tsx diff --git a/src/routes/mfa-setup.tsx b/src/routes/mfa-setup.tsx new file mode 100644 index 0000000..a5183ed --- /dev/null +++ b/src/routes/mfa-setup.tsx @@ -0,0 +1,57 @@ +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; +import { ShieldCheck } from "lucide-react"; +import { SiteLayout } from "@/components/SiteLayout"; +import { SecurityTab } from "@/components/SecurityTab"; +import { supabase } from "@/integrations/supabase/client"; + +export const Route = createFileRoute("/mfa-setup")({ + ssr: false, + head: () => ({ + meta: [ + { title: "Set Up Two-Step Verification — CodeForge" }, + { name: "robots", content: "noindex, nofollow" }, + ], + }), + beforeLoad: async () => { + const { data: userData, error } = await supabase.auth.getUser(); + if (error || !userData.user) throw redirect({ to: "/auth" }); + + const { data: role } = await supabase + .from("user_roles") + .select("role") + .eq("user_id", userData.user.id) + .eq("role", "admin") + .maybeSingle(); + + if (!role) throw redirect({ to: "/auth" }); + + const { data: factors } = await supabase.auth.mfa.listFactors(); + if ((factors?.totp ?? []).length > 0) throw redirect({ to: "/dashboard" }); + + return { user: userData.user }; + }, + component: MfaSetupPage, +}); + +function MfaSetupPage() { + const navigate = useNavigate(); + + return ( + +
+
+
+ +
+

Set Up Two-Step Verification

+

+ CodeForge requires TOTP two-step verification before the administration dashboard + can be used. Scan the QR code, verify the six-digit code, and save your recovery codes. +

+
+ + navigate({ to: "/dashboard" })} /> +
+
+ ); +} From de584aa73110b06ece5e2cfa4bcfcc2e6fa08407 Mon Sep 17 00:00:00 2001 From: B SIMAK AHMED <1NZ24CS030@newhorizonindia.edu> Date: Tue, 22 Sep 2026 19:57:17 +0530 Subject: [PATCH 07/39] security: require MFA on all authenticated admin routes --- src/routes/_authenticated/route.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/routes/_authenticated/route.tsx b/src/routes/_authenticated/route.tsx index e7744e0..5357f80 100644 --- a/src/routes/_authenticated/route.tsx +++ b/src/routes/_authenticated/route.tsx @@ -3,12 +3,11 @@ * * Logic: * 1. No session → /auth - * 2. Has verified TOTP factor BUT session is only AAL1 → /mfa-verify - * (handles the case where the user has MFA enrolled but hasn't verified this session) - * 3. Everything else → allow through + * 2. No verified TOTP factor → /mfa-setup + * 3. Has verified TOTP factor BUT session is only AAL1 → /mfa-verify + * 4. AAL2 session → allow through * - * MFA is OPTIONAL. If a user has no TOTP factor, they pass through normally. - * MFA is enforced only when the user HAS enrolled it. + * MFA is mandatory for the admin account. * * Device-session monitoring: * - Registers the current admin browser/device. @@ -36,7 +35,11 @@ export const Route = createFileRoute("/_authenticated")({ const { data: factors } = await supabase.auth.mfa.listFactors(); const hasVerifiedFactor = (factors?.totp ?? []).length > 0; - if (hasVerifiedFactor) { + if (!hasVerifiedFactor) { + throw redirect({ to: "/mfa-setup" }); + } + + { // User has MFA — enforce AAL2 for this session const { data: aalData } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); if (aalData?.currentLevel !== "aal2") { @@ -44,7 +47,6 @@ export const Route = createFileRoute("/_authenticated")({ throw redirect({ to: "/mfa-verify" }); } } - // No MFA enrolled → pass through normally return { user: userData.user }; }, From 2f005785fcc4069d6701b357f5b239e9bb7c8bfd Mon Sep 17 00:00:00 2001 From: B SIMAK AHMED <1NZ24CS030@newhorizonindia.edu> Date: Tue, 22 Sep 2026 19:57:23 +0530 Subject: [PATCH 08/39] security: validate Turnstile action and hostname --- src/lib/turnstile.server.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/lib/turnstile.server.ts b/src/lib/turnstile.server.ts index b56b9f0..2dde7cf 100644 --- a/src/lib/turnstile.server.ts +++ b/src/lib/turnstile.server.ts @@ -7,7 +7,7 @@ export function getClientIp(): string { return req?.headers?.get("x-real-ip") ?? "unknown"; } -export async function verifyTurnstile(token: string, ip: string): Promise { +export async function verifyTurnstile(token: string, ip: string, expectedAction: string): Promise { const secret = process.env.TURNSTILE_SECRET_KEY; if (!secret) { // Turnstile not configured — fail closed in production, but don't hard-block local dev. @@ -15,13 +15,30 @@ export async function verifyTurnstile(token: string, ip: string): Promise 2048) return false; + const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ secret, response: token, remoteip: ip }), }); - const data = (await res.json()) as { success: boolean }; - return data.success === true; + if (!res.ok) return false; + const data = (await res.json()) as { + success: boolean; + hostname?: string; + action?: string; + }; + if (!data.success || data.action !== expectedAction) return false; + + const configuredHostnames = (process.env.TURNSTILE_HOSTNAMES ?? "codeforgedev.vercel.app") + .split(",") + .map((hostname) => hostname.trim().toLowerCase()) + .filter(Boolean); + const requestHost = getRequest()?.headers?.get("host")?.split(":")[0]?.toLowerCase(); + if (!data.hostname || !configuredHostnames.includes(data.hostname.toLowerCase())) return false; + if (requestHost && !configuredHostnames.includes(requestHost)) return false; + + return true; } catch (e) { console.error("[turnstile] verification failed", e); return false; From 47f794eb5ef174cdb6abc106d90c48158186d9cc Mon Sep 17 00:00:00 2001 From: B SIMAK AHMED <1NZ24CS030@newhorizonindia.edu> Date: Tue, 22 Sep 2026 19:57:29 +0530 Subject: [PATCH 09/39] security: bind Turnstile widgets to explicit actions --- src/components/TurnstileWidget.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/TurnstileWidget.tsx b/src/components/TurnstileWidget.tsx index 8d96c3e..b09dfc5 100644 --- a/src/components/TurnstileWidget.tsx +++ b/src/components/TurnstileWidget.tsx @@ -28,7 +28,7 @@ function loadTurnstileScript(): Promise { return scriptPromise; } -export function TurnstileWidget({ onToken }: { onToken: (token: string) => void }) { +export function TurnstileWidget({ action = "default", onToken }: { action?: string; onToken: (token: string) => void }) { const id = useId(); const containerRef = useRef(null); const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined; @@ -43,6 +43,7 @@ export function TurnstileWidget({ onToken }: { onToken: (token: string) => void widgetId = window.turnstile.render(containerRef.current, { sitekey: siteKey, theme: "dark", + action, callback: (token: string) => onToken(token), "expired-callback": () => onToken(""), "error-callback": () => onToken(""), @@ -53,7 +54,7 @@ export function TurnstileWidget({ onToken }: { onToken: (token: string) => void cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [siteKey]); + }, [siteKey, action, onToken]); if (!siteKey) return null; From 07f2c9b79a4b0ecf7b30b57dedeed384830a43b7 Mon Sep 17 00:00:00 2001 From: B SIMAK AHMED <1NZ24CS030@newhorizonindia.edu> Date: Tue, 22 Sep 2026 19:57:36 +0530 Subject: [PATCH 10/39] security: make contact rate limiting atomic --- src/lib/contact.functions.ts | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/lib/contact.functions.ts b/src/lib/contact.functions.ts index ab38c23..a85aa74 100644 --- a/src/lib/contact.functions.ts +++ b/src/lib/contact.functions.ts @@ -106,19 +106,9 @@ export const submitContactForm = createServerFn({ method: "POST" }) const ipHash = hashIp(ip); // -- spam check ----------------------------------------------------------- - const turnstileOk = await verifyTurnstile(turnstileToken, ip); + const turnstileOk = await verifyTurnstile(turnstileToken, ip, "contact"); if (!turnstileOk) throw new Error("Spam check failed. Please try again."); - // -- rate limit ------------------------------------------------------------- - const { count } = await supabaseAdmin - .from("contact_messages") - .select("*", { count: "exact", head: true }) - .eq("ip_hash", ipHash) - .gte("created_at", new Date(Date.now() - RATE_LIMIT_WINDOW_MS).toISOString()); - if ((count ?? 0) >= RATE_LIMIT_MAX) { - throw new Error("You've sent several messages recently. Please try again later."); - } - // -- attachments ------------------------------------------------------------ const files = data .getAll("attachments") @@ -143,12 +133,18 @@ export const submitContactForm = createServerFn({ method: "POST" }) } // -- insert message row ------------------------------------------------------- - const { data: inserted, error: insertError } = await supabaseAdmin - .from("contact_messages") - .insert({ name, email, subject: subject || null, message, ip_hash: ipHash }) - .select("id") - .single(); + const { data: insertedRows, error: insertError } = await supabaseAdmin.rpc("submit_contact_message", { + p_name: name, + p_email: email, + p_subject: subject || null, + p_message: message, + p_ip_hash: ipHash, + }); + const inserted = insertedRows ? { id: insertedRows as string } : null; if (insertError || !inserted) { + if (insertError?.message.includes("rate limit")) { + throw new Error("You've sent several messages recently. Please try again later."); + } console.error("[contact] insert failed", insertError); throw new Error("Couldn't send your message. Please try again."); } From 45e21eba29c34689803e2942620b95480d91cda0 Mon Sep 17 00:00:00 2001 From: B SIMAK AHMED <1NZ24CS030@newhorizonindia.edu> Date: Tue, 22 Sep 2026 19:57:40 +0530 Subject: [PATCH 11/39] chore: remove obsolete contact rate limit constants --- src/lib/contact.functions.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib/contact.functions.ts b/src/lib/contact.functions.ts index a85aa74..e08e1eb 100644 --- a/src/lib/contact.functions.ts +++ b/src/lib/contact.functions.ts @@ -10,8 +10,6 @@ const MAX_FILES = 3; const MAX_FILE_BYTES = 8 * 1024 * 1024; // 8MB per file const MAX_TOTAL_BYTES = 20 * 1024 * 1024; // 20MB per submission const MIN_SUBMIT_MS = 2000; // reject if the form was "filled" faster than this -const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour -const RATE_LIMIT_MAX = 5; // max submissions per IP per window const ALLOWED_TYPES: Record boolean }> = { "image/jpeg": { From 6da19263798a6d3008bcf514f575d5aeb5a90d3b Mon Sep 17 00:00:00 2001 From: B SIMAK AHMED <1NZ24CS030@newhorizonindia.edu> Date: Tue, 22 Sep 2026 19:57:46 +0530 Subject: [PATCH 12/39] security: bind contact Turnstile action --- src/routes/contact.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/contact.tsx b/src/routes/contact.tsx index ebcc94d..e131d31 100644 --- a/src/routes/contact.tsx +++ b/src/routes/contact.tsx @@ -244,7 +244,7 @@ function ContactPage() {
- +