智能体一旦接上 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 策略一起用;它能做的是让「扫内网」不再是默认成功路径。