network-guard — SSRF 安全的出口校验
@aalis/util-network-guard 把「fetch 一个由用户、LLM 或入站消息影响到的 URL」这件危险操作收口成安全操作:协议白名单、私网/回环/元数据段封锁、DNS 全解析、逐跳重定向重校验。它是 Aalis 里唯一的「安全拉取外部 URL」助手——所有插件的外部 fetch 都应该走它,而不是裸 fetch。
它是一个纯 util 库,不涉及服务注册与 DI,也不经服务容器。你在 package.json 里依赖 @aalis/util-network-guard,然后直接 import 函数调用即可。
包本身只做校验,不下载、不缓存、不限制体积。体积上限、超时、缓存留给调用方按自己的架构(流式代理、全 buffer 下载、内联 fetch)决定。
威胁模型背景(为什么要防、防的是谁)见 安全模型 §3 safeFetch。
1. 导出 API
safeFetch(url, init?, maxRedirects?) — 首选
export async function safeFetch(
url: string,
init: RequestInit = {},
maxRedirects = MAX_REDIRECTS, // = 5
): Promise<Response>SSRF 安全的 fetch 替代品。它逐跳使用 redirect:'manual',每一跳都重新运行 assertSafeUrl,以此杜绝「初始 host 受信、但 30x 重定向跳到内网」的绕过。
命中 30x(301/302/303/307/308)且带有 Location 时,它把 Location 解析成相对于当前 URL 的绝对地址,校验通过后再跟随后续跳转;非 30x 或没有 Location 时,直接返回该 Response。跳数超过 maxRedirects 时抛出 重定向次数超过上限。
除了手动重定向与每跳校验,跟随时像原生 fetch 一样按规范改写下一跳:301、302 的 POST 与 303 的非 GET、HEAD 改成不带正文的 GET;跨源时去掉 Authorization、Cookie、Proxy-Authorization。
assertSafeUrl(rawUrl) — 只校验 URL,不发请求
export async function assertSafeUrl(rawUrl: string): Promise<URL>校验单条 URL,不发起请求。它要求 URL 能被 new URL() 解析(否则抛 非法 URL)、协议只能是 http: 或 https:(否则抛 仅支持 http/https)、目标端口通过 assertPortAllowed(不在 allowedPorts 列表时抛 拒绝访问端口 N)、host 通过 assertSafeHost。全部通过后返回解析出的 URL 对象。
当你自己管理连接(例如流式代理),只想拿到一个校验过的 URL 时,用它。
assertSafeHost(hostname) — 只校验主机名
export async function assertSafeHost(hostname: string): Promise<void>校验单个 hostname 是否可安全连接。失败时抛 Error,由调用方负责转成 HTTP 状态或写日志。判定规则如下:
- IPv6 字面量带方括号(如
[::1])时,先剥掉方括号再判定。 - 字面 IP:
blockPrivate开启时,命中私网、回环或元数据段即拒绝;命中denyCidrs即拒绝。 localhost/*.localhost/*.local主机名:blockPrivate开启时直接拒绝。- 其它域名:用
dns.lookup(host, { all: true })解析出全部 A/AAAA 记录,只要任意一条命中私网或denyCidrs即拒绝,挡住解析到内网 IP 的域名。它只预检、不连接:之后按名字连接会再解析一次,要封堵 DNS rebinding,连接时用pinnedLookup。
pinnedLookup(hostname, options, callback) — 判定与连接用同一次解析
export const pinnedLookup: LookupFunction // node:net 的 LookupFunction自管连接时作为 net.connect / tls.connect(或 undici Agent 的 connect)的 lookup 传入。它用 dns.lookup(host, { all: true }) 解析出全部地址,经 assertAddressesSafe 判定后,把这次解析的结果交给连接使用;任一地址不过判定时以错误回调,连接失败。判定与连接之间不再解析第二次,因此能封堵 DNS rebinding:先 assertSafeHost 再按名字连接会解析两次,低 TTL 的域名可以在两次之间从公网地址换成内网地址。
回调形状跟随 options.all:带 all: true(Node 20+ 默认开启的 happy-eyeballs)时交回 { address, family }[],不带时交回单个地址与 family。
它只管域名:目标是 IP 字面量时 Node 不调用 lookup,调用方须自行用 assertAddressesSafe 判定。safeFetch 的 dispatcher 即用它连接。
assertAddressesSafe(host, addresses) — 校验一组已解析地址
export function assertAddressesSafe(host: string, addresses: readonly string[]): void逐个校验地址:blockPrivate 开启时命中私网、回环或元数据段即抛错,命中 denyCidrs 即抛错。host 只用于错误信息。assertSafeHost 与 pinnedLookup 共用这一判定。自管连接的目标是 IP 字面量时传 assertAddressesSafe(ip, [ip]),地址按数值认,非规范写法与带 zone id 的写法照样判得出;判过之后按 canonicalAddress(ip) 连接,见下。
canonicalAddress(addr) — 判过的 IP 字面量按它连接
export function canonicalAddress(addr: string): string交回 IP 字面量的规范写法:IPv6 拼回压缩写法(如 0000:0000:0000:0000:0000:ffff:127.0.0.10%lo0 得到 ::ffff:7f00:a%lo0),zone id 原样接在后面;IPv4 与不是 IP 字面量的原样交回。node:net 会把 % 之前超过 39 个字符的写法截短,原样交给 net.connect 时,连到的可能不是判定的那个地址;判定查表用的是同一个规范写法(不带 zone id)。浏览器网络闸即按它连接。
assertPortAllowed(port) — 校验目标端口
export function assertPortAllowed(port: number): void策略配置了 allowedPorts 时,端口不在列表中即抛 拒绝访问端口 N(不在允许列表);未配置时不限。assertSafeUrl 用它判定 URL 的端口。自管连接拿到目标端口后直接调用它,浏览器网络闸即如此。
isPrivateAddress(addr) — 同步纯判定
export function isPrivateAddress(addr: string): boolean判断一个字面 IP 是不是公网单播之外的地址(私网、回环、链路本地、元数据、组播、保留与文档段)。它不做 DNS 查询、无副作用、同步返回。地址先按数值解析、换成规范写法再查表,非规范写法(如 0::1)与带 zone id 的写法(如 ::1%lo0)和规范写法判法相同,% 之前写得多长都一样。命中的段如下:
| 段 | 说明 |
|---|---|
10.0.0.0/8 · 172.16.0.0/12 · 192.168.0.0/16 | RFC1918 私网 |
127.0.0.0/8 | 回环 |
0.0.0.0/8 | 「本网络」 |
169.254.0.0/16 | 链路本地,含 AWS/云元数据 169.254.169.254 |
100.64.0.0/10 · 198.18.0.0/15 | CGNAT、基准测试段 |
192.0.0.0/24 · 192.0.2.0/24 · 198.51.100.0/24 · 203.0.113.0/24 | IETF 协议段、文档段 |
224.0.0.0/3 | 组播/保留 |
IPv6 2000::/3 之外全部 | 回环 ::1、未指定 ::、ULA fc00::/7、链路本地 fe80::/10、组播 ff00::/8 等都在内 |
2001::/23 · 2001:db8::/32 · 3fff::/20 · 2002::/16 | IETF 协议段(含 Teredo)、文档段、6to4 |
IPv4 映射 ::ffff:0:0/96 · NAT64 64:ff9b::/96 | 按内嵌的 IPv4 判,不查上面的 IPv6 各段 |
关键约定是解析失败按危险处理:当 isIP(addr) === 0(即传入的不是合法 IP 字面量)时,直接返回 true。因此传入非 IP 字符串(域名、空串)一律得到 true。不要拿它当域名判定器,域名请用 assertSafeHost。
setNetworkPolicy(cfg) — 进程级策略注入
export function setNetworkPolicy(cfg: NetworkPolicyConfig): string | undefined
export interface NetworkPolicyConfig {
blockPrivate?: boolean; // 默认 true;本地自动化可显式 false 关私网拦截
denyCidrs?: string[]; // 额外拒绝的网段(IPv4 或 IPv6),如 ["100.64.0.0/10", "fd00::/8"]
allowedPorts?: number[]; // 非空时仅允许这些目标端口(1 到 65535 的整数),如 [80, 443];空/缺省=不限
}注入进程级出口策略。网段在注入时会预先建成 node:net 的 BlockList(IPv4、IPv6 各一张),每次请求只查表,不在热路径重复解析。
默认策略是 { blockPrivate: true, denyCidrs: [], allowedPorts: null }——也就是说,即使未注入,也默认拦截私网。blockPrivate 只有写成 false 时才关闭,allowedPorts 为空数组等同于不限,各项写成 null 与没写一样。写错一处,策略就处于「配置有误」:cfg 不是对象,有这三项之外的字段,blockPrivate 不是布尔,denyCidrs、allowedPorts 不是列表,或有一条不是网段(网段不带 zone id)、不是 1 到 65535 的整数。这时 setNetworkPolicy 不抛错,交回一句「网络策略配置有误:<字段> 的 <原值>…」(多出的字段点名键名);此后 assertSafeUrl、assertSafeHost、assertAddressesSafe 与 assertPortAllowed 一律抛这一句,经出网闸的请求全部失败,直到注入一份写对的。
由谁调用
策略来自宿主配置文档的 network 字段(由 api-authority 声明),由 plugin-authority 在激活时注入,宿主配置文档变了(热重载即如此)时再注入,改了即时生效。普通插件作者不要调用它——它是进程级单例,会覆盖全局策略。配置字段语义见 api-authority。
2. 用法示例
最小可运行片段(一行替换裸 fetch,SSRF 校验已内置):
import { safeFetch } from '@aalis/util-network-guard';
// url 来自用户 / LLM / 入站消息——直接当 fetch 用
const res = await safeFetch(url, {
signal: AbortSignal.timeout(15_000),
// 用户影响的 URL 不要带 cookie/Authorization(见 §5)
headers: { 'user-agent': 'Mozilla/5.0 (MyPlugin)' },
});
if (!res.ok) throw new Error(`上游返回 ${res.status}`);
const text = await res.text();只想拿到校验过的 URL(自己管理连接,例如流式代理):
import { assertSafeUrl, assertSafeHost } from '@aalis/util-network-guard';
const safe = await assertSafeUrl(rawUrl); // 抛错即拒绝;通过返回 URL
// 或只校验主机名:
await assertSafeHost(parsedUrl.hostname);在 package.json 里声明依赖,x.y.z 写开发时用的版本,写法见第三方插件开发者指南的「依赖怎么归类」:
{ "dependencies": { "@aalis/util-network-guard": ">=x.y.z <1.0.0" } }3. 谁在用(真实消费点)
safeFetch 已经是全仓「拉取外部 URL」的标准出口,以下是真实消费点:
| 消费点 | 场景 |
|---|---|
| OneBot 附件下载 | 入站 http(s):// 附件下载后 base64 内联 |
| OneBot 附件缓存 | 带 30s 超时的附件缓存拉取 |
| media 安全下载 | vision 输入下载,叠加体积上限/超时 |
| WebUI 图片代理 | 浏览器侧代理第三方图片(带凭证规避范例,见 §5) |
| http 工具 | LLM 可调的 http_request / http_download 工具 |
| ASR(openai / whisper-cpp) | 拉远程音频转写 |
| office | 拉远程文档解析 |
| ollama | 下载消息里的图片网址(带 30s 超时) |
| file-reader | 下载网址形式的附件 |
| 策略注入方 | plugin-authority 读宿主配置文档:setNetworkPolicy(config.get('network') ?? {}),激活时注入,宿主文档变了再注入 |
其中两处做法值得参考:图片代理显式不带 cookie、只给一个伪 UA;media 与 http 工具在 safeFetch 之外自行做体积上限与流式累计——util 只负责校验、不限制体积,详见 §5。
pinnedLookup、assertAddressesSafe 与 assertPortAllowed 另有一个自管连接的消费点:plugin-tool-browser 的浏览器网络闸(本进程内的 SOCKS5 服务,浏览器的全部连接经它判定后转发),见 plugin-tool-browser。
4. 不是 storage URI
http:、https:、file: 都是 storage URI 文法的保留 scheme:它们走 safeFetch / readExternalFile 的专门读取路径,不当作 storage URI 解析。
这里有一个需要注意的区分:data: 开头时,若 data[5] === '/'(如 data:/images/...)是 storage URI,而 data:image/...;base64,... 才是 data URI。OneBot 附件处理对此有显式区分。storage URI 文法见 storage-uri-grammar。
5. 边界情形与注意事项
只校验,不限体积、不超时、不缓存。 util 故意不做下载侧防护。safeFetch 返回的是普通 Response,在没有 Content-Length 时全量缓冲会撑爆内存,调用方必须自己加上:
- 超时:
signal: AbortSignal.timeout(ms)(参考 onebot、ollama 消费点)。 - 体积上限:读
content-length并做流式累计中断(可参考图片代理、http 工具的readBodyCapped、media 安全下载的做法)。
重定向与凭据。 跨源跳转不带凭据头(规则见 §1 的 safeFetch);但同源跳转照样带,而且 URL 本身如果是用户或模型给的,凭据也会照样发到那个源。所以对用户或 LLM 影响的 URL 调用 safeFetch 时,不要带任何凭证、cookie 或用户 referer——图片代理就显式只给一个伪 UA。
isPrivateAddress 失败即危险,且只接受字面 IP。 传入域名、空串或非法字符串一律返回 true。它不是域名判定器,域名或混合输入请用 assertSafeHost(带 DNS 解析)。
只挡 SSRF,不是全能网络闸。 协议只放行 http 和 https(file:、gopher: 等被拒)。它不防范:数据外泄到公网上的受信域名、上游返回的恶意内容(例如 SVG XSS,那需要 CSP 与 Content-Type 校验)、应用层鉴权。它解决的只有一件事:别让用户或 LLM 把请求打到内网、元数据或回环。
blockPrivate: false 是 owner 的本地自动化逃生门,不是默认。 关掉后,私网、回环、元数据全部放行——只在 owner 明确需要访问本机服务、且清楚风险时,在宿主配置文档(aalis.config.yaml 顶层 network,由 api-authority 声明)中开启。本地固定服务(ollama、onebot daemon)本就走裸 fetch、不过 safeFetch,不受策略影响。
allowedPorts 只对 IPv4/IPv6 默认端口推断生效: URL 无显式端口时按协议推断(https → 443,http → 80),有显式端口时按显式值。denyCidrs 收 IPv4 与 IPv6;IPv4 网段同样管 IPv4 映射与 NAT64 写法,IPv6 网段不管 IPv4 地址。
6. 交叉链接
- 威胁模型 / 为什么要走 safeFetch:concepts/security-model(§3 safeFetch、§1 单 owner 威胁模型)
- 保留 scheme 与 storage URI 文法:concepts/storage-uri-grammar
- 策略注入方与
network配置:services/authority ·plugin-authority - 下载侧叠加体积/超时的范例消费者:services/media ·
plugin-tool-system的 http 工具