mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-12 03:30:57 +00:00
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:
@@ -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 = {
|
||||
"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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user