400 lines
11 KiB
TypeScript
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;
|
|
};
|