Browse Source

fix(security): properly handle CORS with request Origin header

- Fixed CORS implementation to comply with specification
- Access-Control-Allow-Origin cannot have multiple comma-separated values
- Now extracts Origin from request header and validates against allowed list
- Returns matching origin if allowed, first allowed origin otherwise
- Updated getCorsHeaders() to accept requestOrigin parameter
- Updated handleCorsPreflightRequest() to accept requestOrigin
- Removed module-level corsHeaders, now generated per-request
- Deployed all 13 WebUI-facing Edge Functions with fix

Environment variable format:
EDGE_FUNCTION_ALLOWED_ORIGINS="https://shopcall.ai,http://192.168.2.112:8081"

Functions updated:
- custom-content-* (7 functions)
- api
- api-key-management
- auth
- complete-shoprenter-install
- scraper-management
- validate-shoprenter-hmac
Fszontagh 4 months ago
parent
commit
2a469c1369

+ 27 - 4
supabase/functions/_shared/cors.ts

@@ -8,20 +8,42 @@
 /**
  * Get CORS headers with allowed origins from environment
  *
+ * @param requestOrigin - The Origin header from the request (optional)
  * @param additionalMethods - Additional HTTP methods to allow (default: GET, POST, OPTIONS)
  * @returns CORS headers object
  */
-export function getCorsHeaders(additionalMethods: string[] = []): Record<string, string> {
+export function getCorsHeaders(requestOrigin?: string, additionalMethods: string[] = []): Record<string, string> {
   // Get allowed origins from environment variable
   // Format: comma-separated list, e.g., "https://shopcall.ai,http://192.168.2.112:8081"
   const allowedOriginsEnv = Deno.env.get('EDGE_FUNCTION_ALLOWED_ORIGINS') || '*';
 
+  // Determine the Access-Control-Allow-Origin value
+  let allowOrigin = '*';
+
+  if (allowedOriginsEnv !== '*' && requestOrigin) {
+    // Parse allowed origins
+    const allowedOrigins = allowedOriginsEnv.split(',').map(o => o.trim());
+
+    // Check if request origin is in allowed list
+    if (allowedOrigins.includes(requestOrigin)) {
+      allowOrigin = requestOrigin;
+    } else {
+      // Request origin not allowed - return first allowed origin as fallback
+      // This will cause CORS error on browser side, which is expected behavior
+      allowOrigin = allowedOrigins[0] || '*';
+    }
+  } else if (allowedOriginsEnv !== '*') {
+    // No request origin provided, use first allowed origin
+    const allowedOrigins = allowedOriginsEnv.split(',').map(o => o.trim());
+    allowOrigin = allowedOrigins[0] || '*';
+  }
+
   // Default methods
   const defaultMethods = ['GET', 'POST', 'OPTIONS'];
   const allMethods = [...new Set([...defaultMethods, ...additionalMethods])];
 
   return {
-    'Access-Control-Allow-Origin': allowedOriginsEnv,
+    'Access-Control-Allow-Origin': allowOrigin,
     'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
     'Access-Control-Allow-Methods': allMethods.join(', '),
   };
@@ -30,11 +52,12 @@ export function getCorsHeaders(additionalMethods: string[] = []): Record<string,
 /**
  * Handle CORS preflight request
  *
+ * @param requestOrigin - The Origin header from the request (optional)
  * @param additionalMethods - Additional HTTP methods to allow
  * @returns Response for OPTIONS request
  */
-export function handleCorsPreflightRequest(additionalMethods: string[] = []): Response {
+export function handleCorsPreflightRequest(requestOrigin?: string, additionalMethods: string[] = []): Response {
   return new Response(null, {
-    headers: getCorsHeaders(additionalMethods)
+    headers: getCorsHeaders(requestOrigin, additionalMethods)
   });
 }

+ 4 - 9
supabase/functions/api-key-management/index.ts

@@ -403,19 +403,14 @@ async function handleRotateKey(
 
 Deno.serve(
   wrapHandler(FUNCTION_NAME, async (req: Request): Promise<Response> => {
+    const origin = req.headers.get('Origin') || undefined;
+    const corsHeaders = getCorsHeaders(origin);
+
     const url = new URL(req.url);
 
     // Handle CORS preflight
     if (req.method === "OPTIONS") {
-      return new Response(null, {
-        status: 204,
-        headers: {
-          "Access-Control-Allow-Origin": "*",
-          "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
-          "Access-Control-Allow-Headers": "Authorization, Content-Type",
-          "Access-Control-Max-Age": "86400",
-        },
-      });
+      return handleCorsPreflightRequest(origin);
     }
 
     // Initialize Supabase clients

+ 0 - 1
supabase/functions/api/index.ts

@@ -4,7 +4,6 @@ import { wrapHandler } from '../_shared/error-handler.ts'
 
 import { getCorsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts';
 
-const corsHeaders = getCorsHeaders(['PUT', 'DELETE']);
 
 // Handle OPTIONS requests BEFORE wrapping with error handler to avoid timeouts
 serve(async (req) => {

+ 4 - 3
supabase/functions/auth/index.ts

@@ -3,8 +3,6 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
 import { wrapHandler, logError } from '../_shared/error-handler.ts'
 import { getCorsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts';
 
-const corsHeaders = getCorsHeaders();
-
 function generateOTP(): string {
   return Math.floor(100000 + Math.random() * 900000).toString()
 }
@@ -74,8 +72,11 @@ async function sendOTPEmail(email: string, otp: string, userName: string): Promi
 }
 
 serve(wrapHandler('auth', async (req) => {
+  const origin = req.headers.get('Origin') || undefined;
+  const corsHeaders = getCorsHeaders(origin);
+
   if (req.method === 'OPTIONS') {
-    return handleCorsPreflightRequest()
+    return handleCorsPreflightRequest(origin)
   }
     const url = new URL(req.url)
     const path = url.pathname.replace('/auth/', '')

+ 4 - 2
supabase/functions/complete-shoprenter-install/index.ts

@@ -5,7 +5,6 @@ import { createScraperClient } from '../_shared/scraper-client.ts'
 
 import { getCorsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts';
 
-const corsHeaders = getCorsHeaders();
 
 // Register store with scraper service
 async function registerStoreWithScraper(
@@ -62,8 +61,11 @@ async function registerStoreWithScraper(
 }
 
 serve(wrapHandler('complete-shoprenter-install', async (req) => {
+  const origin = req.headers.get('Origin') || undefined;
+  const corsHeaders = getCorsHeaders(origin);
+
   if (req.method === 'OPTIONS') {
-    return handleCorsPreflightRequest()
+    return handleCorsPreflightRequest(origin)
   }
 
   try {

+ 4 - 2
supabase/functions/custom-content-create/index.ts

@@ -19,12 +19,14 @@ import { syncCustomContent } from '../_shared/qdrant-client.ts';
 import { validateContent } from '../_shared/content-validator.ts';
 import { getCorsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts';
 
-const corsHeaders = getCorsHeaders();
 
 Deno.serve(async (req) => {
+  const origin = req.headers.get('Origin') || undefined;
+  const corsHeaders = getCorsHeaders(origin);
+
   // Handle CORS preflight
   if (req.method === 'OPTIONS') {
-    return handleCorsPreflightRequest();
+    return handleCorsPreflightRequest(origin);
   }
 
   try {

+ 3 - 1
supabase/functions/custom-content-delete/index.ts

@@ -15,9 +15,11 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
 import { deleteCustomContent } from '../_shared/qdrant-client.ts';
 import { getCorsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts';
 
-const corsHeaders = getCorsHeaders(['DELETE']);
 
 Deno.serve(async (req) => {
+  const origin = req.headers.get('Origin') || undefined;
+  const corsHeaders = getCorsHeaders(origin);
+
   // Handle CORS preflight
   if (req.method === 'OPTIONS') {
     return handleCorsPreflightRequest(['DELETE']);

+ 4 - 2
supabase/functions/custom-content-list/index.ts

@@ -9,12 +9,14 @@
 import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
 import { getCorsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts';
 
-const corsHeaders = getCorsHeaders();
 
 Deno.serve(async (req) => {
+  const origin = req.headers.get('Origin') || undefined;
+  const corsHeaders = getCorsHeaders(origin);
+
   // Handle CORS preflight
   if (req.method === 'OPTIONS') {
-    return handleCorsPreflightRequest();
+    return handleCorsPreflightRequest(origin);
   }
 
   try {

+ 4 - 2
supabase/functions/custom-content-retry/index.ts

@@ -11,12 +11,14 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
 
 import { getCorsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts';
 
-const corsHeaders = getCorsHeaders();
 
 Deno.serve(async (req) => {
+  const origin = req.headers.get('Origin') || undefined;
+  const corsHeaders = getCorsHeaders(origin);
+
   // Handle CORS preflight
   if (req.method === 'OPTIONS') {
-    return handleCorsPreflightRequest();
+    return handleCorsPreflightRequest(origin);
   }
 
   try {

+ 4 - 2
supabase/functions/custom-content-sync-status/index.ts

@@ -11,12 +11,14 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
 
 import { getCorsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts';
 
-const corsHeaders = getCorsHeaders();
 
 Deno.serve(async (req) => {
+  const origin = req.headers.get('Origin') || undefined;
+  const corsHeaders = getCorsHeaders(origin);
+
   // Handle CORS preflight
   if (req.method === 'OPTIONS') {
-    return handleCorsPreflightRequest();
+    return handleCorsPreflightRequest(origin);
   }
 
   try {

+ 4 - 2
supabase/functions/custom-content-upload/index.ts

@@ -22,7 +22,6 @@ import { calculateChecksum } from '../_shared/pdf-processor.ts';
 
 import { getCorsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts';
 
-const corsHeaders = getCorsHeaders();
 
 interface UploadRequest {
   file: File;
@@ -31,9 +30,12 @@ interface UploadRequest {
 }
 
 Deno.serve(async (req) => {
+  const origin = req.headers.get('Origin') || undefined;
+  const corsHeaders = getCorsHeaders(origin);
+
   // Handle CORS preflight
   if (req.method === 'OPTIONS') {
-    return handleCorsPreflightRequest();
+    return handleCorsPreflightRequest(origin);
   }
 
   try {

+ 4 - 2
supabase/functions/custom-content-view/index.ts

@@ -12,12 +12,14 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
 
 import { getCorsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts';
 
-const corsHeaders = getCorsHeaders();
 
 Deno.serve(async (req) => {
+  const origin = req.headers.get('Origin') || undefined;
+  const corsHeaders = getCorsHeaders(origin);
+
   // Handle CORS preflight
   if (req.method === 'OPTIONS') {
-    return handleCorsPreflightRequest();
+    return handleCorsPreflightRequest(origin);
   }
 
   try {

+ 4 - 2
supabase/functions/scraper-management/index.ts

@@ -14,7 +14,6 @@ import { createScraperClient, validateSameDomain } from '../_shared/scraper-clie
 
 import { getCorsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts';
 
-const corsHeaders = getCorsHeaders();
 
 interface StoreRow {
   id: string;
@@ -63,9 +62,12 @@ async function updateStoreScraperStatus(
 }
 
 Deno.serve(async (req) => {
+  const origin = req.headers.get('Origin') || undefined;
+  const corsHeaders = getCorsHeaders(origin);
+
   // Handle CORS preflight requests
   if (req.method === 'OPTIONS') {
-    return handleCorsPreflightRequest();
+    return handleCorsPreflightRequest(origin);
   }
 
   try {

+ 4 - 2
supabase/functions/validate-shoprenter-hmac/index.ts

@@ -27,7 +27,6 @@ async function calculateHmacSha256(secret: string, message: string): Promise<str
 
 import { getCorsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts';
 
-const corsHeaders = getCorsHeaders();
 
 // Validate HMAC signature from ShopRenter
 // Per ShopRenter documentation, HMAC is calculated from the query string without HMAC parameter
@@ -78,8 +77,11 @@ async function validateHMAC(params: Record<string, string>, clientSecret: string
 }
 
 serve(wrapHandler('validate-shoprenter-hmac', async (req) => {
+  const origin = req.headers.get('Origin') || undefined;
+  const corsHeaders = getCorsHeaders(origin);
+
   if (req.method === 'OPTIONS') {
-    return handleCorsPreflightRequest()
+    return handleCorsPreflightRequest(origin)
   }
 
   try {