import { lookup } from "node:dns/promises"; import net, { BlockList } from "node:net"; export class RssUrlUnsafeError extends Error { constructor(message: string) { super(message); this.name = "RssUrlUnsafeError"; } } const PRIVATE_IPV4 = /^(127\.|10\.|172\.(1[6-9]|2\d|3[0-1])\.|192\.168\.|0\.|169\.254\.)/; /** 解析得到的 IP 若落在此块内则禁止 RSS 出站拉取(含常见私网与链路本地)。 */ const rssFetchBlocklist = new BlockList(); rssFetchBlocklist.addSubnet("127.0.0.0", 8, "ipv4"); rssFetchBlocklist.addSubnet("10.0.0.0", 8, "ipv4"); rssFetchBlocklist.addSubnet("172.16.0.0", 12, "ipv4"); rssFetchBlocklist.addSubnet("192.168.0.0", 16, "ipv4"); rssFetchBlocklist.addSubnet("169.254.0.0", 16, "ipv4"); rssFetchBlocklist.addSubnet("0.0.0.0", 8, "ipv4"); rssFetchBlocklist.addSubnet("::1", 128, "ipv6"); rssFetchBlocklist.addSubnet("fe80::", 10, "ipv6"); rssFetchBlocklist.addSubnet("fc00::", 7, "ipv6"); function isBlockedResolvedIp(addr: string): boolean { const fam = net.isIP(addr); if (fam === 4) { return rssFetchBlocklist.check(addr, "ipv4"); } if (fam === 6) { return rssFetchBlocklist.check(addr, "ipv6"); } return false; } /** 仅允许 http(s),主机名为非内网字面量(粗略)。 */ export function assertSafeRssUrl(raw: string) { let url: URL; try { url = new URL(raw); } catch { throw new RssUrlUnsafeError("无效的 URL"); } if (url.protocol !== "http:" && url.protocol !== "https:") { throw new RssUrlUnsafeError("仅支持 http/https"); } if (!url.hostname) { throw new RssUrlUnsafeError("缺少主机名"); } const host = url.hostname.toLowerCase(); if (host === "localhost" || host.endsWith(".localhost")) { throw new RssUrlUnsafeError("禁止 localhost"); } if (PRIVATE_IPV4.test(host)) { throw new RssUrlUnsafeError("禁止内网地址"); } return url.toString(); } /** * 在 `assertSafeRssUrl` 基础上对域名做 DNS 解析,拒绝解析到私网/本地 IP(减轻 DNS 重绑定类 SSRF)。 * 小型站点可接受:仍存在解析与建连之间的 TOCTOU,高威胁环境需代理或专用抓取服务。 */ export async function assertSafeRssUrlForFetch(raw: string): Promise { const normalized = assertSafeRssUrl(raw); const url = new URL(normalized); const hostname = url.hostname; if (net.isIP(hostname)) { if (isBlockedResolvedIp(hostname)) { throw new RssUrlUnsafeError("禁止内网或本地地址"); } return normalized; } let records: { address: string; family: number }[]; try { records = await lookup(hostname, { all: true, verbatim: true }); } catch { throw new RssUrlUnsafeError("RSS 地址域名解析失败"); } if (records.length === 0) { throw new RssUrlUnsafeError("RSS 地址域名无解析记录"); } for (const r of records) { if (isBlockedResolvedIp(r.address)) { throw new RssUrlUnsafeError("RSS 地址解析到内网或本地 IP,已拒绝"); } } return normalized; }