# padhle — Security Hardening Plan (fixes first, tested) > **Purpose:** Close every vulnerability found in the 2026-08-19 codebase review, get as close to production-level as possible, and *prove* there are no session leaks with automated tests. > **Runtime:** Local Supabase is started by the user (`npx supabase start` → API on `:54321`, Studio `:54323`). Backend runs on `:3001`. > **Style:** Each phase has small, ordered, independently verifiable steps. Every step has a **Definition of Done** and a **Verify** command so progress is provable. --- ## Phase 0 — Baseline (before touching code) | # | Step | Verify | |---|---|---| | 0.1 | Confirm local Supabase is up: `curl -s http://127.0.0.1:54321/rest/v1/` | HTTP 200 + OpenAPI JSON | | 0.2 | Confirm `backend/.env` has `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `OPENAI_API_KEY` | `grep -oE '^[A-Z_]+=' backend/.env` shows all | | 0.3 | Baseline tests: `cd backend && node --test` and `cd frontend && npm run build` | All pass before changes | --- ## Phase 1 — 🔴 Secret scrub (do first; no code risk) **Problem:** `sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz` (local Supabase **service-role** key, bypasses RLS) is hardcoded in git-tracked files. | # | File | Change | DoD / Verify | |---|---|---|---| | 1.1 | `ANALYSIS.md` (~line 311) | Replace the live secret with `` and a note to never commit keys | `git grep -n sb_secret` → no real key | | 1.2 | `IMPLEMENTATION_PLAN.md` (~line 397) | Same replacement | `git grep -n sb_secret` → no real key | | 1.3 | `backend/scripts/setup-db.js` (line 10) | Remove hardcoded fallback; **throw** if `SUPABASE_SERVICE_ROLE_KEY` is missing | `node --check backend/scripts/setup-db.js` passes; no key string in file | | 1.4 | `backend/.env.example` | Add `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_SERVICE_ROLE_KEY` placeholders | File lists the vars | | 1.5 | Rotate local keys | User runs `npx supabase stop && npx supabase start` (or regenerates), then updates `backend/.env` | Old key string no longer works; app still connects | | 1.6 | Guard | Add a `scripts/check-secrets.mjs` (fails `git grep`-style scan on known patterns) and note to run it pre-commit | Script exits 0 on clean tree, 1 on a planted secret | > Also add a `.gitignore`-style reminder: never add `.env` files. Already covered by existing gitignores. --- ## Phase 2 — 🟠 Auth hardening **Problem A:** `GET /api/auth/me` trusts an unsigned `padhle.user` JSON cookie — identity is forgeable. | # | File | Change | Verify | |---|---|---|---| | 2.1 | `backend/src/routes/auth.js` | `/me` becomes async: read `padhle.token`, call `verifyToken()` from `middleware/supabaseAuth.js`; on success return `{ user: { uid, email } }` from the **verified** token; on failure `401` | `curl` with forged cookie → 401; real login → 200 with correct uid | | 2.2 | `backend/src/middleware/supabaseAuth.js` | Export `verifyToken` already exists — no change needed | — | **Problem B:** Password policy is client-side only (`minLength=6`). | # | File | Change | Verify | |---|---|---|---| | 2.3 | `backend/src/routes/auth.js` | In `signup`, reject `password.length < 6` with 400 before calling Supabase | Test: short password → 400 "Password must be at least 6 characters" | --- ## Phase 3 — 🟠 Input validation (prompt injection vector) **Problem:** `grade`/`subject` are allow-listed, but `chapter` is free-form and is injected verbatim into the AI **system prompt** and stored. Also `POST /api/sessions` validates nothing. | # | File | Change | Verify | |---|---|---|---| | 3.1 | `backend/src/routes/chatValidation.js` | Add `validateChapter(chapter)`: non-empty, ≤ 200 chars, reject `\n`, `\r`, `<`, `>`. Add `validateSessionMeta({grade, subject, chapter})` reusing the allow-lists + chapter check. Export both. | Unit test in `chatValidation.test.js`: valid chapter ok; `"Ignore "` → error; 201-char → error | | 3.2 | `backend/src/routes/chat.js` | Call `validateChapter(chapter)` (when present) in `POST /api/chat` | `curl` with `chapter: "