fix(api/fetchRes): mitigate SSRF by validating target URL

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)
This commit is contained in:
Sebastion
2026-07-14 21:37:14 +01:00
parent 98d9e8cc97
commit bf2c241749
+108 -2
View File
@@ -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
})
}