mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-12 05:50:56 +00:00
fix(security): identify the caller from their token, not the request body
two-factor-auth and notifications-manager run with the service role, so they
bypass RLS and whatever user id they act on is the entire authorisation
decision. Both read that id from the request body, and neither checked who was
calling. Access-Control-Allow-Origin was '*' on both.
Confirmed against the live endpoint: with only the public anon key and no user
session, from an arbitrary origin,
POST /functions/v1/two-factor-auth {"action":"check","userId":"<any-uuid>"}
-> 200 {"success":true,"enabled":false}
VERIFY_JWT is on and does not stop this, because the anon key is itself a valid
JWT and every browser has it. The same path reaches "disable", so anyone could
turn off another account's 2FA, and read, delete or forge their notifications.
The client already sends the signed-in user's token — functions.invoke puts the
session access token in the Authorization header — so the caller can simply be
read from it. Both functions now do that and ignore the body's userId. An
anon-key call carries no user, so it is rejected with 401.
create is the one action that may target someone else, because AdminPanel
notifies other users; that path now requires admin or super_admin in
admin_roles. CORS drops to the same allowlist the other functions here use.
No frontend change: the body may keep sending userId, it is simply not read.
Note: 2FA is not yet enforced at login — TwoFactorVerify is not referenced
anywhere and Login.tsx never checks it, so enrolling does not currently protect
sign-in. Tracked separately; this change is about the endpoints themselves.
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