From 7c16596beee620550f0f3a32966033a45ac7f125 Mon Sep 17 00:00:00 2001 From: Hansi Lee Date: Tue, 3 Nov 2026 10:58:19 -0800 Subject: [PATCH] feat: harden backend with USC signup gate, security headers, and CI - Add before-user-created auth hook that rejects non-@usc.edu signups - Send CSP (frame-ancestors, base-uri, form-action, object-src), nosniff, referrer, permissions, and HSTS headers on every route - Refresh the cached schedule every 30s instead of 5 minutes - Raise people search limit to 1000 so header counts stay exact - Add GitHub Actions CI: lint, build, tsc, tests Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 29 +++++++++++++++++ next.config.ts | 18 ++++++++++- src/lib/data.ts | 5 +-- supabase/config.toml | 6 ++-- .../20261103000000_usc_signup_gate.sql | 32 +++++++++++++++++++ 5 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 supabase/migrations/20261103000000_usc_signup_gate.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..62b42a4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + env: + # Placeholders so the build can run; CI never talks to Supabase. + NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: sb_publishable_placeholder + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run lint + # Build first: it generates the route types that tsc checks against. + - run: npm run build + - run: npx tsc --noEmit + - run: npm test --if-present diff --git a/next.config.ts b/next.config.ts index a0cf0c7..ae5d58f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,24 @@ import type { NextConfig } from "next"; +// No script-src: a nonce-based CSP would force every page to render +// dynamically. These directives still block clickjacking, hijacking, +// form exfiltration, and plugins. +const securityHeaders = [ + { + key: "Content-Security-Policy", + value: "frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'", + }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { key: "Strict-Transport-Security", value: "max-age=63072000" }, +]; + const nextConfig: NextConfig = { - /* config options here */ cacheComponents: true, + async headers() { + return [{ source: "/:path*", headers: securityHeaders }]; + }, partialPrefetching: true, turbopack: { rules: { diff --git a/src/lib/data.ts b/src/lib/data.ts index 7890534..fb23f83 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -13,7 +13,8 @@ export type ScheduleResult = { events: EventRow[]; error: string | null }; export async function getSchedule(): Promise { "use cache"; cacheTag("events"); - cacheLife("minutes"); + // Organizers edit events in the dashboard; show changes within ~30s. + cacheLife({ stale: 30, revalidate: 30, expire: 3600 }); if (!isSupabaseConfigured) return { events: [], error: null }; @@ -73,6 +74,6 @@ export async function searchProfiles(filters: PeopleFilters) { query = query.order("created_at", { ascending: false }); } - const { data, error } = await query.limit(200); + const { data, error } = await query.limit(1000); return { profiles: data ?? [], error: error?.message ?? null }; } diff --git a/supabase/config.toml b/supabase/config.toml index 21ac97d..0b1617e 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -277,9 +277,9 @@ max_frequency = "5s" # inactivity_timeout = "8h" # This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. -# [auth.hook.before_user_created] -# enabled = true -# uri = "pg-functions://postgres/auth/before-user-created-hook" +[auth.hook.before_user_created] +enabled = true +uri = "pg-functions://postgres/public/hook_before_user_created" # This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. # [auth.hook.custom_access_token] diff --git a/supabase/migrations/20261103000000_usc_signup_gate.sql b/supabase/migrations/20261103000000_usc_signup_gate.sql new file mode 100644 index 0000000..d3e4c34 --- /dev/null +++ b/supabase/migrations/20261103000000_usc_signup_gate.sql @@ -0,0 +1,32 @@ +-- Only @usc.edu addresses can create accounts. Runs as the Supabase Auth +-- "before user created" hook, so it also covers sign-ups made straight +-- against the Auth API (bypassing the app's own form check). +-- Existing accounts are untouched; this only gates new users. +-- +-- Enable on the hosted project: Dashboard -> Authentication -> Hooks -> +-- "Before User Created" -> Postgres function public.hook_before_user_created. + +create or replace function public.hook_before_user_created(event jsonb) +returns jsonb +language plpgsql +stable +set search_path = '' +as $$ +declare + email text := lower(trim(coalesce(event->'user'->>'email', ''))); +begin + if email ~ '^[^@[:space:]]+@usc[.]edu$' then + return '{}'::jsonb; + end if; + + return jsonb_build_object( + 'error', jsonb_build_object( + 'http_code', 403, + 'message', 'Sign up with your @usc.edu email.' + ) + ); +end; +$$; + +grant execute on function public.hook_before_user_created(jsonb) to supabase_auth_admin; +revoke execute on function public.hook_before_user_created(jsonb) from public, anon, authenticated;