glp-companion/supabase/functions/recipe-proxy/index.ts

119 lines
4.0 KiB
TypeScript

// 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://<project>.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;
}