From b134f87326e61546adc75a55de657bb1e63d83af Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Sat, 3 Oct 2026 12:49:53 +0200 Subject: [PATCH] Phase 1: token schemas and anon RLS check script tokens.ts validates link tokens (32 hex chars) before any DB query. check:rls proves the anon key cannot read or insert on any table, and fails loudly if the migration has not been applied. Co-Authored-By: Claude Opus 5.5 --- package.json | 3 ++- scripts/check-rls.mjs | 50 ++++++++++++++++++++++++++++++++++++++++++ src/lib/tokens.test.ts | 27 +++++++++++++++++++++++ src/lib/tokens.ts | 11 ++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 scripts/check-rls.mjs create mode 100644 src/lib/tokens.test.ts create mode 100644 src/lib/tokens.ts diff --git a/package.json b/package.json index e5092b8..0148123 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "eslint", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "check:rls": "node --env-file=.env.local scripts/check-rls.mjs" }, "dependencies": { "@supabase/ssr": "^0.12.7", diff --git a/scripts/check-rls.mjs b/scripts/check-rls.mjs new file mode 100644 index 0000000..e393e6b --- /dev/null +++ b/scripts/check-rls.mjs @@ -0,0 +1,50 @@ +// Proves the anon key has no table access (CLAUDE.md rule 2). +// Run with: npm run check:rls +import { createClient } from "@supabase/supabase-js"; + +const TABLES = ["events", "teams", "games", "criteria", "judges", "scores", "adjustments"]; + +const url = process.env.NEXT_PUBLIC_SUPABASE_URL; +const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; +const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; +if (!url || !anonKey || !serviceKey) { + console.error("Missing Supabase env vars. Fill in .env.local first."); + process.exit(1); +} + +const options = { auth: { persistSession: false, autoRefreshToken: false } }; +const anon = createClient(url, anonKey, options); +const service = createClient(url, serviceKey, options); + +let failed = false; +const fail = (message) => { + failed = true; + console.error(` FAIL ${message}`); +}; + +for (const table of TABLES) { + console.log(table); + + // Not a HEAD request: those report no error when the table is missing. + const real = await service.from(table).select("id", { count: "exact" }).limit(1); + if (real.error) { + fail(`table not reachable with the service key (${real.error.code}). Has the migration been applied?`); + continue; + } + + const read = await anon.from(table).select("id").limit(1); + if (read.data?.length) fail("anon can read rows"); + else if (real.count === 0) console.log(" ok anon reads nothing (table is empty, so this is a weak check)"); + else console.log(` ok anon reads nothing (${real.count} rows exist)`); + + const write = await anon.from(table).insert({}).select("id"); + if (!write.error) { + fail("anon inserted a row"); + await service.from(table).delete().in("id", write.data.map((row) => row.id)); + } else { + console.log(` ok anon insert denied (${write.error.code})`); + } +} + +console.log(failed ? "\nRLS check FAILED" : "\nRLS check passed: anon has no table access."); +process.exit(failed ? 1 : 0); \ No newline at end of file diff --git a/src/lib/tokens.test.ts b/src/lib/tokens.test.ts new file mode 100644 index 0000000..dd54ee0 --- /dev/null +++ b/src/lib/tokens.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { idSchema, tokenSchema } from "./tokens"; + +describe("tokenSchema", () => { + it("accepts 32 lowercase hex chars", () => { + expect(tokenSchema.safeParse("0123456789abcdef0123456789abcdef").success).toBe(true); + }); + + it.each([ + ["too short", "abc123"], + ["too long", "0123456789abcdef0123456789abcdef0"], + ["uppercase", "0123456789ABCDEF0123456789ABCDEF"], + ["non-hex", "0123456789abcdef0123456789abcdeg"], + ["injection attempt", "0123456789abcdef0123456789abcde'"], + ["empty", ""], + ])("rejects %s", (_label, value) => { + expect(tokenSchema.safeParse(value).success).toBe(false); + }); +}); + +describe("idSchema", () => { + it("accepts a uuid and rejects anything else", () => { + expect(idSchema.safeParse("3f2b8c1e-9d4a-4e6f-8b7a-1c2d3e4f5a6b").success).toBe(true); + expect(idSchema.safeParse("not-a-uuid").success).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/lib/tokens.ts b/src/lib/tokens.ts new file mode 100644 index 0000000..6b970e6 --- /dev/null +++ b/src/lib/tokens.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +// Matches new_token() in supabase/migrations/0001_init.sql: 16 random bytes as hex = 128 bits. +// Validate the shape before touching the DB so junk URLs never become queries. +export const tokenSchema = z.string().regex(/^[0-9a-f]{32}$/, "Invalid link"); + +export const scoreboardTokenSchema = tokenSchema; +export const judgeTokenSchema = tokenSchema; +export const openTokenSchema = tokenSchema; + +export const idSchema = z.uuid();