From bf2c2417496eaa2e65e0c57de3987f494b126c75 Mon Sep 17 00:00:00 2001 From: Sebastion Date: Tue, 14 Jul 2026 21:37:14 +0100 Subject: [PATCH] fix(api/fetchRes): mitigate SSRF by validating target URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/fetchRes proxy endpoint accepted an arbitrary URL from the request body and passed it directly to fetch(), returning the response to the caller. Any user with the site auth code could use it to reach internal services (cloud metadata endpoints, RFC1918 hosts, loopback, non-http(s) schemes) — a classic CWE-918 SSRF primitive. The endpoint now: - rejects non-http(s) schemes - rejects URLs containing embedded credentials - rejects hostnames resolving to loopback/private/link-local/CGNAT/ multicast/reserved ranges, IPv6 ULA/link-local, IPv4-mapped variants and well-known cloud metadata hostnames - follows redirects manually and re-validates each hop (max 5) --- functions/api/fetchRes.js | 110 +++++++++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 2 deletions(-) diff --git a/functions/api/fetchRes.js b/functions/api/fetchRes.js index dadfc77c..c7d5c5b4 100644 --- a/functions/api/fetchRes.js +++ b/functions/api/fetchRes.js @@ -4,6 +4,58 @@ */ import { dualAuthCheck } from '../utils/auth/dualAuth.js'; +/** + * Determine whether a hostname refers to a private, loopback, link-local, + * cloud-metadata, or otherwise internal network address. Used to prevent + * SSRF against internal services from the proxy endpoint. + * + * @param {string} hostname - hostname or IP literal from a parsed URL + * @returns {boolean} + */ +function isPrivateHostname(hostname) { + if (!hostname) return true; + let h = hostname.toLowerCase(); + // Strip IPv6 brackets + if (h.startsWith('[') && h.endsWith(']')) { + h = h.slice(1, -1); + } + + // Obvious local names + if (h === 'localhost' || h === 'ip6-localhost' || h === 'ip6-loopback') return true; + if (h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) return true; + + // Cloud metadata service hostnames + if (h === 'metadata.google.internal' || h === 'metadata.goog') return true; + + // IPv4 literal check + const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4) { + const [a, b] = [parseInt(ipv4[1], 10), parseInt(ipv4[2], 10)]; + if (a === 10) return true; // 10.0.0.0/8 + if (a === 127) return true; // loopback + if (a === 0) return true; // 0.0.0.0/8 + if (a === 169 && b === 254) return true; // link-local / AWS metadata 169.254.169.254 + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64.0.0/10 + if (a >= 224) return true; // multicast / reserved + return false; + } + + // IPv6 literal check (basic) + if (h.includes(':')) { + if (h === '::' || h === '::1') return true; + if (h.startsWith('fe80:') || h.startsWith('fe80::')) return true; // link-local + if (h.startsWith('fc') || h.startsWith('fd')) return true; // unique local fc00::/7 + // IPv4-mapped IPv6 (::ffff:a.b.c.d) + const mapped = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (mapped) return isPrivateHostname(mapped[1]); + return false; + } + + return false; +} + export async function onRequest(context) { // 获取请求体中URL的内容 const { @@ -30,9 +82,63 @@ export async function onRequest(context) { if (targetUrl === undefined) { return new Response('URL is required', { status: 400 }) } - const response = await fetch(targetUrl); + + // Validate the target URL to mitigate SSRF (CWE-918). + let parsed; + try { + parsed = new URL(targetUrl); + } catch (e) { + return new Response(JSON.stringify({ error: 'Invalid URL' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Only allow http(s); block file:, gopher:, data:, ftp:, blob:, etc. + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return new Response(JSON.stringify({ error: 'Only http(s) URLs are allowed' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Refuse embedded credentials (used to smuggle auth into internal targets). + if (parsed.username || parsed.password) { + return new Response(JSON.stringify({ error: 'Credentials in URL are not allowed' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Block private / loopback / link-local / metadata targets. + if (isPrivateHostname(parsed.hostname)) { + return new Response(JSON.stringify({ error: 'Access to internal addresses is not allowed' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Follow redirects manually so a permitted host cannot redirect us onto + // an internal address without re-validation. + let response = await fetch(parsed.toString(), { redirect: 'manual' }); + let hops = 0; + while (response.status >= 300 && response.status < 400 && response.headers.get('location') && hops < 5) { + const next = new URL(response.headers.get('location'), parsed); + if ((next.protocol !== 'http:' && next.protocol !== 'https:') || + next.username || next.password || + isPrivateHostname(next.hostname)) { + return new Response(JSON.stringify({ error: 'Redirect to disallowed target' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + response = await fetch(next.toString(), { redirect: 'manual' }); + hops++; + } + const headers = new Headers(response.headers); return new Response(response.body, { - headers: headers + headers: headers, + status: response.status }) }