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:
pavlov346346-source 2026-09-12 14:09:16 +03:00
parent 4106452f56
commit aeca9eae99
2 changed files with 47 additions and 18 deletions

View File

@ -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)
} }
}) })

View File

@ -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);