给本地模型的对话面板加联网搜索时,我写了一段很标准的出网防护:解析目标主机名,拿到 IP,如果不是公网地址就拒绝。这是防 SSRF 的教科书写法,用来挡住模型被诱导去访问内网服务。
写完之后,搜索功能开始稳定地返回空结果。不报错,不超时,就是一条都没有。
排查的结论是:那段防护把所有目标都拦下了,包括维基百科和搜索 API。而它的判断在自己的逻辑里完全正确。
被合成出来的地址
这台机器的出网走一层透明代理。这类代理为了接管 DNS,会给域名返回一个合成的假地址,自己在这个地址上收连接,再按域名转发。实际解析结果长这样:
$ python3 -c "import socket;print(socket.gethostbyname('zh.wikipedia.org'))"
198.18.8.116
$ python3 -c "import socket;print(socket.gethostbyname('api.tavily.com'))"
198.18.10.9
198.18.0.0/15 是 RFC 2544 划给网络设备基准测试的保留段。Python 的 ipaddress 判它 is_global == False——完全正确,这确实不是公网地址。IPv6 那边同理,代理用的是一段 fdfe: 开头的私有地址。
于是所有走代理的域名,在我的检查里都是「非公网」,全被拒。唯一能过的是那些国内直连、返回真实 IP 的域名。搜索之所以「静默返回空」而不是报错,是因为我把「所有候选源都不可用」当成了「没搜到」。
直接的修法是把这两段假地址池显式加进允许列表。但改完之后我意识到,真正的问题不在于漏了哪个网段。
检查的地址,不是要连的地址
SSRF 防护的整个逻辑建立在一个前提上:我解析出来的这个 IP,就是待会儿真正要连的那个 IP。只有这个前提成立,「检查 IP 是否属于内网」才是有意义的。
透明代理让这个前提直接失效了。解析出来的是一个占位地址,真正的连接由代理在别处建立,目标是什么、去哪儿,我这段代码既不知道也管不着。这时候那个检查已经不是「弱」,而是没有意义——它检查的东西和最终发生的事情之间没有因果关系。
把假地址池加进允许列表,只是让程序恢复可用,并没有恢复那道防护。诚实的说法是:在这个网络环境下,出网目标的控制权已经交给了代理,我这一层能做的只剩下协议和端口的限制。把这件事写清楚,比留着一段看起来很安全的代码要好。
同一个间隙,第二次出现
意识到「检查的对象 ≠ 使用的对象」之后,我回头把这个模式当成一条线索去查,在同一个文件里又找到一处。
解析 URL 用的是标准库的 urlsplit,实际发请求用的是 httpx。对于含非 ASCII 字符的国际化域名,这两者用的是不同版本的 IDNA 标准:标准库走 IDNA 2003,httpx 走 IDNA 2008。同一个 Unicode 域名,两边归一化出的 punycode 可以不一样。
后果和 fake-ip 那个坑是同一种:我检查了域名 A,程序去抓了域名 B。区别只在于前者是环境造成的,后者是两个库的实现差异造成的。
这一处的修法简单粗暴——直接拒绝非 ASCII 主机名。搜索结果里的国际化域名极少,为了它去对齐两套 IDNA 实现,代价远大于收益。
第三次出现,这次没修
同一个间隙还有第三种形式:检查和连接之间隔着一次时间差。
我先解析一次域名做检查,httpx 建连时会再解析一次。控制权威 DNS 的一方可以让这两次返回不同结果,第一次给公网地址骗过检查,第二次给内网地址。这就是经典的 DNS rebinding,实测两次解析间隔大约 4.6 毫秒。
这一处我没修,理由写在这里供参考:根治要自定义 httpx 的 transport,强制用已校验过的 IP 建连,改动面不小。而在这套系统里,端口白名单已经把可达面压到内网的 80 和 443,取回的内容只进本地模型的上下文、不回显给发起方,能造成的实际损害有限。
我把它定级为低风险并记录在案。已知未修和不知道是两回事,前者可以在环境变化时重新评估,后者不能。
同一次审查里查出的其它坑
顺着这条线做完整审查,还有几个和上面那个间隙无关、但更容易在生产上炸的问题。
压缩炸弹截断得太晚。我原本写的是取响应体前若干字节:
data = r.content[:MAX_BYTES] # 太晚了
问题在于访问 r.content 的那一刻,httpx 已经把整个响应体解压进内存了,切片是在解压之后才发生的。实测一个 0.3 MB 的线路流量解压出 200 MB,进程 RSS 冲到 747 MB。正确的做法是流式读取,一边累计一边截断,并且把 content-type 检查提到读 body 之前:
async with client.stream("GET", url) as r:
if not r.headers.get("content-type", "").startswith("text/"):
return None
buf = bytearray()
async for chunk in r.aiter_bytes():
buf += chunk
if len(buf) >= MAX_BYTES:
break
同步调用堵死了事件循环。getaddrinfo 和正文抽取都是同步的,直接写在协程里会让「并发抓取」退化成串行,更糟的是它会冻结同一个循环上所有正在流式输出的对话。丢进 asyncio.to_thread 就好,但这个症状在低并发下完全看不出来。
每次超时不等于总超时。httpx 的 timeout 是每次 socket 操作的上限。对面用慢速滴水的方式发数据,每次都不超时,累计能把 8 秒拖到 40 秒以上。得在外面再套一层总超时。
解析失败不能静默。必应结果页的标题是 <h2 class=""> 而不是裸 <h2>,我第一版正则写错,于是「抓取成功、解析出 0 条」,模型得到的信息是「网上查不到」。现在的规则是:拿到了页面却解析不出任何条目,直接抛异常。静默的空结果比报错危险得多,因为它会被下游当成事实。
编码猜错比抓失败更糟。大量中文站点只在 <meta> 里写 charset,不写在 HTTP 头上。按 UTF-8 硬解会得到乱码,而程序里的 fetched 标志仍然是 True——乱码被当成正文喂给了模型。解码顺序要按 HTTP 头、meta、UTF-8、GB18030 依次尝试。
三处静默的截断
「静默失败」这条线继续往下拉,还有三个都发生在数据被悄悄丢掉的地方。
去重把不同的页面判成了同一个。去重逻辑原本是先去掉 URL 的查询串再比较。这在大多数站点上没问题,但在那些用查询参数区分内容的站点上——视频站的 ?bvid=、论坛的 ?tid=——两个完全不同的页面会被判成重复,后一个被丢掉。现在只去掉 fragment,查询串保留参与比较。
字数预算先到先得。抓回来的正文有一个总字数上限,防止把模型的上下文撑爆。原来的实现是按顺序往里填,填满为止。结果是前两三个来源吃光全部预算,排在后面的来源整条消失——而且不会出现在引用列表里,模型和用户都不知道它存在过。改成按来源均分之后,每个来源都留下一段,引用列表也完整了。
token 预算被思考吃光。联网之后,网页正文会把提示词顶到五千多 token,模型的思考过程也随之变长。如果生成上限还按不联网时的几百设置,思考就把额度全部吃完,最终输出为空。这个现象和前面那些坑一样:每一步都没报错,结果是空的。现在会在预算过低时给出提示,建议不低于 1024。
被驳回的那些指控
这次审查是按对抗方式做的——先假设每个环节都有问题,再逐条找证据。有几条指控最后被驳回了,但它们比确认的漏洞更值得记,因为它们说明了在哪里容易把事情想歪。
第一条是「引用链接可以塞进 javascript: 伪协议」。听起来很合理,但实际不可达:解析搜索结果的正则限定了 https?:// 开头,其余路径也都做了 scheme 校验。攻击面要沿着实际的数据通路验证,不能只看某个字段类型上「可能」是什么。
第二条是「提示词里没有防注入的措辞」。这条的错误在于找错了边界。真正让提示注入无法升级为实际危害的,不是提示词怎么写,而是这个模型没有任何工具权限——它读到的网页内容再有煽动性,也只能变成文字输出。防线在架构上,不在措辞上。
第三条是「抓取没有并发限制」。提出者只搜了 Python 源码,没看反向代理的配置——限流规则写在 nginx 里,早就管着那个接口。只在一种文件类型里找证据,就会得出一个在那个范围内成立、放到整个系统里不成立的结论。
可以带走的判据
这次审查里最有复用价值的,是那条把三个不同 bug 串起来的线索。任何「先检查、后使用」的防护,都要盯着一个问题:检查的那个对象,和使用的那个对象,是不是同一个?
它们会因为很多互不相干的原因分开——环境里有个代理在合成地址、两个库对同一个字符串的归一化规则不同、检查和使用之间隔了一次可以被人操纵的重新解析。三种原因毫无关系,但产生的漏洞形状完全一样。
与之配套的第二条:防护失效时,程序应该响亮地失败。这次所有的坑里,最难查的恰恰是那些「成功了,只是结果是空的」——静默返回空的搜索、解析出 0 条的页面、解码成乱码但标记为成功的正文。它们都不会出现在错误日志里,只会安静地把错误的信息交给下游。