Merge pull request #21 from pavlov346346-source/fix/edge-function-hardening-v1774
Harden employee Edge Function boundary
This commit is contained in:
commit
5f6e00c49b
@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js",
|
"check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js",
|
||||||
"test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs",
|
"test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs",
|
||||||
"check:release": "node tests/release-check.mjs",
|
"check:release": "node tests/release-check.mjs",
|
||||||
"check:deploy": "npm run check:syntax && npm run test:static && npm run check:release",
|
"check:deploy": "npm run check:syntax && npm run test:static && npm run check:release",
|
||||||
"test:e2e": "playwright test --config=tests/playwright.config.mjs",
|
"test:e2e": "playwright test --config=tests/playwright.config.mjs",
|
||||||
|
|||||||
@ -1,13 +1,36 @@
|
|||||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
import { createClient } from "npm:@supabase/supabase-js@2.116.0";
|
||||||
|
|
||||||
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",
|
||||||
"Content-Type": "application/json",
|
]);
|
||||||
};
|
const WORKERS_DEV_ORIGIN = /^https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.workers\.dev$/i;
|
||||||
const reply = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: cors });
|
const LOCAL_ORIGIN = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d{1,5})?$/i;
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
const ALLOWED_ROLES = new Set(["admin", "manager", "kitchen", "courier", "viewer"]);
|
||||||
|
|
||||||
|
function allowedOrigin(origin: string) {
|
||||||
|
if (!origin) return true;
|
||||||
|
return PROD_ORIGINS.has(origin) || WORKERS_DEV_ORIGIN.test(origin) || LOCAL_ORIGIN.test(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 reply = (req: Request, body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: corsHeaders(req) });
|
||||||
|
|
||||||
type PrepData = {
|
type PrepData = {
|
||||||
status?: string;
|
status?: string;
|
||||||
@ -17,26 +40,55 @@ type PrepData = {
|
|||||||
role?: string;
|
role?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function publicError(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || "");
|
||||||
|
if (/access denied|permission|not allowed|недостаточно прав|сначала войдите/i.test(message)) return { status: 403, error: "Недостаточно прав для добавления сотрудника.", code: "forbidden" };
|
||||||
|
if (/subscription|blocked|plan|подписк|тариф/i.test(message)) return { status: 403, error: "Добавление сотрудника недоступно для текущего тарифа.", code: "plan_restricted" };
|
||||||
|
if (/limit|maximum|max_members|лимит/i.test(message)) return { status: 409, error: "Достигнут лимит сотрудников для текущего тарифа.", code: "member_limit" };
|
||||||
|
if (/already registered|already exists|duplicate|уже существует/i.test(message)) return { status: 409, error: "Аккаунт с этим email уже существует.", code: "account_exists" };
|
||||||
|
return { status: 500, error: "Не удалось добавить сотрудника. Повторите попытку.", code: "employee_create_failed" };
|
||||||
|
}
|
||||||
|
|
||||||
Deno.serve(async (req: Request) => {
|
Deno.serve(async (req: Request) => {
|
||||||
if (req.method === "OPTIONS") return new Response("ok", { headers: cors });
|
const origin = req.headers.get("Origin") || "";
|
||||||
if (req.method !== "POST") return reply({ error: "Method not allowed" }, 405);
|
if (origin && !allowedOrigin(origin)) return reply(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 reply(req, { error: "Method not allowed", code: "method_not_allowed" }, 405);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = req.headers.get("Authorization") || "";
|
const auth = req.headers.get("Authorization") || "";
|
||||||
|
if (!/^Bearer\s+\S+/i.test(auth)) return reply(req, { error: "Требуется авторизация.", code: "unauthorized" }, 401);
|
||||||
|
|
||||||
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") || "";
|
||||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
|
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
|
||||||
if (!url || !anon || !serviceKey) return reply({ error: "Server auth configuration is incomplete" }, 500);
|
if (!url || !anon || !serviceKey) {
|
||||||
|
console.error("[caterium-create-employee] incomplete server auth configuration");
|
||||||
|
return reply(req, { error: "Сервис временно недоступен.", code: "server_configuration" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch (_) {
|
||||||
|
return reply(req, { error: "Некорректный формат запроса.", code: "invalid_json" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceId = String(body.workspace_id || "").trim();
|
||||||
|
const email = String(body.email || "").trim().toLowerCase();
|
||||||
|
const displayName = String(body.display_name || "").trim();
|
||||||
|
const role = String(body.role || "manager").trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!UUID_RE.test(workspaceId)) return reply(req, { error: "Некорректная компания.", code: "invalid_workspace" }, 400);
|
||||||
|
if (!EMAIL_RE.test(email) || email.length > 254) return reply(req, { error: "Введите корректный email.", code: "invalid_email" }, 400);
|
||||||
|
if (displayName.length > 120) return reply(req, { error: "Имя сотрудника слишком длинное.", code: "invalid_display_name" }, 400);
|
||||||
|
if (!ALLOWED_ROLES.has(role)) return reply(req, { error: "Некорректная роль сотрудника.", code: "invalid_role" }, 400);
|
||||||
|
|
||||||
const caller = createClient(url, anon, {
|
const caller = createClient(url, anon, {
|
||||||
global: { headers: { Authorization: auth } },
|
global: { headers: { Authorization: auth } },
|
||||||
auth: { persistSession: false, autoRefreshToken: false },
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
});
|
});
|
||||||
const service = createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false } });
|
const service = createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false } });
|
||||||
const body = await req.json();
|
|
||||||
const workspaceId = String(body.workspace_id || "");
|
|
||||||
const email = String(body.email || "").trim().toLowerCase();
|
|
||||||
const displayName = String(body.display_name || "").trim();
|
|
||||||
const role = String(body.role || "manager");
|
|
||||||
|
|
||||||
const prepare = async () => {
|
const prepare = async () => {
|
||||||
const result = await caller.rpc("sun_employee_prepare_v28", {
|
const result = await caller.rpc("sun_employee_prepare_v28", {
|
||||||
@ -57,7 +109,7 @@ Deno.serve(async (req: Request) => {
|
|||||||
p_role: role,
|
p_role: role,
|
||||||
});
|
});
|
||||||
if (fin.error) throw new Error(fin.error.message);
|
if (fin.error) throw new Error(fin.error.message);
|
||||||
return reply({
|
return reply(req, {
|
||||||
...fin.data,
|
...fin.data,
|
||||||
created,
|
created,
|
||||||
temporary_password: temporaryPassword,
|
temporary_password: temporaryPassword,
|
||||||
@ -69,7 +121,7 @@ Deno.serve(async (req: Request) => {
|
|||||||
const prepStatus = String(prep.status || "");
|
const prepStatus = String(prep.status || "");
|
||||||
|
|
||||||
if (prepStatus === "already_member") {
|
if (prepStatus === "already_member") {
|
||||||
return reply({
|
return reply(req, {
|
||||||
status: "already_member",
|
status: "already_member",
|
||||||
user_id: prep.user_id,
|
user_id: prep.user_id,
|
||||||
email: prep.email || email,
|
email: prep.email || email,
|
||||||
@ -83,13 +135,11 @@ Deno.serve(async (req: Request) => {
|
|||||||
|
|
||||||
if (prepStatus === "existing") {
|
if (prepStatus === "existing") {
|
||||||
const existingUserId = String(prep.user_id || "");
|
const existingUserId = String(prep.user_id || "");
|
||||||
if (!existingUserId) return reply({ error: "Existing employee account has no user id" }, 409);
|
if (!UUID_RE.test(existingUserId)) throw new Error("Existing employee account has no valid user id");
|
||||||
return await finalize(existingUserId, false, null);
|
return await finalize(existingUserId, false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prepStatus !== "new") {
|
if (prepStatus !== "new") throw new Error(`Unexpected employee prepare status: ${prepStatus || "empty"}`);
|
||||||
return reply({ error: `Unexpected employee prepare status: ${prepStatus || "empty"}` }, 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
|
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(12));
|
const bytes = crypto.getRandomValues(new Uint8Array(12));
|
||||||
@ -109,12 +159,10 @@ Deno.serve(async (req: Request) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (created.error || !created.data.user) {
|
if (created.error || !created.data.user) {
|
||||||
// A concurrent request or a previous partial failure may have created Auth already.
|
|
||||||
// Re-read server state and finalize the existing account instead of leaving the workspace half-configured.
|
|
||||||
const retryPrep = await prepare();
|
const retryPrep = await prepare();
|
||||||
const retryStatus = String(retryPrep.status || "");
|
const retryStatus = String(retryPrep.status || "");
|
||||||
if (retryStatus === "already_member") {
|
if (retryStatus === "already_member") {
|
||||||
return reply({
|
return reply(req, {
|
||||||
status: "already_member",
|
status: "already_member",
|
||||||
user_id: retryPrep.user_id,
|
user_id: retryPrep.user_id,
|
||||||
email: retryPrep.email || email,
|
email: retryPrep.email || email,
|
||||||
@ -125,14 +173,14 @@ Deno.serve(async (req: Request) => {
|
|||||||
must_change_password: false,
|
must_change_password: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (retryStatus === "existing" && retryPrep.user_id) {
|
if (retryStatus === "existing" && retryPrep.user_id) return await finalize(String(retryPrep.user_id), false, null);
|
||||||
return await finalize(String(retryPrep.user_id), false, null);
|
throw new Error(created.error?.message || "Employee auth creation failed");
|
||||||
}
|
|
||||||
return reply({ error: created.error?.message || "Create failed" }, 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await finalize(created.data.user.id, true, password);
|
return await finalize(created.data.user.id, true, password);
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
return reply({ error: e instanceof Error ? e.message : String(e) }, 500);
|
console.error("[caterium-create-employee]", error);
|
||||||
|
const safe = publicError(error);
|
||||||
|
return reply(req, { error: safe.error, code: safe.code }, safe.status);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
14
tests/edge-security-v1774.mjs
Normal file
14
tests/edge-security-v1774.mjs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
const src=fs.readFileSync('supabase/functions/caterium-create-employee/index.ts','utf8');
|
||||||
|
let bad=0;const check=(v,m)=>{console.log(`${v?'OK':'FAIL'}: ${m}`);if(!v)bad++};
|
||||||
|
check(src.includes('npm:@supabase/supabase-js@2.116.0'),'Supabase JS is pinned');
|
||||||
|
check(!src.includes('"Access-Control-Allow-Origin": "*"'),'wildcard CORS is removed');
|
||||||
|
check(src.includes('https://app.caterium.ru')&&src.includes('WORKERS_DEV_ORIGIN'),'production and backup origins are allowlisted');
|
||||||
|
check(src.includes('origin_not_allowed'),'unknown browser origins fail closed');
|
||||||
|
check(src.includes('UUID_RE')&&src.includes('EMAIL_RE')&&src.includes('ALLOWED_ROLES'),'employee inputs are validated');
|
||||||
|
check(src.includes('["admin", "manager", "kitchen", "courier", "viewer"]'),'edge roles match database roles');
|
||||||
|
check(!src.includes('"operator"')&&!src.includes('"owner", "admin"'),'invented employee roles are not accepted');
|
||||||
|
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');
|
||||||
|
if(bad)process.exit(1);
|
||||||
Loading…
Reference in New Issue
Block a user