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.
 
 
 
 
 

91 lines
3.0 KiB

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<string> {
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;
}