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