(
+
+ ),
+ img: ({node, ...props}) => (
+
+ )
+ }}
>
{content}
diff --git a/supabase/functions/_shared/rateLimiter.ts b/supabase/functions/_shared/rateLimiter.ts
new file mode 100644
index 00000000..45503427
--- /dev/null
+++ b/supabase/functions/_shared/rateLimiter.ts
@@ -0,0 +1,222 @@
+/**
+ * Shared Rate Limiting Middleware for Edge Functions
+ * Prevents abuse and DoS attacks with in-memory rate limiting
+ */
+
+export interface RateLimitConfig {
+ windowMs: number; // Time window in milliseconds
+ maxRequests: number; // Max requests per window
+ maxMapSize?: number; // Max IPs to track (default: 10000)
+ keyGenerator?: (req: Request) => string; // Custom key generator
+ trustProxy?: boolean; // Trust X-Forwarded-For header
+}
+
+export interface RateLimitResult {
+ allowed: boolean;
+ retryAfter?: number;
+ remaining?: number;
+}
+
+class RateLimiter {
+ private rateLimitMap = new Map
();
+ private config: Required;
+ private cleanupInterval: number;
+
+ constructor(config: RateLimitConfig) {
+ this.config = {
+ maxMapSize: 10000,
+ keyGenerator: (req: Request) => this.getClientIP(req),
+ trustProxy: true,
+ ...config
+ };
+
+ // Setup periodic cleanup
+ this.cleanupInterval = setInterval(
+ () => this.cleanupExpiredEntries(),
+ Math.min(this.config.windowMs / 2, 30000)
+ );
+ }
+
+ private getClientIP(req: Request): string {
+ if (this.config.trustProxy) {
+ const forwarded = req.headers.get('x-forwarded-for');
+ if (forwarded) return forwarded.split(',')[0].trim();
+
+ const realIP = req.headers.get('x-real-ip');
+ if (realIP) return realIP;
+ }
+
+ // Fallback for testing
+ return '0.0.0.0';
+ }
+
+ private cleanupExpiredEntries(): void {
+ try {
+ const now = Date.now();
+ let deletedCount = 0;
+
+ for (const [key, data] of this.rateLimitMap.entries()) {
+ if (now > data.resetAt) {
+ this.rateLimitMap.delete(key);
+ deletedCount++;
+ }
+ }
+
+ if (deletedCount > 0) {
+ console.log(`[RateLimit] Cleaned ${deletedCount} expired entries`);
+ }
+ } catch (error) {
+ console.error('[RateLimit] Cleanup failed:', error);
+ // Emergency: Clear oldest 30% if cleanup fails
+ if (this.rateLimitMap.size > this.config.maxMapSize) {
+ const toClear = Math.floor(this.rateLimitMap.size * 0.3);
+ const keys = Array.from(this.rateLimitMap.keys()).slice(0, toClear);
+ keys.forEach(key => this.rateLimitMap.delete(key));
+ console.warn(`[RateLimit] Emergency cleared ${keys.length} entries`);
+ }
+ }
+ }
+
+ check(req: Request): RateLimitResult {
+ const key = this.config.keyGenerator(req);
+ const now = Date.now();
+ const existing = this.rateLimitMap.get(key);
+
+ // Check existing entry
+ if (existing && now <= existing.resetAt) {
+ if (existing.count >= this.config.maxRequests) {
+ const retryAfter = Math.ceil((existing.resetAt - now) / 1000);
+ return {
+ allowed: false,
+ retryAfter,
+ remaining: 0
+ };
+ }
+ existing.count++;
+ return {
+ allowed: true,
+ remaining: this.config.maxRequests - existing.count
+ };
+ }
+
+ // Handle capacity
+ if (!existing && this.rateLimitMap.size >= this.config.maxMapSize) {
+ this.cleanupExpiredEntries();
+
+ // LRU eviction if still at capacity
+ if (this.rateLimitMap.size >= this.config.maxMapSize) {
+ const toDelete = Math.floor(this.config.maxMapSize * 0.3);
+ const sortedEntries = Array.from(this.rateLimitMap.entries())
+ .sort((a, b) => a[1].resetAt - b[1].resetAt);
+
+ for (let i = 0; i < toDelete && i < sortedEntries.length; i++) {
+ this.rateLimitMap.delete(sortedEntries[i][0]);
+ }
+ }
+ }
+
+ // Create new entry
+ this.rateLimitMap.set(key, {
+ count: 1,
+ resetAt: now + this.config.windowMs
+ });
+
+ return {
+ allowed: true,
+ remaining: this.config.maxRequests - 1
+ };
+ }
+
+ destroy(): void {
+ clearInterval(this.cleanupInterval);
+ this.rateLimitMap.clear();
+ }
+}
+
+// Export factory function for different rate limit tiers
+export function createRateLimiter(config: RateLimitConfig): RateLimiter {
+ return new RateLimiter(config);
+}
+
+// Pre-configured rate limiters for common use cases
+export const rateLimiters = {
+ // Strict: For expensive operations (file uploads, data exports)
+ strict: createRateLimiter({
+ windowMs: 60000, // 1 minute
+ maxRequests: 5, // 5 requests per minute
+ }),
+
+ // Standard: For most API endpoints
+ standard: createRateLimiter({
+ windowMs: 60000, // 1 minute
+ maxRequests: 10, // 10 requests per minute
+ }),
+
+ // Lenient: For read-only, cached endpoints
+ lenient: createRateLimiter({
+ windowMs: 60000, // 1 minute
+ maxRequests: 30, // 30 requests per minute
+ }),
+
+ // Per-user: For authenticated endpoints (uses user ID as key)
+ perUser: (maxRequests: number = 20) => createRateLimiter({
+ windowMs: 60000,
+ maxRequests,
+ keyGenerator: (req: Request) => {
+ // Extract user ID from Authorization header JWT
+ const authHeader = req.headers.get('Authorization');
+ if (authHeader) {
+ try {
+ const token = authHeader.replace('Bearer ', '');
+ const payload = JSON.parse(atob(token.split('.')[1]));
+ return `user:${payload.sub}`;
+ } catch {
+ // Fall back to IP if JWT parsing fails
+ return req.headers.get('x-forwarded-for')?.split(',')[0] || '0.0.0.0';
+ }
+ }
+ return req.headers.get('x-forwarded-for')?.split(',')[0] || '0.0.0.0';
+ }
+ }),
+};
+
+// Middleware helper
+export function withRateLimit(
+ handler: (req: Request) => Promise,
+ limiter: RateLimiter,
+ corsHeaders: Record = {}
+): (req: Request) => Promise {
+ return async (req: Request) => {
+ const result = limiter.check(req);
+
+ if (!result.allowed) {
+ return new Response(
+ JSON.stringify({
+ error: 'Rate limit exceeded',
+ message: 'Too many requests. Please try again later.',
+ retryAfter: result.retryAfter
+ }),
+ {
+ status: 429,
+ headers: {
+ ...corsHeaders,
+ 'Content-Type': 'application/json',
+ 'Retry-After': String(result.retryAfter || 60),
+ 'X-RateLimit-Limit': String(limiter['config'].maxRequests),
+ 'X-RateLimit-Remaining': String(result.remaining || 0),
+ }
+ }
+ );
+ }
+
+ const response = await handler(req);
+
+ // Add rate limit headers to successful responses
+ if (result.remaining !== undefined) {
+ response.headers.set('X-RateLimit-Limit', String(limiter['config'].maxRequests));
+ response.headers.set('X-RateLimit-Remaining', String(result.remaining));
+ }
+
+ return response;
+ };
+}
diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts
index 20c08957..af3d05fa 100644
--- a/supabase/functions/process-selective-approval/index.ts
+++ b/supabase/functions/process-selective-approval/index.ts
@@ -4,6 +4,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { validateEntityData, validateEntityDataStrict } from "./validation.ts";
import { createErrorResponse } from "../_shared/errorSanitizer.ts";
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts";
+import { rateLimiters, withRateLimit } from "../_shared/rateLimiter.ts";
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
@@ -46,7 +47,10 @@ const RIDE_MODEL_FIELDS = [
'card_image_url', 'card_image_id'
];
-serve(async (req) => {
+// Apply per-user rate limiting for moderators (10 approvals/minute per moderator)
+const approvalRateLimiter = rateLimiters.perUser(10);
+
+serve(withRateLimit(async (req) => {
const tracking = startRequest(); // Start request tracking
if (req.method === 'OPTIONS') {
@@ -764,7 +768,7 @@ serve(async (req) => {
'process-selective-approval'
);
}
-});
+}, approvalRateLimiter, corsHeaders));
// Helper functions
function topologicalSort(items: any[]): any[] {
diff --git a/supabase/functions/upload-image/index.ts b/supabase/functions/upload-image/index.ts
index 4493b2f7..1f3c5582 100644
--- a/supabase/functions/upload-image/index.ts
+++ b/supabase/functions/upload-image/index.ts
@@ -1,6 +1,7 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts'
+import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts'
// Environment-aware CORS configuration
const getAllowedOrigin = (requestOrigin: string | null): string | null => {
@@ -69,7 +70,10 @@ const createAuthenticatedSupabaseClient = (authHeader: string) => {
})
}
-serve(async (req) => {
+// Apply strict rate limiting (5 requests/minute) to prevent abuse
+const uploadRateLimiter = rateLimiters.strict;
+
+serve(withRateLimit(async (req) => {
const tracking = startRequest();
const requestOrigin = req.headers.get('origin');
const allowedOrigin = getAllowedOrigin(requestOrigin);
@@ -643,4 +647,4 @@ serve(async (req) => {
}
)
}
-})
\ No newline at end of file
+}, uploadRateLimiter, getCorsHeaders(allowedOrigin)));
\ No newline at end of file