Merge pull request #625 from sebastiondev/fix/cwe918-fetchres-server-837c

fix(fetchRes): prevent SSRF in server-side URL proxy
This commit is contained in:
叁月柒
2026-07-15 09:51:53 +08:00
committed by GitHub
+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
})
}