mirror of
https://github.com/ZSCGR/CloudFlare-ImgBed.git
synced 2026-08-13 04:03:42 +08:00
190 lines
6.5 KiB
JavaScript
190 lines
6.5 KiB
JavaScript
/**
|
|
* 远程资源代理读取 API
|
|
* 负责在鉴权后拉取请求体中指定 URL 的资源并透传响应内容
|
|
*/
|
|
import { dualAuthCheck } from '../utils/auth/dualAuth.js';
|
|
|
|
const IS_NODE_RUNTIME = typeof process !== 'undefined' && Boolean(process.versions?.node);
|
|
const HOP_BY_HOP_HEADERS = [
|
|
'connection',
|
|
'keep-alive',
|
|
'proxy-authenticate',
|
|
'proxy-authorization',
|
|
'te',
|
|
'trailer',
|
|
'transfer-encoding',
|
|
'upgrade'
|
|
];
|
|
|
|
/**
|
|
* Build response headers that are safe to send on a new proxy response.
|
|
* Node's fetch transparently decompresses upstream bodies while retaining
|
|
* Content-Encoding/Content-Length, so those headers must be removed there.
|
|
*
|
|
* @param {Headers} responseHeaders - headers returned by the upstream server
|
|
* @returns {Headers}
|
|
*/
|
|
function createProxyHeaders(responseHeaders) {
|
|
const headers = new Headers(responseHeaders);
|
|
|
|
for (const name of HOP_BY_HOP_HEADERS) {
|
|
headers.delete(name);
|
|
}
|
|
|
|
if (IS_NODE_RUNTIME) {
|
|
headers.delete('content-encoding');
|
|
headers.delete('content-length');
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
function fetchTarget(url) {
|
|
const options = { redirect: 'manual' };
|
|
if (IS_NODE_RUNTIME) {
|
|
options.headers = { 'Accept-Encoding': 'identity' };
|
|
}
|
|
return fetch(url.toString(), options);
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
request,
|
|
env,
|
|
params,
|
|
waitUntil,
|
|
next,
|
|
data
|
|
} = context;
|
|
|
|
// 双重鉴权检查
|
|
const url = new URL(request.url);
|
|
const { authorized } = await dualAuthCheck(env, url, request);
|
|
if (!authorized) {
|
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
}
|
|
|
|
const jsonRequest = await request.json();
|
|
const targetUrl = jsonRequest.url;
|
|
if (targetUrl === undefined) {
|
|
return new Response('URL is required', { status: 400 })
|
|
}
|
|
|
|
// 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 currentUrl = parsed;
|
|
let response = await fetchTarget(currentUrl);
|
|
let hops = 0;
|
|
while (response.status >= 300 && response.status < 400 && response.headers.get('location') && hops < 5) {
|
|
const next = new URL(response.headers.get('location'), currentUrl);
|
|
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' }
|
|
});
|
|
}
|
|
currentUrl = next;
|
|
response = await fetchTarget(currentUrl);
|
|
hops++;
|
|
}
|
|
|
|
const headers = createProxyHeaders(response.headers);
|
|
return new Response(response.body, {
|
|
headers: headers,
|
|
status: response.status
|
|
})
|
|
}
|