diff --git a/supabase/functions/caterium-platform-auth-admin/index.ts b/supabase/functions/caterium-platform-auth-admin/index.ts index b8d44d3..0d80de8 100644 --- a/supabase/functions/caterium-platform-auth-admin/index.ts +++ b/supabase/functions/caterium-platform-auth-admin/index.ts @@ -1,15 +1,37 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4' -const cors = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', +const PROD_ORIGINS = new Set([ + 'https://app.caterium.ru', + 'https://caterium.ru', + '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 = { + '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) => { - if (req.method === 'OPTIONS') return new Response('ok', {headers:cors}) - if (req.method !== 'POST') return json({error:'Method not allowed'},405) + const origin = req.headers.get('Origin') || '' + 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 { const url = Deno.env.get('SUPABASE_URL')! const anon = Deno.env.get('SUPABASE_ANON_KEY')! @@ -17,9 +39,9 @@ Deno.serve(async (req) => { const authHeader = req.headers.get('Authorization') || '' const userClient = createClient(url, anon, {global:{headers:{Authorization:authHeader}},auth:{persistSession:false,autoRefreshToken:false}}) 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') - 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 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(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||'', 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()), @@ -51,29 +73,29 @@ Deno.serve(async (req) => { } const targetId = String(body.user_id || '') - if (!targetId) return json({error:'user_id required'},400) - if (targetId === user.id && action === 'set_ban') return json({error:'Нельзя заблокировать собственный аккаунт разработчика.'},400) + if (!targetId) return json(req, {error:'user_id required'},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) - 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') { const banned = body.banned === true const {error}=await admin.auth.admin.updateUserById(targetId,{ban_duration:banned?'876000h':'none'}) 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||''}}) - return json({ok:true,banned}) + return json(req, {ok:true,banned}) } 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 {error}=await publicClient.auth.resetPasswordForEmail(target.email) 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}}) - return json({ok:true}) + return json(req, {ok:true}) } - return json({error:'Unknown action'},400) + return json(req, {error:'Unknown action'},400) } catch (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) } }) diff --git a/tests/edge-security-v1774.mjs b/tests/edge-security-v1774.mjs index 5f048f2..c4f8968 100644 --- a/tests/edge-security-v1774.mjs +++ b/tests/edge-security-v1774.mjs @@ -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('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'); + +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);