// Supabase Edge Function: recipe-proxy // // Browsers cannot download recipe pages cross-origin — recipe sites never // send `Access-Control-Allow-Origin`, so the Recipe Vault's web build needs // a server-side relay. This function fetches the target URL with // browser-like headers and returns the HTML with permissive CORS headers. // // Deploy (requires the Supabase CLI): // supabase functions deploy recipe-proxy // Then run the web app with: // flutter run -d chrome \ // --dart-define=RECIPE_PROXY_URL=https://.supabase.co/functions/v1/recipe-proxy // // Security posture (mirrors the platform's privacy tier): // * Only http(s) URLs are accepted; localhost and RFC-1918/link-local // hosts are refused so the function cannot be abused as an SSRF probe // into internal networks or cloud metadata endpoints. // * The response body is capped at 2 MiB — recipe pages are far smaller. // * Only GET semantics are exposed; the function never executes or stores // anything server-side. const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'GET, OPTIONS', }; /// Maximum relayed response size (2 MiB). const maxBodyBytes = 2 * 1024 * 1024; /// Browser-like headers so upstream anti-bot checks accept the request. const browserHeaders = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9', }; Deno.serve(async (req) => { if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } const targetParam = new URL(req.url).searchParams.get('url'); if (!targetParam) { return jsonError(400, 'Missing required "url" query parameter.'); } let target: URL; try { target = new URL(targetParam); } catch { return jsonError(400, 'The "url" parameter is not a valid URL.'); } if (target.protocol !== 'https:' && target.protocol !== 'http:') { return jsonError(400, 'Only http(s) URLs can be proxied.'); } if (isPrivateHost(target.hostname)) { return jsonError( 400, 'Private or loopback hosts cannot be proxied (SSRF protection).', ); } try { const upstream = await fetch(target, { headers: browserHeaders, redirect: 'follow', signal: AbortSignal.timeout(15_000), }); const declaredLength = Number(upstream.headers.get('content-length') ?? 0); if (declaredLength > maxBodyBytes) { return jsonError(413, 'The requested page is too large to proxy.'); } const body = await upstream.text(); if (body.length > maxBodyBytes) { return jsonError(413, 'The requested page is too large to proxy.'); } return new Response(body, { status: upstream.status, headers: { ...corsHeaders, 'Content-Type': upstream.headers.get('content-type') ?? 'text/html; charset=utf-8', }, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return jsonError(502, `Upstream download failed: ${message}`); } }); function jsonError(status: number, message: string): Response { return new Response(JSON.stringify({ error: message }), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } /// Refuses loopback, RFC-1918, link-local, and IPv6-loopback hosts so the /// relay cannot reach internal services or cloud metadata endpoints. function isPrivateHost(hostname: string): boolean { const host = hostname.toLowerCase(); if (host === 'localhost' || host === '::1' || host === '[::1]') return true; if (/^127\./.test(host)) return true; if (/^10\./.test(host)) return true; if (/^192\.168\./.test(host)) return true; if (/^169\.254\./.test(host)) return true; if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true; return false; }