diff --git a/web/supabase/functions/_shared/caller-auth.ts b/web/supabase/functions/_shared/caller-auth.ts new file mode 100644 index 00000000..7f62cda8 --- /dev/null +++ b/web/supabase/functions/_shared/caller-auth.ts @@ -0,0 +1,81 @@ +// Identify the caller from their JWT instead of trusting the request body. +// +// Functions that run with the service role bypass RLS entirely, so whatever +// user id they act on is the whole authorisation decision. Reading that id from +// the body means any caller can name any account: with only the public anon key +// — which every browser has — a request could read or change another user's +// records. VERIFY_JWT does not help, because the anon key is itself a valid JWT. +// +// The client already sends the signed-in user's token: supabase.functions.invoke +// puts the session access token in the Authorization header. These helpers read +// the caller from it, so the body's userId can be ignored. + +import type { SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3' + +export const ALLOWED_ORIGINS = [ + 'https://app.pezkuwichain.io', + 'https://www.pezkuwichain.io', + 'https://pezkuwichain.io', + 'https://pex.mom', +] + +export function getCorsHeaders(origin: string | null): Record { + const allowedOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0] + return { + 'Access-Control-Allow-Origin': allowedOrigin, + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Credentials': 'true', + } +} + +/** + * The signed-in user behind this request, or null. + * + * Returns null for the anon key: it carries no user, so getUser rejects it. + * That is the point — an anon-key call must not be able to act on an account. + */ +export async function getCaller( + req: Request, + serviceClient: SupabaseClient +): Promise<{ id: string; email?: string } | null> { + const header = req.headers.get('Authorization') + if (!header?.startsWith('Bearer ')) return null + + const jwt = header.slice('Bearer '.length).trim() + if (!jwt) return null + + const { + data: { user }, + error, + } = await serviceClient.auth.getUser(jwt) + + if (error || !user) return null + return { id: user.id, email: user.email ?? undefined } +} + +/** Admin or super_admin in admin_roles. Moderators are not admins here. */ +export async function isAdmin(serviceClient: SupabaseClient, userId: string): Promise { + const { data, error } = await serviceClient + .from('admin_roles') + .select('role') + .eq('user_id', userId) + .maybeSingle() + + if (error || !data) return false + return data.role === 'super_admin' || data.role === 'admin' +} + +export function unauthorized(corsHeaders: Record, message = 'Unauthorized') { + return new Response(JSON.stringify({ error: message }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) +} + +export function forbidden(corsHeaders: Record, message = 'Forbidden') { + return new Response(JSON.stringify({ error: message }), { + status: 403, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) +} diff --git a/web/supabase/functions/notifications-manager/index.ts b/web/supabase/functions/notifications-manager/index.ts index 32f02262..10badb9b 100644 --- a/web/supabase/functions/notifications-manager/index.ts +++ b/web/supabase/functions/notifications-manager/index.ts @@ -1,10 +1,14 @@ -export const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": - "authorization, x-client-info, apikey, content-type", -}; +import { + forbidden, + getCaller, + getCorsHeaders, + isAdmin, + unauthorized, +} from "../_shared/caller-auth.ts"; Deno.serve(async (req) => { + const corsHeaders = getCorsHeaders(req.headers.get("origin")); + if (req.method === "OPTIONS") { return new Response("ok", { headers: corsHeaders }); } @@ -12,7 +16,7 @@ Deno.serve(async (req) => { try { const { action, - userId, + userId: requestedUserId, notificationId, title, message, @@ -28,6 +32,22 @@ Deno.serve(async (req) => { const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const supabase = createClient(supabaseUrl, supabaseServiceKey); + // Read the account from the caller's token, not the body. Reading it from + // the body let an anon-key request list, delete or forge anyone's + // notifications. + const caller = await getCaller(req, supabase); + if (!caller) return unauthorized(corsHeaders); + + // AdminPanel legitimately notifies other users, so create is the one action + // allowed to target someone else - and only for an admin. + let userId = caller.id; + if (action === "create" && requestedUserId && requestedUserId !== caller.id) { + if (!(await isAdmin(supabase, caller.id))) { + return forbidden(corsHeaders, "Only admins can notify other users"); + } + userId = requestedUserId; + } + let result; switch (action) { diff --git a/web/supabase/functions/two-factor-auth/index.ts b/web/supabase/functions/two-factor-auth/index.ts index 2a98b435..ca7513bc 100644 --- a/web/supabase/functions/two-factor-auth/index.ts +++ b/web/supabase/functions/two-factor-auth/index.ts @@ -1,10 +1,5 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3"; - -export const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": - "authorization, x-client-info, apikey, content-type", -}; +import { getCaller, getCorsHeaders, unauthorized } from "../_shared/caller-auth.ts"; function generateSecret(): string { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; @@ -45,6 +40,8 @@ function generateTOTP(secret: string, window: number = 0): string { } Deno.serve(async (req) => { + const corsHeaders = getCorsHeaders(req.headers.get("origin")); + if (req.method === "OPTIONS") { return new Response("ok", { headers: corsHeaders }); } @@ -54,7 +51,14 @@ Deno.serve(async (req) => { const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const supabase = createClient(supabaseUrl, supabaseServiceKey); - const { action, userId, code, backupCode } = await req.json(); + // Every action below touches one account's 2FA state, so the account is + // taken from the caller's token. The body used to name it, which let an + // anon-key request read or disable anyone's 2FA. + const caller = await getCaller(req, supabase); + if (!caller) return unauthorized(corsHeaders); + const userId = caller.id; + + const { action, code, backupCode } = await req.json(); switch (action) { case "setup": {