1. 从一次内网探测说起:WebFetch 出站请求为什么必须拦
WebFetch 类工具在服务端发起出站请求时,最大的风险不是"抓不到网页",而是"抓到了不该抓的东西"。SSRF(Server-Side Request Forgery,服务端请求伪造)的本质是:攻击者通过控制请求目标,让服务端代替自己去访问内网资源。放到 Agent 场景里,这个风险被放大——因为发起请求的可能是被 Prompt 注入影响的模型,而不是一个明确知道自己在干什么的开发者。
我见过最典型的链路是这样的:项目 README 里藏了一段指令,诱导 Agent 用 WebFetch 去请求http://169.254.169.254/latest/meta-data/iam/security-credentials/。这个地址是云厂商的元数据端点,一旦返回内容被模型读进上下文,IAM 临时凭证就可能顺着后续的对话或日志外泄。更隐蔽的是,攻击者不需要直接写死内网 IP,用::ffff:169.254.169.254这种 IPv4-mapped IPv6 写法,或者用一个先解析到公网、再解析到内网的域名(DNS Rebinding),就能绕过只检查字符串的黑名单。
所以这篇要解决的核心问题是:在自有环境里,怎么给 WebFetch 这类出站工具加一层可落地的私有 IP 拦截与出站安全策略。适合谁?正在把 Agent 接入生产、需要过安全评审的后端和平台同学;以及自己写 HTTP Hook、担心配置被恶意篡改的开发者。下面我会给出可复制的 HTTP Hook 拦截配置骨架、settings.json片段,并用内网地址实测拦截是否生效。整套思路围绕四个关键词:SSRF、WebFetch、私有 IP 拦截、出站安全。
2. 前置准备:TaoToken 接入与出站策略的边界
在写拦截逻辑之前,先把"请求从哪出去"这件事理清楚。出站安全的第一原则是:所有出站请求都应该经过一个你能观测、能拦截的统一入口。如果你用的是 TaoToken 这类统一接入层,模型调用和工具出站可以走同一套凭证与审计体系,排查问题时不用在多个服务之间来回跳。
TaoToken 的接入地址是https://taotoken.net/api,控制台和密钥管理在官网https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。你需要先拿到 API Key,再把它配到环境变量里,后续的 Hook 和 WebFetch 都复用这套凭证。
# 写入环境变量,避免硬编码到配置文件 export TAOTOKEN_API_KEY="sk-你的密钥" export TAOTOKEN_BASE_URL="https://taotoken.net/api"密钥申请入口在 API Keys 管理页,接入细节可以对照接入文档。这里要强调一个边界:TaoToken 负责的是模型与工具调用的统一接入,不替代你自己的出站防火墙。私有 IP 拦截必须落在你自己的服务端或 Hook 层,因为只有你知道哪些内网段是敏感的。
注意:不要把 API Key 写进会被提交到 Git 的
settings.json。用环境变量引用,并在 Hook 里做变量白名单,只允许特定变量被插值。
3. 可复制配置:HTTP Hook 拦截骨架与 settings.json
出站拦截最实用的落点是 HTTP Hook——它在请求真正发出前介入,能拿到目标 URL 和解析后的地址。下面是一个拦截骨架,核心逻辑是:解析目标主机 → 判断是否为私有/链路本地地址 → 命中则直接拒绝。
// ssrf-guard-hook.js —— 出站请求前的私有 IP 拦截 const dns = require('dns').promises; const net = require('net'); // 私有/保留地址段,命中即拦截 const BLOCKED_V4 = [ [/^0\./, 'this-network'], [/^10\./, 'rfc1918-10'], [/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, 'cgnat'], [/^169\.254\./, 'link-local'], [/^172\.(1[6-9]|2\d|3[01])\./, 'rfc1918-172'], [/^192\.168\./, 'rfc1918-192'], ]; function isBlockedV4(ip) { return BLOCKED_V4.find(([re]) => re.test(ip))?.[1] || null; } function isBlockedV6(ip) { const lower = ip.toLowerCase(); if (lower === '::1') return null; // 回环放行,本地开发需要 if (lower === '::') return 'unspecified'; if (lower.startsWith('fc') || lower.startsWith('fd')) return 'unique-local'; if (/^fe[89ab]/.test(lower)) return 'link-local-v6'; // IPv4-mapped IPv6 递归降维 const m = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (m) return isBlockedV4(m[1]); return null; } async function guard(urlStr) { const u = new URL(urlStr); const host = u.hostname; // 主机名是 IP 字面量,直接判定 if (net.isIP(host)) { const reason = net.isIPv4(host) ? isBlockedV4(host) : isBlockedV6(host); if (reason) throw new Error(`SSRF blocked: ${host} (${reason})`); return; } // 域名:解析所有结果,任一命中即拦截 const addrs = await dns.lookup(host, { all: true }); for (const { address } of addrs) { const reason = net.isIPv4(address) ? isBlockedV4(address) : isBlockedV6(address); if (reason) throw new Error(`SSRF blocked: ${host} -> ${address} (${reason})`); } } module.exports = { guard };对应的settings.json片段,把 Hook 挂到出站事件上,并限制环境变量插值范围:
{ "hooks": { "PreToolUse": [ { "matcher": "WebFetch", "hooks": [ { "type": "command", "command": "node ./hooks/ssrf-guard-hook.js" } ] } ] }, "allowedHttpHookUrls": ["https://your-audit.internal/*"], "httpHookAllowedEnvVars": ["TAOTOKEN_API_KEY", "TAOTOKEN_BASE_URL"] }几个关键点值得展开。allowedHttpHookUrls是 Hook 自身的出站白名单,undefined表示不限制、空数组表示全部阻止、非空数组则必须匹配某个模式——生产环境建议显式列出审计端点。httpHookAllowedEnvVars控制哪些环境变量能被插值进 Hook 的 header,不在白名单里的变量会被替换为空字符串,防止密钥被恶意配置外泄。另外,Hook 的 header 值一定要做 CRLF 清洗,剥离\r、\n、\0,否则token\r\nX-Evil: 1这种值会注入额外 header。
4. 验证请求:用内网地址实测拦截是否生效
配置写完不验证等于没写。下面用几个典型地址实测,覆盖 IPv4 私有段、链路本地、IPv4-mapped IPv6 三种情况。
// verify-guard.js const { guard } = require('./ssrf-guard-hook'); const cases = [ 'http://169.254.169.254/latest/meta-data/', // 云元数据,应拦截 'http://10.0.0.5/admin', // RFC1918,应拦截 'http://192.168.1.1/router', // RFC1918,应拦截 'http://[::ffff:169.254.169.254]/meta', // IPv4-mapped,应拦截 'http://127.0.0.1:8080/health', // 回环,应放行 'https://example.com/', // 公网,应放行 ]; (async () => { for (const url of cases) { try { await guard(url); console.log(`PASS ${url}`); } catch (e) { console.log(`BLOCK ${url} -> ${e.message}`); } } })();预期输出大致如下:
BLOCK http://169.254.169.254/latest/meta-data/ -> SSRF blocked: 169.254.169.254 (link-local) BLOCK http://10.0.0.5/admin -> SSRF blocked: 10.0.0.5 (rfc1918-10) BLOCK http://192.168.1.1/router -> SSRF blocked: 192.168.1.1 (rfc1918-192) BLOCK http://[::ffff:169.254.169.254]/meta -> SSRF blocked: ::ffff:169.254.169.254 (link-local) PASS http://127.0.0.1:8080/health PASS https://example.com/如果::ffff:169.254.169.254这一条没被拦住,说明你的实现只做了 IPv4 字符串匹配,漏掉了 IPv4-mapped IPv6 的递归降维。这是最常见的绕过点,务必单独测。回环地址放行是有意为之——本地开发、策略服务器、健康检查都依赖它,一刀切会打断正常工作流。
验证通过后,可以进一步用真实模型对话确认整条链路:在 模型对话 里让模型尝试请求一个内网地址,观察 Hook 是否在请求发出前就返回了拦截信息。这一步能验证 Hook 与工具调用链的挂载是否正确。
5. 本篇常见错排查
拦截没生效,请求照样出去了。先确认 Hook 的matcher是否精确匹配到工具名。不同版本的工具体系里,WebFetch 可能被归类到外部集成类工具,matcher 写错就完全不会触发。其次检查 Hook 脚本的退出码——非零退出才会阻断,如果脚本内部 catch 了异常却没重新抛出,进程正常退出,请求就放行了。
域名解析到多个 IP,只拦了第一个。这是典型的漏网场景。dns.lookup默认只返回一个地址,必须显式传{ all: true }拿到全部结果,任一命中私有段就拦截。否则攻击者可以让域名同时解析到公网和内网,你检查到公网那个就放行了。
DNS Rebinding 窗口没堵住。如果你的实现是"先解析验证、再让 HTTP 客户端自己解析连接",两次解析之间就存在时间窗口。正确做法是把验证后的 IP 直接交给连接层使用,让验证和连接用同一个 IP,消除 TOCTOU 竞争。上面骨架里guard只做验证,实际接入时要把解析结果透传给请求库的lookup选项。
代理环境下拦截误伤。如果系统配了 HTTP 代理,代理地址本身常在私有段(如10.0.0.1:3128),会被 Guard 误拦,导致所有出站都失败。这时要么让代理层承担访问控制、Guard 对代理连接退让,要么把代理地址加入例外。代理激活时 Guard 验证的是代理 IP 而非目标 IP,验证结果本身也失去意义,退让是合理选择。
环境变量插值把密钥带出去了。检查httpHookAllowedEnvVars是否配了最小集合。默认不配的话,某些实现可能允许任意变量插值,恶意 Hook 配置就能把AWS_SECRET_ACCESS_KEY之类塞进 header 发到外部。白名单 + CRLF 清洗两层都要有。
6. 把出站安全固化进日常流程
拦截配置写完只是起点,真正难的是让它长期有效。我的做法是把上面那段验证脚本挂进 CI,每次改 Hook 或settings.json都跑一遍,用例里固定包含169.254.169.254、::ffff:映射、多 IP 域名这几类。这样任何一次"顺手放宽"都会被测试挡下来。
如果你还在搭 Agent 的编码工作流,出站安全建议和权限体系一起设计,别等上线前才补。长期跑编码任务或 Agent 的话,可以用 Coding Plan 把模型调用和工具出站统一到一套凭证下,审计时能一眼看到哪个会话请求了哪些地址。密钥轮换和用量查看在 控制台,接入细节随时对照接入文档。
最后留一个实用习惯:每次新增预批准域名或放宽白名单时,问自己一句"这个域名允许上传吗"。允许文件上传的站点,一旦被放进任意网络访问的白名单,就会变成数据外泄通道——GET 请求安全,不代表 POST 也安全。这条边界想清楚,出站安全就稳了一大半。