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"); +}); 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 7b36c98cd..6b87f4c9b 100644 --- a/supabase/functions/proxy-health/index.ts +++ b/supabase/functions/proxy-health/index.ts @@ -10,10 +10,17 @@ // 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, mergeCsvHeaderValues } 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) { + const shared = getCorsHeaders(req) + return { + ...shared, + 'Access-Control-Allow-Headers': mergeCsvHeaderValues( + shared['Access-Control-Allow-Headers'], + 'authorization, x-client-info, apikey, content-type', + ), + } } // Limiares de alerta — ajustáveis via env vars. @@ -168,7 +175,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 +199,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 +266,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..c9d30df40 100644 --- a/supabase/functions/proxy-metrics/index.ts +++ b/supabase/functions/proxy-metrics/index.ts @@ -15,17 +15,24 @@ // Output: text/plain; version=0.0.4 (Prometheus exposition format). import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0' +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') ?? '' -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) { + const shared = getCorsHeaders(req) + return { + ...shared, + 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', + 'Cache-Control': 'no-store', + 'Access-Control-Allow-Headers': mergeCsvHeaderValues( + shared['Access-Control-Allow-Headers'], + 'authorization, content-type', + ), + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + } } type MetricRow = { @@ -195,23 +202,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 +243,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 +252,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) }) })