mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-12 10:11:34 +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 = {
|
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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user