supersam/supabase/functions/_shared/security.ts

400 lines
11 KiB
TypeScript

type CorsMode = "public" | "integration" | "webhook";
type JsonBodyOptions = {
maxBytes: number;
errorMessage?: string;
};
type RateLimitOptions = {
scope: string;
key: string;
maxCount: number;
windowSeconds: number;
blockSeconds?: number;
};
type RateLimitResult = {
allowed: boolean;
currentCount: number;
limitCount: number;
blockedUntil: string | null;
windowStart: string;
};
type IntegrationAuthOptions = {
rawBody: string;
secretEnvNames?: string[];
tokenEnvNames?: string[];
signatureHeader?: string;
timestampHeader?: string;
requestIdHeader?: string;
allowedClockSkewSeconds?: number;
};
const DEFAULT_LOCAL_ORIGINS = [
"http://localhost:5173",
"http://localhost:4173",
"http://127.0.0.1:5173",
"http://127.0.0.1:4173",
];
const normalizeOrigin = (value: string) => value.replace(/\/$/, "");
const splitList = (value: string | null | undefined) =>
(value || "")
.split(",")
.map((item) => normalizeOrigin(item.trim()))
.filter(Boolean);
const getRequestOrigin = (request: Request) => {
const origin = request.headers.get("origin");
if (origin) {
return normalizeOrigin(origin);
}
const referer = request.headers.get("referer");
if (!referer) {
return "";
}
try {
return normalizeOrigin(new URL(referer).origin);
} catch {
return "";
}
};
const readEnv = (name: string) => {
try {
if (typeof Deno === "undefined") {
return "";
}
return Deno.env.get(name) || "";
} catch {
return "";
}
};
const isLocalhostOrigin = (origin: string) =>
/:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
const resolveAllowedOrigins = (mode: CorsMode) => {
const publicOrigins = [
...splitList(readEnv("APP_ALLOWED_ORIGINS")),
...splitList(readEnv("PUBLIC_APP_URL")),
...splitList(readEnv("APP_PUBLIC_URL")),
];
const integrationOrigins = [
...splitList(readEnv("INTEGRATION_ALLOWED_ORIGINS")),
...splitList(readEnv("PUBLIC_APP_URL")),
];
const webhookOrigins = [
...splitList(readEnv("WEBHOOK_ALLOWED_ORIGINS")),
...splitList(readEnv("PUBLIC_APP_URL")),
];
const configured =
mode === "public"
? publicOrigins
: mode === "integration"
? integrationOrigins
: webhookOrigins;
if (configured.length > 0) {
return Array.from(new Set(configured));
}
return [];
};
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "HttpError";
}
}
export const jsonResponse = (
body: unknown,
status = 200,
headers: HeadersInit = {},
) =>
new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
...headers,
},
});
export const getCorsHeaders = (request: Request, mode: CorsMode) => {
const origin = getRequestOrigin(request);
const allowedOrigins = resolveAllowedOrigins(mode);
if (!origin) {
if (allowedOrigins.length === 0) {
return null;
}
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Max-Age": "86400",
Vary: "Origin",
} satisfies Record<string, string>;
}
const isAllowed =
allowedOrigins.length === 0
? false
: allowedOrigins.some((allowedOrigin) => {
if (allowedOrigin === "*") {
return true;
}
return origin === allowedOrigin || origin.startsWith(`${allowedOrigin}/`);
}) || (!readEnv("NODE_ENV") || readEnv("NODE_ENV") !== "production" && isLocalhostOrigin(origin));
if (!isAllowed) {
return null;
}
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Max-Age": "86400",
Vary: "Origin",
} satisfies Record<string, string>;
};
export const preflightResponse = (request: Request, mode: CorsMode) => {
const corsHeaders = getCorsHeaders(request, mode);
if (!corsHeaders) {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
}
return new Response("ok", {
status: 204,
headers: corsHeaders,
});
};
export const assertAllowedOrigin = (request: Request, mode: CorsMode) => {
const corsHeaders = getCorsHeaders(request, mode);
if (!corsHeaders) {
throw new HttpError(403, "Origin not allowed");
}
return corsHeaders;
};
export const readJsonBody = async <T extends Record<string, unknown>>(
request: Request,
options: JsonBodyOptions,
): Promise<{ body: T; rawBody: string }> => {
const rawBody = await request.clone().text();
const byteLength = new TextEncoder().encode(rawBody).length;
if (byteLength > options.maxBytes) {
throw new HttpError(413, options.errorMessage || "Payload too large");
}
if (!rawBody.trim()) {
throw new HttpError(400, "Request body is required");
}
try {
return {
body: JSON.parse(rawBody) as T,
rawBody,
};
} catch {
throw new HttpError(400, "Invalid JSON payload");
}
};
export const getClientIp = (request: Request) => {
const forwardedFor = request.headers.get("x-forwarded-for") || request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || "";
return forwardedFor.split(",")[0]?.trim() || "unknown";
};
export const sha256Hex = async (value: string) => {
const bytes = new TextEncoder().encode(value);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
};
export const hashText = sha256Hex;
const hmacHex = async (secret: string, value: string) => {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value));
return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
};
export const verifyInternalRequest = async (
request: Request,
rawBody: string,
options: IntegrationAuthOptions = { rawBody },
) => {
const tokenEnvNames = options.tokenEnvNames || ["INTEGRATION_API_KEY", "INTERNAL_API_KEY"];
const secretEnvNames = options.secretEnvNames || ["INTEGRATION_WEBHOOK_SECRET", "CHATBOT_WEBHOOK_SECRET"];
const bearerToken = request.headers.get("authorization") || "";
const token = bearerToken.toLowerCase().startsWith("bearer ") ? bearerToken.slice(7).trim() : "";
const requestId = request.headers.get(options.requestIdHeader || "x-request-id") || "";
const timestamp = request.headers.get(options.timestampHeader || "x-timestamp") || "";
const signature = request.headers.get(options.signatureHeader || "x-signature") || "";
const sharedTokens = tokenEnvNames.map((name) => readEnv(name)).filter(Boolean);
const sharedSecrets = secretEnvNames.map((name) => readEnv(name)).filter(Boolean);
if (token && sharedTokens.some((candidate) => candidate === token)) {
return { requestId, authenticatedBy: "bearer" as const };
}
if (sharedSecrets.length === 0) {
throw new HttpError(401, "Integration auth is not configured");
}
if (!timestamp || !signature) {
throw new HttpError(401, "Missing integration signature");
}
const timestampNumber = Number(timestamp);
if (!Number.isFinite(timestampNumber)) {
throw new HttpError(401, "Invalid integration timestamp");
}
const now = Date.now();
const allowedSkew = (options.allowedClockSkewSeconds || 300) * 1000;
if (Math.abs(now - timestampNumber) > allowedSkew) {
throw new HttpError(401, "Stale integration request");
}
const payload = `${timestamp}.${rawBody}`;
const expectedSignatures = await Promise.all(
sharedSecrets.map(async (secret) => hmacHex(secret, payload)),
);
if (!expectedSignatures.some((candidate) => candidate === signature)) {
throw new HttpError(401, "Invalid integration signature");
}
return { requestId, authenticatedBy: "hmac" as const };
};
export const maskPhoneNumber = (phone: string | null | undefined) => {
const value = String(phone || "").trim();
if (!value) {
return null;
}
const digits = value.replace(/\D/g, "");
if (digits.length < 4) {
return value;
}
const tail = digits.slice(-4);
const country = digits.startsWith("7") || digits.startsWith("8") ? "+7" : "+";
return `${country} *** ***-${tail.slice(0, 2)}-${tail.slice(2)}`;
};
export const maskCustomerName = (name: string | null | undefined) => {
const value = String(name || "").trim();
if (!value) {
return null;
}
const parts = value.split(/\s+/).filter(Boolean);
if (parts.length === 1) {
return `${parts[0].slice(0, 1)}.`;
}
return `${parts[0]} ${parts[1].slice(0, 1)}.`;
};
export const maskOrderNumber = (orderNumber: string | null | undefined) => {
const value = String(orderNumber || "").trim();
if (!value) {
return null;
}
if (value.length <= 4) {
return value;
}
return `${value.slice(-4)}`;
};
export const isValidUuid = (value: string): boolean => {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
};
export const requireUuid = (value: string | undefined | null, label = "id"): string => {
const trimmed = (value || "").trim();
if (!trimmed || !isValidUuid(trimmed)) {
throw new HttpError(400, `Invalid ${label} format`);
}
return trimmed;
};
export const requireSameOrigin = (request: Request, allowedOrigins: string[]) => {
const origin = request.headers.get("origin") || "";
const host = request.headers.get("host") || "";
if (!origin || !host) {
return false;
}
try {
const originHost = new URL(origin).host;
return allowedOrigins.some((allowed) => {
try {
return new URL(allowed).host === originHost;
} catch {
return allowed === origin;
}
});
} catch {
return false;
}
};
export const requireRateLimit = async (
supabase: {
rpc: (
name: string,
params: Record<string, unknown>,
) => PromiseLike<{ data: RateLimitResult | null; error: Error | null }>;
},
options: RateLimitOptions,
) => {
const { data, error } = await supabase.rpc("check_rate_limit", {
p_scope: options.scope,
p_key: options.key,
p_max_count: options.maxCount,
p_window_seconds: options.windowSeconds,
p_block_seconds: options.blockSeconds || 0,
});
if (error) {
throw error;
}
if (!data?.allowed) {
throw new HttpError(429, "Too many requests");
}
return data;
};