fix: lock down caterium-platform-auth-admin CORS to allowlisted origins
This Edge Function grants platform-admin power (list every user across every workspace, ban/unban accounts, trigger password resets for any user_id) but answered with Access-Control-Allow-Origin: '*', unlike the sibling caterium-create-employee function which already uses an origin allowlist. Authorization itself was never bypassable this way (the function still requires the caller's own Bearer token and re-checks sun_is_platform_admin() server-side), but a wildcard CORS response removes a real layer of defense-in-depth if a platform-admin token were ever exposed to another origin. Applies the same allowedOrigin()/corsHeaders() pattern already proven in caterium-create-employee, and extends edge-security-v1774.mjs (which already asserted the wildcard was gone from create-employee, but never checked this function) to cover both. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
4106452f56
commit
aeca9eae99
@ -1,15 +1,37 @@
|
|||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'
|
||||||
|
|
||||||
const cors = {
|
const PROD_ORIGINS = new Set([
|
||||||
'Access-Control-Allow-Origin': '*',
|
'https://app.caterium.ru',
|
||||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
'https://caterium.ru',
|
||||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
'https://www.caterium.ru',
|
||||||
|
])
|
||||||
|
const WORKERS_DEV_ORIGIN = /^https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.workers\.dev$/i
|
||||||
|
const LOCAL_ORIGIN = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d{1,5})?$/i
|
||||||
|
|
||||||
|
function allowedOrigin(origin: string) {
|
||||||
|
if (!origin) return true
|
||||||
|
return PROD_ORIGINS.has(origin) || WORKERS_DEV_ORIGIN.test(origin) || LOCAL_ORIGIN.test(origin)
|
||||||
}
|
}
|
||||||
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), {status, headers:{...cors,'Content-Type':'application/json'}})
|
|
||||||
|
function corsHeaders(req: Request) {
|
||||||
|
const origin = req.headers.get('Origin') || ''
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||||
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Vary': 'Origin',
|
||||||
|
}
|
||||||
|
if (origin && allowedOrigin(origin)) headers['Access-Control-Allow-Origin'] = origin
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (req: Request, body: unknown, status = 200) => new Response(JSON.stringify(body), {status, headers: corsHeaders(req)})
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
Deno.serve(async (req) => {
|
||||||
if (req.method === 'OPTIONS') return new Response('ok', {headers:cors})
|
const origin = req.headers.get('Origin') || ''
|
||||||
if (req.method !== 'POST') return json({error:'Method not allowed'},405)
|
if (origin && !allowedOrigin(origin)) return json(req, {error: 'Origin is not allowed', code: 'origin_not_allowed'}, 403)
|
||||||
|
if (req.method === 'OPTIONS') return new Response('ok', {headers: corsHeaders(req)})
|
||||||
|
if (req.method !== 'POST') return json(req, {error:'Method not allowed'},405)
|
||||||
try {
|
try {
|
||||||
const url = Deno.env.get('SUPABASE_URL')!
|
const url = Deno.env.get('SUPABASE_URL')!
|
||||||
const anon = Deno.env.get('SUPABASE_ANON_KEY')!
|
const anon = Deno.env.get('SUPABASE_ANON_KEY')!
|
||||||
@ -17,9 +39,9 @@ Deno.serve(async (req) => {
|
|||||||
const authHeader = req.headers.get('Authorization') || ''
|
const authHeader = req.headers.get('Authorization') || ''
|
||||||
const userClient = createClient(url, anon, {global:{headers:{Authorization:authHeader}},auth:{persistSession:false,autoRefreshToken:false}})
|
const userClient = createClient(url, anon, {global:{headers:{Authorization:authHeader}},auth:{persistSession:false,autoRefreshToken:false}})
|
||||||
const {data:{user},error:userError} = await userClient.auth.getUser()
|
const {data:{user},error:userError} = await userClient.auth.getUser()
|
||||||
if (userError || !user) return json({error:'Unauthorized'},401)
|
if (userError || !user) return json(req, {error:'Unauthorized'},401)
|
||||||
const {data:isAdmin,error:adminError} = await userClient.rpc('sun_is_platform_admin')
|
const {data:isAdmin,error:adminError} = await userClient.rpc('sun_is_platform_admin')
|
||||||
if (adminError || isAdmin !== true) return json({error:'Platform administrator required'},403)
|
if (adminError || isAdmin !== true) return json(req, {error:'Platform administrator required'},403)
|
||||||
|
|
||||||
const body = await req.json().catch(()=>({})) as Record<string,unknown>
|
const body = await req.json().catch(()=>({})) as Record<string,unknown>
|
||||||
const action = String(body.action || 'list')
|
const action = String(body.action || 'list')
|
||||||
@ -41,7 +63,7 @@ Deno.serve(async (req) => {
|
|||||||
if(!byId.has(id))byId.set(id,{is_platform_admin:row.is_platform_admin===true,workspaces:[]})
|
if(!byId.has(id))byId.set(id,{is_platform_admin:row.is_platform_admin===true,workspaces:[]})
|
||||||
if(row.workspace_id)byId.get(id).workspaces.push({id:row.workspace_id,name:row.workspace_name,role:row.role,display_name:row.display_name,is_active:row.is_active!==false})
|
if(row.workspace_id)byId.get(id).workspaces.push({id:row.workspace_id,name:row.workspace_name,role:row.role,display_name:row.display_name,is_active:row.is_active!==false})
|
||||||
}
|
}
|
||||||
return json({users:users.map(u=>({
|
return json(req, {users:users.map(u=>({
|
||||||
id:u.id,email:u.email||'',created_at:u.created_at||'',last_sign_in_at:u.last_sign_in_at||'',
|
id:u.id,email:u.email||'',created_at:u.created_at||'',last_sign_in_at:u.last_sign_in_at||'',
|
||||||
email_confirmed_at:u.email_confirmed_at||u.confirmed_at||'',banned_until:u.banned_until||'',
|
email_confirmed_at:u.email_confirmed_at||u.confirmed_at||'',banned_until:u.banned_until||'',
|
||||||
is_banned:Boolean(u.banned_until&&new Date(u.banned_until)>new Date()),
|
is_banned:Boolean(u.banned_until&&new Date(u.banned_until)>new Date()),
|
||||||
@ -51,29 +73,29 @@ Deno.serve(async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetId = String(body.user_id || '')
|
const targetId = String(body.user_id || '')
|
||||||
if (!targetId) return json({error:'user_id required'},400)
|
if (!targetId) return json(req, {error:'user_id required'},400)
|
||||||
if (targetId === user.id && action === 'set_ban') return json({error:'Нельзя заблокировать собственный аккаунт разработчика.'},400)
|
if (targetId === user.id && action === 'set_ban') return json(req, {error:'Нельзя заблокировать собственный аккаунт разработчика.'},400)
|
||||||
const {data:{user:target},error:targetError}=await admin.auth.admin.getUserById(targetId)
|
const {data:{user:target},error:targetError}=await admin.auth.admin.getUserById(targetId)
|
||||||
if(targetError||!target)return json({error:'User not found'},404)
|
if(targetError||!target)return json(req, {error:'User not found'},404)
|
||||||
|
|
||||||
if (action === 'set_ban') {
|
if (action === 'set_ban') {
|
||||||
const banned = body.banned === true
|
const banned = body.banned === true
|
||||||
const {error}=await admin.auth.admin.updateUserById(targetId,{ban_duration:banned?'876000h':'none'})
|
const {error}=await admin.auth.admin.updateUserById(targetId,{ban_duration:banned?'876000h':'none'})
|
||||||
if(error)throw error
|
if(error)throw error
|
||||||
await userClient.rpc('sun_platform_log_event',{p_action:banned?'account.ban':'account.unban',p_workspace:null,p_user:targetId,p_details:{email:target.email||''}})
|
await userClient.rpc('sun_platform_log_event',{p_action:banned?'account.ban':'account.unban',p_workspace:null,p_user:targetId,p_details:{email:target.email||''}})
|
||||||
return json({ok:true,banned})
|
return json(req, {ok:true,banned})
|
||||||
}
|
}
|
||||||
if (action === 'send_recovery') {
|
if (action === 'send_recovery') {
|
||||||
if(!target.email)return json({error:'У пользователя нет email.'},400)
|
if(!target.email)return json(req, {error:'У пользователя нет email.'},400)
|
||||||
const publicClient=createClient(url,anon,{auth:{persistSession:false,autoRefreshToken:false}})
|
const publicClient=createClient(url,anon,{auth:{persistSession:false,autoRefreshToken:false}})
|
||||||
const {error}=await publicClient.auth.resetPasswordForEmail(target.email)
|
const {error}=await publicClient.auth.resetPasswordForEmail(target.email)
|
||||||
if(error)throw error
|
if(error)throw error
|
||||||
await userClient.rpc('sun_platform_log_event',{p_action:'account.recovery_sent',p_workspace:null,p_user:targetId,p_details:{email:target.email}})
|
await userClient.rpc('sun_platform_log_event',{p_action:'account.recovery_sent',p_workspace:null,p_user:targetId,p_details:{email:target.email}})
|
||||||
return json({ok:true})
|
return json(req, {ok:true})
|
||||||
}
|
}
|
||||||
return json({error:'Unknown action'},400)
|
return json(req, {error:'Unknown action'},400)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
return json({error:e instanceof Error?e.message:String(e)},500)
|
return json(req, {error:e instanceof Error?e.message:String(e)},500)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -14,4 +14,11 @@ check(!src.includes('"operator"')&&!src.includes('"owner", "admin"'),'invented e
|
|||||||
check(src.includes('console.error("[caterium-create-employee]"'),'internal errors remain server-side');
|
check(src.includes('console.error("[caterium-create-employee]"'),'internal errors remain server-side');
|
||||||
check(src.includes('employee_create_failed'),'unexpected failures return a safe public code');
|
check(src.includes('employee_create_failed'),'unexpected failures return a safe public code');
|
||||||
check(!src.includes('return reply({ error: e instanceof Error ? e.message'),'raw exception messages are not returned');
|
check(!src.includes('return reply({ error: e instanceof Error ? e.message'),'raw exception messages are not returned');
|
||||||
|
|
||||||
|
const adminSrc=fs.readFileSync('supabase/functions/caterium-platform-auth-admin/index.ts','utf8');
|
||||||
|
check(!adminSrc.includes("'Access-Control-Allow-Origin': '*'"),'platform-auth-admin: wildcard CORS is removed');
|
||||||
|
check(adminSrc.includes('https://app.caterium.ru')&&adminSrc.includes('WORKERS_DEV_ORIGIN'),'platform-auth-admin: production and backup origins are allowlisted');
|
||||||
|
check(adminSrc.includes('origin_not_allowed'),'platform-auth-admin: unknown browser origins fail closed');
|
||||||
|
check(adminSrc.includes("sun_is_platform_admin"),'platform-auth-admin: caller platform-admin check remains in place');
|
||||||
|
|
||||||
if(bad)process.exit(1);
|
if(bad)process.exit(1);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user