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

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})`);
}
}
}