From f84a879985b7101e8d2dff562464381d7115c446 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 23:48:34 +0000 Subject: [PATCH 1/2] fix(security): harden admin bootstrap and admin account search - claimAdminBootstrap now checks/inserts the admin role via the service-role client. The user-scoped client is blinded by the `roles read self` RLS policy and reported 0 admins to any non-admin caller, allowing privilege escalation after an admin already existed. - Strip PostgREST filter metacharacters from the admin account search term to prevent filter injection via the interpolated `.or()` clause. https://claude.ai/code/session_01TSEwzP9iRBZiLkmCV2RGWx --- src/lib/admin/accounts.functions.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/lib/admin/accounts.functions.ts b/src/lib/admin/accounts.functions.ts index 541b887..3b910be 100644 --- a/src/lib/admin/accounts.functions.ts +++ b/src/lib/admin/accounts.functions.ts @@ -1,6 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware"; +import { supabaseAdmin } from "@/integrations/supabase/client.server"; import { requireAdmin } from "./middleware"; // Used by the admin layout guard. @@ -17,9 +18,12 @@ export const getMyAdminStatus = createServerFn({ method: "GET" }) export const claimAdminBootstrap = createServerFn({ method: "POST" }) .middleware([requireSupabaseAuth]) .handler(async ({ context }) => { - const { supabase: _sbCtx, userId } = context as any; - const supabase = _sbCtx as any; - const { count, error: cErr } = await supabase + const { userId } = context as any; + // Use the service-role client so the "is there any admin yet?" check is a + // true global invariant. The user-scoped client is blinded by RLS + // (`roles read self`) and would report 0 admins to any non-admin caller, + // letting any authenticated user claim admin even after one exists. + const { count, error: cErr } = await supabaseAdmin .from("user_roles") .select("user_id", { count: "exact", head: true }) .eq("role", "admin"); @@ -27,7 +31,9 @@ export const claimAdminBootstrap = createServerFn({ method: "POST" }) if ((count ?? 0) > 0) { throw new Response("Admin already exists", { status: 409 }); } - const { error } = await supabase.from("user_roles").insert({ user_id: userId, role: "admin" }); + const { error } = await supabaseAdmin + .from("user_roles") + .insert({ user_id: userId, role: "admin" }); if (error) throw new Response(error.message, { status: 500 }); return { ok: true }; }); @@ -46,7 +52,12 @@ export const listAccounts = createServerFn({ method: "GET" }) .order("created_at", { ascending: false }) .limit(200); if (data.search) { - q = q.or(`handle.ilike.%${data.search}%,display_name.ilike.%${data.search}%`); + // Strip PostgREST filter metacharacters to prevent filter injection + // through the interpolated `.or()` expression. + const term = data.search.replace(/[%,()*]/g, "").trim(); + if (term) { + q = q.or(`handle.ilike.%${term}%,display_name.ilike.%${term}%`); + } } const { data: profiles, error } = await q; if (error) throw new Response(error.message, { status: 500 }); From e27e31ac7afc6d3852e828bcb21619bf446f99d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 23:49:26 +0000 Subject: [PATCH 2/2] chore(security): stop tracking .env files Untrack .env, .env.development and .env.production and ignore local environment files going forward. .env.example remains tracked as the documented template. The committed keys were publishable (Supabase anon, Stripe publishable) and not secrets, but env files should not be in VCS. https://claude.ai/code/session_01TSEwzP9iRBZiLkmCV2RGWx --- .env | 5 ----- .env.development | 1 - .env.production | 1 - .gitignore | 6 ++++++ 4 files changed, 6 insertions(+), 7 deletions(-) delete mode 100644 .env delete mode 100644 .env.development delete mode 100644 .env.production diff --git a/.env b/.env deleted file mode 100644 index 2a3f72d..0000000 --- a/.env +++ /dev/null @@ -1,5 +0,0 @@ -SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlueHVvdGJqZnhrY3JmdmtybHRyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzgzODI2NjYsImV4cCI6MjA5Mzk1ODY2Nn0.NmGjLNtxcYfnmhpd6T7VwH5sO2R0K8D-4sv7ATvZezc" -SUPABASE_URL="https://ynxuotbjfxkcrfvkrltr.supabase.co" -VITE_SUPABASE_PROJECT_ID="ynxuotbjfxkcrfvkrltr" -VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlueHVvdGJqZnhrY3JmdmtybHRyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzgzODI2NjYsImV4cCI6MjA5Mzk1ODY2Nn0.NmGjLNtxcYfnmhpd6T7VwH5sO2R0K8D-4sv7ATvZezc" -VITE_SUPABASE_URL="https://ynxuotbjfxkcrfvkrltr.supabase.co" diff --git a/.env.development b/.env.development deleted file mode 100644 index a83be55..0000000 --- a/.env.development +++ /dev/null @@ -1 +0,0 @@ -VITE_PAYMENTS_CLIENT_TOKEN="pk_test_51TVtRMIaKlRpu2f0PAnMiD4SlLBnuVlP45ldYg3nMRkL0GzMBhzneAi6ffDR8SHm8VaAhuWQRW3AG4rRadwwukqR00TCwmWdvD" \ No newline at end of file diff --git a/.env.production b/.env.production deleted file mode 100644 index aef9c1c..0000000 --- a/.env.production +++ /dev/null @@ -1 +0,0 @@ -VITE_PAYMENTS_CLIENT_TOKEN="pk_live_51TVxFtEY0e7r92kuZbgPk7JFoEL91eq4fdsC8XNY3h9Apr18wMrrmIizeVzB9jzoBParMr9LT3NF7HAToX4rjFi600tHSSgV2g" \ No newline at end of file diff --git a/.gitignore b/.gitignore index d24df8a..40bc305 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,12 @@ pnpm-debug.log* lerna-debug.log* node_modules + +# Local environment files (keep .env.example tracked) +.env +.env.* +!.env.example + dist dist-ssr .output