119 lines
4.0 KiB
TypeScript
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;
|
|
}
|