智能体一旦接上 fetch / browse,失败模式就从答错变成出站网络被带跑:环回地址、链路本地、云元数据、
file/gopher以及无限重定向都会出现。提示词拦不住一次被诱导的工具调用,把 URL 规范化、DNS/IP 校验、重定向次数、超时与体积预算、Content-Type 白名单做成统一中间层更靠谱。拦截原因应写成稳定错误码,既给模型消费,也方便测试断言。
失败模式已经换了
给智能体接 HTTP 工具之前,质量问题多半是幻觉、漏读、引用错页面。接上之后,多了一类完全不同的事故:模型并不需要「想清楚要黑你的内网」,只要把用户或检索结果里的 URL 原样交给 fetch,请求就会按操作系统的默认规则走出去。
常见坑并不玄乎。http://127.0.0.1/、http://localhost:8080/admin、http://169.254.169.254/latest/meta-data/ 是直连;http://0x7f000001/、IPv6 环回、带用户名的怪异 URL、相对重定向到内网,是解析层的绕法。协议也不止 HTTP:file://、gopher://、dict:// 一旦进了底层客户端,行为就不再是「读一个网页」。重定向如果只设了「跟就完了」,三次跳到元数据服务和三十次跳到自己,对调用方看起来只是「这个工具有点慢」。
这类问题的共同点是:模型侧没有可执行的网络语义,而工具侧如果用光秃秃的 fetch(url),等于把 DNS、重定向和响应体都交给了对方。后面的防护都按这个前提来,不假设模型会小心。
护栏放在工具层,而不是提示词里
function calling 的适配层是唯一还能做强制校验的地方。提示词可以写「不要访问内网」,但工具参数是字符串,模型或上游内容都可以再塞一个看起来像文档的 URL。正确的形状是:模型只负责提出「我想读这个 URL」,真正的出站由你的 Node.js 中间层执行;通过则返回截断后的文本,失败则返回结构稳定的错误,而不是把异常栈或 HTML 500 页塞回上下文。
这层东西不替代安全组、egress proxy、禁用 IMDS 之类的基础设施控制,只补应用层一定会碰到的洞:DNS 重绑定、重定向换目标、响应把上下文窗口撑爆。下面的实现只用 Node.js 内置模块(node:dns、node:net、node:http / node:https),避免把防护绑在某个 HTTP 封装库的默认重定向策略上。IP 钉扎(解析后连 IP,同时带上原来的 Host / SNI)是为了缩小「先查一次是公网、真正连接时变成内网」的窗口;它不是形式化证明,但比「lookup 完再按主机名连一次」实在。
URL 规范化、协议白名单和解析后校验
先把字符串变成 WHATWG URL,再看解析后的协议与主机,不要用正则在原始字符串上猜。协议只放行 http: 和 https:。主机名做小写比较,把 localhost、以 .localhost 结尾的名字、以及常见云元数据主机直接拒绝。对「看起来像点分数字 / 十六进制、但 net.isIP 又不认」的主机名直接拒绝,少在应用层复刻一套 IP 解析器。
解析出的若已经是 IP,就不必再 DNS,但仍走同一套私网/特殊用途地址表。net.BlockList 是现成 API,把 RFC1918、环回、链路本地、CGNAT(100.64.0.0/10,阿里云元数据 100.100.100.200 落在这里)、部分文档地址、组播和 IPv6 对应段放进去。IPv4 映射的 IPv6(::ffff:127.0.0.1)要剥成 v4 再查,否则环回会漏。
下面是可运行的完整模块(Node.js 18+,ESM)。把文件存成 safe-fetch.mjs 即可被测试或工具适配器引用。
import { lookup } from "node:dns/promises";
import http from "node:http";
import https from "node:https";
import { BlockList, isIP } from "node:net";
export class ToolHttpError extends Error {
constructor(code, message, extras = {}) {
super(message);
this.name = "ToolHttpError";
this.code = code;
this.url = extras.url ?? null;
this.detail = extras.detail ?? null;
}
toJSON() {
return {
ok: false,
error: {
code: this.code,
message: this.message,
url: this.url,
detail: this.detail,
},
};
}
}
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const ALLOWED_TYPES = new Set([
"text/html",
"text/plain",
"text/markdown",
"text/csv",
"text/xml",
"application/json",
"application/xml",
"application/xhtml+xml",
"application/rss+xml",
"application/atom+xml",
]);
const blockedHosts = new Set([
"localhost",
"metadata.google.internal",
"metadata.goog",
"kubernetes.default",
"kubernetes.default.svc",
"kubernetes.default.svc.cluster.local",
]);
const blockList = new BlockList();
function addV4(net, prefix) {
blockList.addSubnet(net, prefix, "ipv4");
}
function addV6(net, prefix) {
blockList.addSubnet(net, prefix, "ipv6");
}
addV4("0.0.0.0", 8);
addV4("10.0.0.0", 8);
addV4("127.0.0.0", 8);
addV4("169.254.0.0", 16);
addV4("172.16.0.0", 12);
addV4("192.168.0.0", 16);
addV4("100.64.0.0", 10);
addV4("192.0.0.0", 24);
addV4("192.0.2.0", 24);
addV4("198.18.0.0", 15);
addV4("198.51.100.0", 24);
addV4("203.0.113.0", 24);
addV4("224.0.0.0", 4);
addV4("240.0.0.0", 4);
addV6("::", 128);
addV6("::1", 128);
addV6("fc00::", 7);
addV6("fe80::", 10);
addV6("ff00::", 8);
function fail(code, message, extras) {
throw new ToolHttpError(code, message, extras);
}
function isNumericLikeHost(hostname) {
if (isIP(hostname)) return false;
return /^[0-9a-fx.:]+$/i.test(hostname) && /[0-9]/.test(hostname);
}
function isBlockedHostname(hostname) {
const h = hostname.toLowerCase();
if (blockedHosts.has(h)) return true;
if (h.endsWith(".localhost") || h.endsWith(".local")) return true;
return false;
}
function ipv4FromMapped(address) {
const lower = address.toLowerCase();
if (lower.startsWith("::ffff:")) return lower.slice("::ffff:".length);
return null;
}
function isBlockedIp(address, family) {
const type = family === 6 ? "ipv6" : "ipv4";
if (type === "ipv6") {
const mapped = ipv4FromMapped(address);
if (mapped && isIP(mapped) === 4) {
return blockList.check(mapped, "ipv4") || blockList.check(address, "ipv6");
}
}
return blockList.check(address, type);
}
function parseAndNormalize(input) {
let url;
try {
url = new URL(String(input).trim());
} catch {
fail("SSRF_INVALID_URL", "URL 无法解析", { url: String(input) });
}
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
fail("SSRF_BLOCKED_SCHEME", `不允许的协议: ${url.protocol}`, {
url: url.href,
detail: url.protocol,
});
}
if (!url.hostname) {
fail("SSRF_INVALID_URL", "缺少主机名", { url: url.href });
}
if (isBlockedHostname(url.hostname)) {
fail("SSRF_BLOCKED_HOSTNAME", "主机名在拒绝名单中", {
url: url.href,
detail: url.hostname,
});
}
if (isNumericLikeHost(url.hostname)) {
fail("SSRF_BLOCKED_NUMERIC_HOST", "拒绝非标准数字形态主机名", {
url: url.href,
detail: url.hostname,
});
}
return url;
}
async function resolvePublicAddress(url) {
const host = url.hostname;
const ipVersion = isIP(host);
if (ipVersion) {
if (isBlockedIp(host, ipVersion === 6 ? 6 : 4)) {
fail("SSRF_BLOCKED_PRIVATE_IP", "目标地址为私网或特殊用途地址", {
url: url.href,
detail: host,
});
}
return { address: host, family: ipVersion === 6 ? 6 : 4 };
}
let records;
try {
records = await lookup(host, { all: true, verbatim: true });
} catch (err) {
fail("SSRF_DNS_FAILED", "DNS 解析失败", {
url: url.href,
detail: err.code ?? err.message,
});
}
if (!records.length) {
fail("SSRF_DNS_FAILED", "DNS 无记录", { url: url.href });
}
for (const r of records) {
if (isBlockedIp(r.address, r.family)) {
fail("SSRF_BLOCKED_PRIVATE_IP", "解析结果包含私网或特殊用途地址", {
url: url.href,
detail: r.address,
});
}
}
return records[0];
}
function mediaType(contentType) {
if (!contentType) return "";
return contentType.split(";")[0].trim().toLowerCase();
}
function readLimited(stream, maxBytes, url) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
const onData = (chunk) => {
size += chunk.length;
if (size > maxBytes) {
stream.destroy();
reject(
new ToolHttpError("RESPONSE_TOO_LARGE", "响应超过体积预算", {
url,
detail: String(maxBytes),
})
);
return;
}
chunks.push(chunk);
};
stream.on("data", onData);
stream.once("end", () => resolve(Buffer.concat(chunks)));
stream.once("error", reject);
});
}
function pinnedRequest(url, ipInfo, { timeoutMs }) {
const lib = url.protocol === "https:" ? https : http;
const port = url.port
? Number(url.port)
: url.protocol === "https:"
? 443
: 80;
const path = `${url.pathname}${url.search}`;
const options = {
hostname: ipInfo.address,
port,
path,
method: "GET",
headers: {
Host: url.hostname,
Accept: "text/html, text/plain, application/json, application/xml;q=0.9",
"User-Agent": "tool-http-guard/1.0",
Connection: "close",
},
timeout: timeoutMs,
};
if (url.protocol === "https:") {
options.servername = url.hostname;
}
return new Promise((resolve, reject) => {
const req = lib.request(options, resolve);
const timer = setTimeout(() => {
req.destroy();
reject(
new ToolHttpError("REQUEST_TIMEOUT", "超过单跳超时", {
url: url.href,
detail: String(timeoutMs),
})
);
}, timeoutMs);
req.on("timeout", () => req.destroy());
req.on("error", (err) => {
clearTimeout(timer);
if (err instanceof ToolHttpError) reject(err);
else {
reject(
new ToolHttpError("REQUEST_FAILED", "请求失败", {
url: url.href,
detail: err.code ?? err.message,
})
);
}
});
req.on("close", () => clearTimeout(timer));
req.end();
}).then((res) => {
res.once("end", () => {});
return res;
});
}
export async function fetchUrlForTool(input, options = {}) {
const maxRedirects = options.maxRedirects ?? 3;
const timeoutMs = options.timeoutMs ?? 5000;
const maxBytes = options.maxBytes ?? 512 * 1024;
const seen = new Set();
let current = String(input).trim();
for (let hop = 0; hop <= maxRedirects; hop += 1) {
const url = parseAndNormalize(current);
if (seen.has(url.href)) {
fail("SSRF_REDIRECT_LOOP", "重定向循环", { url: url.href });
}
seen.add(url.href);
const ipInfo = await resolvePublicAddress(url);
const res = await pinnedRequest(url, ipInfo, { timeoutMs });
const status = res.statusCode ?? 0;
if ([301, 302, 303, 307, 308].includes(status)) {
res.resume();
const location = res.headers.location;
if (!location) {
fail("SSRF_REDIRECT_INVALID", "重定向缺少 Location", { url: url.href });
}
if (hop === maxRedirects) {
fail("SSRF_TOO_MANY_REDIRECTS", "超过重定向次数预算", {
url: url.href,
detail: String(maxRedirects),
});
}
current = new URL(location, url).href;
continue;
}
if (status < 200 || status >= 300) {
res.resume();
fail("HTTP_ERROR", `HTTP ${status}`, {
url: url.href,
detail: String(status),
});
}
const type = mediaType(res.headers["content-type"]);
if (type && !ALLOWED_TYPES.has(type)) {
res.resume();
fail("RESPONSE_TYPE_DENIED", "Content-Type 不在白名单", {
url: url.href,
detail: type,
});
}
const buf = await readLimited(res, maxBytes, url.href);
return {
ok: true,
url: url.href,
status,
contentType: type || null,
body: buf.toString("utf8"),
};
}
fail("SSRF_TOO_MANY_REDIRECTS", "超过重定向次数预算", { url: current });
}
要点就三句:只信 new URL 之后的字段;主机名与 IP 两道拒绝名单;每一跳都重新走完整校验,而不是只校验用户当初传入的那一个 URL。
DNS 解析、私网拦截与重定向预算
dns.promises.lookup(..., { all: true }) 把 A/AAAA 一次拿全。策略取严的一边:任一地址落在 BlockList 就整次拒绝。双栈主机如果一边是公网、一边是内网,对智能体工具来说没有必要「挑一个能通的公网地址继续」——那正是重绑定和误伤元数据的灰色地带。
连接不使用解析后的主机名再查一次 DNS,而是 hostname: 解析得到的 IP,Host 仍为原名;HTTPS 用 servername 做 SNI,这样证书校验还按站点名字走,默认 rejectUnauthorized: true 不要关。重定向必须 Location 经 new URL(location, current) 解析成绝对 URL,然后当作全新目标:协议、主机名、DNS、IP 全部再来一遍。次数预算默认 3,循环用 href 集合检测。不要把底层客户端的 maxRedirects 打开后放任它自己跟,否则校验只发生在第一跳。
100.64.0.0/10 和 .local 属于产品取舍:在「智能体服务器主动出站」这个场景里,误伤正常公网页的概率很低,换来的是少踩一类元数据与 mDNS 名字。若你的工具本来就要访问用户内网(例如公司知识库),这层默认拒绝名单不适用,应改成明确的允许名单,而不是把 SSRF 护栏关掉。
超时、体积、Content-Type,以及模型可消费的错误
智能体上下文装不下任意响应。单跳超时(示例里 5 秒)避免挂死在慢源或黑洞 IP;体积预算(示例里 512KiB)避免把 PDF、镜像或无限流读进内存。Content-Type 按 media type 比较、丢掉 charset。缺省 Content-Type 在示例里放行、仍受体积限制——很多站点对 HTML 不声明类型;若你更怕二进制,可以把空类型也改成拒绝。白名单里没有 application/octet-stream、image/*、video/*,这是给「读给模型看的文本」用的,不是通用下载器。
错误不要只 throw new Error("blocked")。适配器应 catch (err) 后若 err instanceof ToolHttpError,把 err.toJSON() 当作 tool 的返回值。模型看到的是 code 而不是散文,测试也可以断言码而不是断言中文句子。
import assert from "node:assert/strict";
import test from "node:test";
import { fetchUrlForTool, ToolHttpError } from "./safe-fetch.mjs";
async function expectCode(url, code) {
await assert.rejects(
() => fetchUrlForTool(url, { timeoutMs: 1000, maxRedirects: 1 }),
(err) => {
assert.equal(err instanceof ToolHttpError, true);
assert.equal(err.code, code);
const payload = err.toJSON();
assert.equal(payload.ok, false);
assert.equal(payload.error.code, code);
return true;
}
);
}
test("拒绝非 HTTP 协议", async () => {
await expectCode("file:///etc/passwd", "SSRF_BLOCKED_SCHEME");
await expectCode("gopher://127.0.0.1:70/1", "SSRF_BLOCKED_SCHEME");
});
test("拒绝环回与链路本地", async () => {
await expectCode("http://127.0.0.1/", "SSRF_BLOCKED_PRIVATE_IP");
await expectCode("http://[::1]/", "SSRF_BLOCKED_PRIVATE_IP");
await expectCode("http://169.254.169.254/latest/meta-data/", "SSRF_BLOCKED_PRIVATE_IP");
await expectCode("http://localhost:8080/", "SSRF_BLOCKED_HOSTNAME");
});
test("拒绝无法解析的输入", async () => {
await expectCode("not a url", "SSRF_INVALID_URL");
});
工具适配器里只需要把成功结果的 body 再截一段(按字符而不是按字节,避免把半个多字节字符切进 prompt),失败则原样返回 JSON。不要在失败时再让模型「换个 URL 试试内网别名」——错误码已经足够它改口去要一个公网文档地址。
嵌进现有 function calling 时,保持单一入口:所有出站 HTTP 都走 fetchUrlForTool,禁止工具实现里另开 fetch、axios、http.get。护栏是开关,不是建议。
总结
智能体的 HTTP 工具一旦直连出站,风险就不只是读错页面,而是协议、DNS、重定向和响应体四条线一起失控。用 Node.js 内置的 URL、dns.lookup、BlockList 和钉扎后的 http/https 请求,可以把协议白名单、私网/元数据拦截、重定向次数、超时、体积和 Content-Type 收成同一道门。拦截结果用稳定 code 返回,测试断言码,模型消费码,而不是消费一段不可解析的异常文本。这层中间件仍然要和 egress 策略一起用;它能做的是让「扫内网」不再是默认成功路径。
评论