Merge pull request #24 from pezkuwichain/fix/function-authorization

fix(security): identify the caller from their token, not the request body
This commit is contained in:
SatoshiQaziMuhammed
2026-07-30 04:25:26 -07:00
committed by GitHub
3 changed files with 118 additions and 13 deletions
@@ -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<string, string> {
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<boolean> {
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<string, string>, message = 'Unauthorized') {
return new Response(JSON.stringify({ error: message }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
export function forbidden(corsHeaders: Record<string, string>, message = 'Forbidden') {
return new Response(JSON.stringify({ error: message }), {
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
@@ -1,10 +1,14 @@
export const corsHeaders = { import {
"Access-Control-Allow-Origin": "*", forbidden,
"Access-Control-Allow-Headers": getCaller,
"authorization, x-client-info, apikey, content-type", getCorsHeaders,
}; isAdmin,
unauthorized,
} from "../_shared/caller-auth.ts";
Deno.serve(async (req) => { Deno.serve(async (req) => {
const corsHeaders = getCorsHeaders(req.headers.get("origin"));
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders }); return new Response("ok", { headers: corsHeaders });
} }
@@ -12,7 +16,7 @@ Deno.serve(async (req) => {
try { try {
const { const {
action, action,
userId, userId: requestedUserId,
notificationId, notificationId,
title, title,
message, message,
@@ -28,6 +32,22 @@ Deno.serve(async (req) => {
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseServiceKey); 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; let result;
switch (action) { switch (action) {
@@ -1,10 +1,5 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";
import { getCaller, getCorsHeaders, unauthorized } from "../_shared/caller-auth.ts";
export const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
function generateSecret(): string { function generateSecret(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
@@ -45,6 +40,8 @@ function generateTOTP(secret: string, window: number = 0): string {
} }
Deno.serve(async (req) => { Deno.serve(async (req) => {
const corsHeaders = getCorsHeaders(req.headers.get("origin"));
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders }); return new Response("ok", { headers: corsHeaders });
} }
@@ -54,7 +51,14 @@ Deno.serve(async (req) => {
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseServiceKey); 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) { switch (action) {
case "setup": { case "setup": {