From 9ecaed72ec44158409feddf4ebb319220ddc3cd4 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Mon, 27 Apr 2026 08:59:26 -0300 Subject: [PATCH 1/4] fix(security): tighten CORS policy in proxy observability functions --- supabase/functions/proxy-health/index.ts | 15 ++++++++----- supabase/functions/proxy-metrics/index.ts | 27 +++++++++++++---------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/supabase/functions/proxy-health/index.ts b/supabase/functions/proxy-health/index.ts index 7b36c98cd..f3284344a 100644 --- a/supabase/functions/proxy-health/index.ts +++ b/supabase/functions/proxy-health/index.ts @@ -10,10 +10,13 @@ // Ideal para chamar via cron a cada 5 minutos com evaluate=1. import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.49.1' +import { getCorsHeaders } from '../_shared/validation.ts' -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +function getJsonCorsHeaders(req?: Request) { + return { + ...getCorsHeaders(req), + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + } } // Limiares de alerta — ajustáveis via env vars. @@ -168,7 +171,7 @@ export function evaluateAlerts(m: ComputedMetrics): AlertCandidate[] { Deno.serve(async (req) => { if (req.method === 'OPTIONS') { - return new Response('ok', { headers: corsHeaders }) + return new Response('ok', { headers: getJsonCorsHeaders(req) }) } const url = new URL(req.url) @@ -192,7 +195,7 @@ Deno.serve(async (req) => { if (error) { return new Response(JSON.stringify({ error: error.message }), { - status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, headers: { ...getJsonCorsHeaders(req), 'Content-Type': 'application/json' }, }) } @@ -259,6 +262,6 @@ Deno.serve(async (req) => { alert_candidates: candidates, fired_alerts: firedAlerts, }, null, 2), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + headers: { ...getJsonCorsHeaders(req), 'Content-Type': 'application/json' }, }) }) diff --git a/supabase/functions/proxy-metrics/index.ts b/supabase/functions/proxy-metrics/index.ts index 2e5e97a4b..9bcaea884 100644 --- a/supabase/functions/proxy-metrics/index.ts +++ b/supabase/functions/proxy-metrics/index.ts @@ -15,17 +15,20 @@ // Output: text/plain; version=0.0.4 (Prometheus exposition format). import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0' +import { getCorsHeaders } from '../_shared/validation.ts' const SUPABASE_URL = Deno.env.get('SUPABASE_URL')! const SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! const SCRAPE_TOKEN = Deno.env.get('PROXY_METRICS_TOKEN') ?? '' -const PROM_HEADERS = { - 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', - 'Cache-Control': 'no-store', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, content-type', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', +function getPromHeaders(req?: Request) { + return { + ...getCorsHeaders(req), + 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', + 'Cache-Control': 'no-store', + 'Access-Control-Allow-Headers': 'authorization, content-type', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + } } type MetricRow = { @@ -195,23 +198,23 @@ function buildExposition(rows: MetricRow[], windowKey: WindowKey, generatedAtMs: Deno.serve(async (req) => { if (req.method === 'OPTIONS') { - return new Response('ok', { headers: PROM_HEADERS }) + return new Response('ok', { headers: getPromHeaders(req) }) } if (req.method !== 'GET') { - return new Response('Method not allowed\n', { status: 405, headers: PROM_HEADERS }) + return new Response('Method not allowed\n', { status: 405, headers: getPromHeaders(req) }) } // Auth — fail-closed if no token configured. if (!SCRAPE_TOKEN) { return new Response( '# proxy-metrics: PROXY_METRICS_TOKEN secret is not configured.\n', - { status: 503, headers: PROM_HEADERS }, + { status: 503, headers: getPromHeaders(req) }, ) } const auth = req.headers.get('Authorization') ?? '' const provided = auth.startsWith('Bearer ') ? auth.slice(7) : auth if (provided !== SCRAPE_TOKEN) { - return new Response('# unauthorized\n', { status: 401, headers: PROM_HEADERS }) + return new Response('# unauthorized\n', { status: 401, headers: getPromHeaders(req) }) } const url = new URL(req.url) @@ -236,7 +239,7 @@ Deno.serve(async (req) => { if (error) { return new Response( `# error fetching proxy_metrics: ${error.message.replace(/\n/g, ' ')}\n`, - { status: 500, headers: PROM_HEADERS }, + { status: 500, headers: getPromHeaders(req) }, ) } if (!data || data.length === 0) break @@ -245,5 +248,5 @@ Deno.serve(async (req) => { } const body = buildExposition(rows, windowKey, Date.now()) - return new Response(body, { status: 200, headers: PROM_HEADERS }) + return new Response(body, { status: 200, headers: getPromHeaders(req) }) }) From 5e26ec0dd3c1e780d6eabcdf20d87f8f6d7fba86 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Mon, 27 Apr 2026 09:05:29 -0300 Subject: [PATCH 2/4] fix(security): preserve shared CORS headers in proxy endpoints --- supabase/functions/proxy-health/index.ts | 20 ++++++++++++++++++-- supabase/functions/proxy-metrics/index.ts | 20 ++++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/supabase/functions/proxy-health/index.ts b/supabase/functions/proxy-health/index.ts index f3284344a..94480cf4b 100644 --- a/supabase/functions/proxy-health/index.ts +++ b/supabase/functions/proxy-health/index.ts @@ -12,10 +12,26 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.49.1' import { getCorsHeaders } from '../_shared/validation.ts' +function mergeCsvHeaderValues(...values: Array): string { + const merged = new Set() + for (const value of values) { + if (!value) continue + for (const token of value.split(',')) { + const normalized = token.trim().toLowerCase() + if (normalized) merged.add(normalized) + } + } + return Array.from(merged).join(', ') +} + function getJsonCorsHeaders(req?: Request) { + const shared = getCorsHeaders(req) return { - ...getCorsHeaders(req), - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + ...shared, + 'Access-Control-Allow-Headers': mergeCsvHeaderValues( + shared['Access-Control-Allow-Headers'], + 'authorization, x-client-info, apikey, content-type', + ), } } diff --git a/supabase/functions/proxy-metrics/index.ts b/supabase/functions/proxy-metrics/index.ts index 9bcaea884..b63b60e5e 100644 --- a/supabase/functions/proxy-metrics/index.ts +++ b/supabase/functions/proxy-metrics/index.ts @@ -21,12 +21,28 @@ const SUPABASE_URL = Deno.env.get('SUPABASE_URL')! const SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! const SCRAPE_TOKEN = Deno.env.get('PROXY_METRICS_TOKEN') ?? '' +function mergeCsvHeaderValues(...values: Array): string { + const merged = new Set() + for (const value of values) { + if (!value) continue + for (const token of value.split(',')) { + const normalized = token.trim().toLowerCase() + if (normalized) merged.add(normalized) + } + } + return Array.from(merged).join(', ') +} + function getPromHeaders(req?: Request) { + const shared = getCorsHeaders(req) return { - ...getCorsHeaders(req), + ...shared, 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store', - 'Access-Control-Allow-Headers': 'authorization, content-type', + 'Access-Control-Allow-Headers': mergeCsvHeaderValues( + shared['Access-Control-Allow-Headers'], + 'authorization, content-type', + ), 'Access-Control-Allow-Methods': 'GET, OPTIONS', } } From 161489e997a66b7d326566cb122f434dde700067 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Mon, 27 Apr 2026 09:11:01 -0300 Subject: [PATCH 3/4] refactor(security): share CSV header merge utility across edge functions --- supabase/functions/_shared/validation.ts | 13 +++++++++++++ supabase/functions/proxy-health/index.ts | 14 +------------- supabase/functions/proxy-metrics/index.ts | 14 +------------- 3 files changed, 15 insertions(+), 26 deletions(-) diff --git a/supabase/functions/_shared/validation.ts b/supabase/functions/_shared/validation.ts index 0ecb8118c..be7599315 100644 --- a/supabase/functions/_shared/validation.ts +++ b/supabase/functions/_shared/validation.ts @@ -211,6 +211,19 @@ function isAllowedOrigin(origin: string): boolean { return EXACT_ALLOWED_ORIGINS.has(origin) || LOCAL_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin)); } +/** Merge comma-separated header values, normalizing casing and deduplicating tokens. */ +export function mergeCsvHeaderValues(...values: Array): string { + const merged = new Set() + for (const value of values) { + if (!value) continue + for (const token of value.split(',')) { + const normalized = token.trim().toLowerCase() + if (normalized) merged.add(normalized) + } + } + return Array.from(merged).join(', ') +} + /** Security headers applied to all responses */ const SECURITY_HEADERS: Record = { 'X-Content-Type-Options': 'nosniff', diff --git a/supabase/functions/proxy-health/index.ts b/supabase/functions/proxy-health/index.ts index 94480cf4b..6b87f4c9b 100644 --- a/supabase/functions/proxy-health/index.ts +++ b/supabase/functions/proxy-health/index.ts @@ -10,19 +10,7 @@ // Ideal para chamar via cron a cada 5 minutos com evaluate=1. import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.49.1' -import { getCorsHeaders } from '../_shared/validation.ts' - -function mergeCsvHeaderValues(...values: Array): string { - const merged = new Set() - for (const value of values) { - if (!value) continue - for (const token of value.split(',')) { - const normalized = token.trim().toLowerCase() - if (normalized) merged.add(normalized) - } - } - return Array.from(merged).join(', ') -} +import { getCorsHeaders, mergeCsvHeaderValues } from '../_shared/validation.ts' function getJsonCorsHeaders(req?: Request) { const shared = getCorsHeaders(req) diff --git a/supabase/functions/proxy-metrics/index.ts b/supabase/functions/proxy-metrics/index.ts index b63b60e5e..c9d30df40 100644 --- a/supabase/functions/proxy-metrics/index.ts +++ b/supabase/functions/proxy-metrics/index.ts @@ -15,24 +15,12 @@ // Output: text/plain; version=0.0.4 (Prometheus exposition format). import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0' -import { getCorsHeaders } from '../_shared/validation.ts' +import { getCorsHeaders, mergeCsvHeaderValues } from '../_shared/validation.ts' const SUPABASE_URL = Deno.env.get('SUPABASE_URL')! const SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! const SCRAPE_TOKEN = Deno.env.get('PROXY_METRICS_TOKEN') ?? '' -function mergeCsvHeaderValues(...values: Array): string { - const merged = new Set() - for (const value of values) { - if (!value) continue - for (const token of value.split(',')) { - const normalized = token.trim().toLowerCase() - if (normalized) merged.add(normalized) - } - } - return Array.from(merged).join(', ') -} - function getPromHeaders(req?: Request) { const shared = getCorsHeaders(req) return { From 82e2de132fc7b50b28556860b224c9a95a52a2aa Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Mon, 27 Apr 2026 09:12:13 -0300 Subject: [PATCH 4/4] test(security): add coverage for CORS header merge utility --- .../__tests__/validation-headers.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 supabase/functions/_shared/__tests__/validation-headers.test.ts diff --git a/supabase/functions/_shared/__tests__/validation-headers.test.ts b/supabase/functions/_shared/__tests__/validation-headers.test.ts new file mode 100644 index 000000000..63bead3cb --- /dev/null +++ b/supabase/functions/_shared/__tests__/validation-headers.test.ts @@ -0,0 +1,32 @@ +import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { mergeCsvHeaderValues } from "../validation.ts"; + +Deno.test("mergeCsvHeaderValues: normaliza casing e remove duplicados", () => { + const merged = mergeCsvHeaderValues( + "Authorization, Content-Type, X-Request-Id", + "authorization,content-type, x-request-id", + ); + + assertEquals(merged, "authorization, content-type, x-request-id"); +}); + +Deno.test("mergeCsvHeaderValues: ignora valores vazios e espaços extras", () => { + const merged = mergeCsvHeaderValues( + " authorization , content-type ", + undefined, + "", + "x-client-info, ", + ); + + assertEquals(merged, "authorization, content-type, x-client-info"); +}); + +Deno.test("mergeCsvHeaderValues: preserva ordem de primeira ocorrência", () => { + const merged = mergeCsvHeaderValues( + "x-custom-b, x-custom-a", + "x-custom-a, x-custom-c", + "x-custom-b", + ); + + assertEquals(merged, "x-custom-b, x-custom-a, x-custom-c"); +});