mirror of
https://github.com/ZSCGR/CloudFlare-ImgBed.git
synced 2026-08-13 04:03:42 +08:00
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:
+108
-2
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user