supersam/supabase/functions/_shared/security.ts

216 lines
7.4 KiB
TypeScript

import { createClient } from 'npm:@supabase/supabase-js@2';
const ALLOWED_ORIGINS = [
'https://supa.supersamsev.ru',
'https://dost.supersamsev.ru',
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:3000',
'https://supasevdev.mkn8n.ru',
];
export function createServiceClient() {
const supabaseUrl = Deno.env.get('SUPABASE_URL') || '';
const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') || '';
return createClient(supabaseUrl, serviceRoleKey);
}
export function getClientIp(request: Request): string {
const xff = request.headers.get('x-forwarded-for');
if (xff) return xff.split(',')[0].trim();
return request.headers.get('x-real-ip') || 'unknown';
}
export function getCorsHeaders(request: Request, _access: 'public' | 'private') {
const origin = request.headers.get('origin') || '';
if (!origin) {
return {
'Access-Control-Allow-Origin': ALLOWED_ORIGINS[0],
'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type,Authorization,apikey,x-application-name,x-client-info',
'Access-Control-Max-Age': '86400',
};
}
const allowed = ALLOWED_ORIGINS.includes(origin);
if (!allowed) return null;
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type,Authorization,apikey,x-application-name,x-client-info',
'Access-Control-Max-Age': '86400',
};
}
export function preflightResponse(request: Request, access: 'public' | 'private') {
const corsHeaders = getCorsHeaders(request, access);
if (!corsHeaders) {
return new Response('Origin not allowed', { status: 403 });
}
return new Response(null, { status: 204, headers: corsHeaders });
}
export function jsonResponse(body: unknown, status = 200, corsHeaders?: Record<string, string>) {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (corsHeaders) Object.assign(headers, corsHeaders);
return new Response(JSON.stringify(body), { status, headers });
}
export async function hashText(text: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
interface JsonBodyResult<T> {
body: T;
}
export async function readJsonBody<T>(request: Request, options?: { maxBytes?: number }): Promise<JsonBodyResult<T>> {
const maxBytes = options?.maxBytes ?? 1024 * 1024;
const reader = request.body?.getReader();
if (!reader) throw new Error('No body');
const chunks: Uint8Array[] = [];
let totalBytes = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.length;
if (totalBytes > maxBytes) {
reader.cancel();
throw Object.assign(new Error('Request body too large'), { status: 413 });
}
chunks.push(value);
}
const combined = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
const text = new TextDecoder().decode(combined);
const body = JSON.parse(text) as T;
return { body };
}
interface RateLimitOptions {
scope: string;
key: string;
maxCount: number;
windowSeconds: number;
blockSeconds: number;
}
class RateLimitError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
export async function requireRateLimit(supabase: ReturnType<typeof createClient>, options: RateLimitOptions) {
const { scope, key, maxCount, windowSeconds, blockSeconds } = options;
// Use atomic SQL function to avoid race conditions
const { data, error } = await supabase.rpc('check_rate_limit', {
p_scope: scope,
p_key: key,
p_max_count: maxCount,
p_window_seconds: windowSeconds,
p_block_seconds: blockSeconds,
});
if (error) {
console.error('Rate limit check error:', error);
return;
}
if (data && !data.allowed) {
throw new RateLimitError('Too many requests. Please try again later.', 429);
}
}
// ── UUID validation ──────────────────────────────────────────────────────
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isValidUuid(value: unknown): value is string {
return typeof value === 'string' && UUID_REGEX.test(value.trim());
}
export function requireUuid(value: unknown, fieldName = 'id'): string {
if (!isValidUuid(value)) {
throw Object.assign(new Error(`${fieldName} must be a valid UUID`), { status: 400 });
}
return (value as string).trim();
}
// ── PII masking ──────────────────────────────────────────────────────────
export function maskCustomerName(name: string | null | undefined): string | null {
if (!name || typeof name !== 'string') return null;
const trimmed = name.trim();
if (!trimmed) return null;
const firstChar = trimmed[0];
return `${firstChar}.`;
}
export function maskPhoneNumber(phone: string | null | undefined): string | null {
if (!phone || typeof phone !== 'string') return null;
const trimmed = phone.trim();
if (!trimmed) return null;
if (trimmed.length < 4) return trimmed;
return `+7 *** ***-${trimmed.slice(-2)}`;
}
// ── Same-origin / CSRF check ─────────────────────────────────────────────
export function requireSameOrigin(request: Request, allowedOrigins: string[]): boolean {
const origin = request.headers.get('origin') || '';
if (!origin) return false;
return allowedOrigins.includes(origin);
}
// ── Internal request verification (HMAC or shared secret) ────────────────
interface VerifyInternalOptions {
rawBody?: Uint8Array | string;
secretEnvNames?: string[];
tokenEnvNames?: string[];
allowedClockSkewSeconds?: number;
}
export async function verifyInternalRequest(
request: Request,
_rawBody: Uint8Array | string | undefined,
options: VerifyInternalOptions = {},
): Promise<{ authenticated: boolean; authenticatedBy: string }> {
const {
secretEnvNames = ['INTEGRATION_WEBHOOK_SECRET'],
tokenEnvNames = ['INTEGRATION_API_KEY'],
} = options;
// Check for shared secret via header
const authHeader = request.headers.get('x-internal-secret') || request.headers.get('x-webhook-secret') || '';
for (const envName of secretEnvNames) {
const secret = Deno.env.get(envName);
if (secret && authHeader && authHeader === secret) {
return { authenticated: true, authenticatedBy: envName };
}
}
// Check for API token via header
const tokenHeader = request.headers.get('x-internal-token') || request.headers.get('x-api-key') || '';
for (const envName of tokenEnvNames) {
const token = Deno.env.get(envName);
if (token && tokenHeader && tokenHeader === token) {
return { authenticated: true, authenticatedBy: envName };
}
}
// No valid credentials found
throw Object.assign(new Error('Unauthorized: invalid or missing internal credentials'), { status: 401 });
}