You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
114 lines
3.5 KiB
114 lines
3.5 KiB
import * as dnsPromises from "node:dns/promises";
|
|
import { isIP } from "node:net";
|
|
|
|
const PRIVATE_IP_RANGES: Array<{ start: bigint; end: bigint }> = [
|
|
// 10.0.0.0/8
|
|
{ start: BigInt("0x0a000000"), end: BigInt("0x0affffff") },
|
|
// 172.16.0.0/12
|
|
{ start: BigInt("0xac100000"), end: BigInt("0xac1fffff") },
|
|
// 192.168.0.0/16
|
|
{ start: BigInt("0xc0a80000"), end: BigInt("0xc0a8ffff") },
|
|
// 127.0.0.0/8 (loopback)
|
|
{ start: BigInt("0x7f000000"), end: BigInt("0x7fffffff") },
|
|
// 169.254.0.0/16 (link-local)
|
|
{ start: BigInt("0xa9fe0000"), end: BigInt("0xa9feffff") },
|
|
// 0.0.0.0/8
|
|
{ start: BigInt("0x00000000"), end: BigInt("0x00ffffff") },
|
|
// 100.64.0.0/10 (CGNAT)
|
|
{ start: BigInt("0x64400000"), end: BigInt("0x647fffff") },
|
|
];
|
|
|
|
export function isPrivateIp(ip: string): boolean {
|
|
const type = isIP(ip);
|
|
if (type === 4) {
|
|
const parts = ip.split(".").map(Number);
|
|
const numeric =
|
|
(BigInt(parts[0] ?? 0) << BigInt(24)) |
|
|
(BigInt(parts[1] ?? 0) << BigInt(16)) |
|
|
(BigInt(parts[2] ?? 0) << BigInt(8)) |
|
|
BigInt(parts[3] ?? 0);
|
|
return PRIVATE_IP_RANGES.some(
|
|
(r) => numeric >= r.start && numeric <= r.end,
|
|
);
|
|
}
|
|
if (type === 6) {
|
|
const lower = ip.toLowerCase();
|
|
if (lower === "::1") return true;
|
|
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
if (lower.startsWith("fe80")) return true;
|
|
if (lower.startsWith("::ffff:")) {
|
|
const v4 = lower.slice(7);
|
|
return isPrivateIp(v4);
|
|
}
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function matchDomain(hostname: string, pattern: string): boolean {
|
|
if (pattern === "*") return true;
|
|
const lowerHost = hostname.toLowerCase();
|
|
const lowerPattern = pattern.toLowerCase();
|
|
if (lowerPattern.startsWith("*.")) {
|
|
const suffix = lowerPattern.slice(2);
|
|
return lowerHost === suffix || lowerHost.endsWith("." + suffix);
|
|
}
|
|
return lowerHost === lowerPattern;
|
|
}
|
|
|
|
export function checkDomainAccess(
|
|
hostname: string,
|
|
allowedDomains: string[],
|
|
blockedDomains: string[],
|
|
): void {
|
|
for (const blocked of blockedDomains) {
|
|
if (matchDomain(hostname, blocked)) {
|
|
throw new Error(`域名被黑名单禁止: ${hostname}`);
|
|
}
|
|
}
|
|
// 白名单为空时允许所有域名(仅靠黑名单限制)
|
|
if (allowedDomains.length > 0) {
|
|
const allowed = allowedDomains.some((d) => matchDomain(hostname, d));
|
|
if (!allowed) {
|
|
throw new Error(`域名不在白名单中: ${hostname}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function assertSafeUrl(url: string): Promise<void> {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
throw new Error("无效的 URL 格式");
|
|
}
|
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
throw new Error("仅支持 http/https 协议");
|
|
}
|
|
const hostname = parsed.hostname;
|
|
if (!hostname) {
|
|
throw new Error("URL 缺少主机名");
|
|
}
|
|
// 如果 hostname 本身就是 IP,直接检查
|
|
if (isIP(hostname)) {
|
|
if (isPrivateIp(hostname)) {
|
|
throw new Error(`禁止访问内网地址: ${hostname}`);
|
|
}
|
|
return;
|
|
}
|
|
// DNS 解析,检查所有返回的 IP
|
|
let addresses: { address: string; family: number }[];
|
|
try {
|
|
addresses = await dnsPromises.lookup(hostname, { all: true });
|
|
} catch {
|
|
throw new Error(`DNS 解析失败: ${hostname}`);
|
|
}
|
|
if (addresses.length === 0) {
|
|
throw new Error(`DNS 解析无结果: ${hostname}`);
|
|
}
|
|
for (const addr of addresses) {
|
|
if (isPrivateIp(addr.address)) {
|
|
throw new Error(`禁止访问内网地址: ${addr.address} (${hostname})`);
|
|
}
|
|
}
|
|
}
|
|
|