锁上了大门,却忘了后院——AI 伴侣项目里一次"开门揖盗"的教训
给 AI 伴侣做安全审查,第一条就把我看沉默了:127.0.0.1 挡住了互联网,却挡不住自己电脑上的其他程序。这篇聊聊 WebSocket 是什么、这个洞是怎么开的,以及三种补法各值多少钱。
上周把 AI 伴侣这个项目丢去做了次安全审查,心里还挺松弛——“桌面软件而已,又没挂公网,能有什么大问题”。报告第一条看完,我沉默了两分钟。
WebSocket 是什么
先说清楚这次出问题的到底是个什么东西。
平时刷网页用的 HTTP,像是发电报:你问一句,服务器答一句,答完线就断了,下次问话再重新拨号。WebSocket 不这么干——它拨通之后,线一直开着,电话没挂,两头随时能开口,不用每次都重新握手寒暄。
这个项目非它不可,是因为需要这种”随叫随到”的能力:摄像头识别到你表情一变,得立刻推给界面;AI 回复要一个字一个字地流出来。电报式的一问一答,做不到这么实时。
具体到代码里:sidecar_server.py 在你电脑上开了个 WebSocket 服务,只听 127.0.0.1:8765——这地址的意思是”只有本机能敲门,外面的网络一概不认”。桌面前端启动后,用 new WebSocket("ws://127.0.0.1:8765") 拨通这根线,之后聊天、人脸识别结果、改配置,全靠这一条管道传 JSON 消息。
问题出在哪:锁对了大门,却没锁窗
127.0.0.1 确实把互联网上的陌生人挡在了外面。但《论语》里季孙氏家臣的一句话,用在这儿再合适不过——“吾恐季孙之忧,不在颛臾,而在萧墙之内也”。萧墙是自家门内的那道照壁,孔子的意思是:真正该担心的祸患,往往不在外头,而在自己院子里。
放到这个洞上就是:127.0.0.1 锁住的是”外面进不来”,锁不住”你电脑上正跑着的其他程序”。假设你不小心运行了个来路不明的小工具——不专门冲你这个项目来的,就是顺手一扫,发现本机 8765 端口开着个 WebSocket,它完全可以这么连上去:
import websocket
ws = websocket.create_connection("ws://127.0.0.1:8765")
ws.send('{"type": "set_config", "values": {"...key..."}}')
项目里原本设了一道 ALLOWED_ORIGINS 检查,本意是”只认自家前端页面发来的连接”——校验的是握手时浏览器自动带上的 Origin 头,这个头浏览器绝不会伪造,是浏览器自己的安全承诺。可上面这段 Python 脚本压根不是浏览器,它想在 Origin 里写什么、写不写,全凭自己高兴。这道检查对”正经网页”有效,对”存心写脚本连你端口的家伙”基本形同虚设——门是关的,但没上锁,推一下就开,这就是”开门揖盗”。
一旦连上,能干的事不算少:set_config 把 API key 悄悄换成攻击者自己的(之后你说的每句话都经过它中转,钱也算在你账上);直接发 chat 白嫖你的账户额度;用 enroll/delete_person 篡改人脸库。
三个选项,各买到什么
快速缓解(改一行):把 ALLOWED_ORIGINS 里”完全不带 Origin 头也放行”的例外删掉。挡住的是那些偷懒、没特意伪造头部的脚本——这类占大多数。但真要是冲着 8765 端口来的,给请求随手加一句 Origin: http://127.0.0.1 就绕过去了。这一步挡的是”无心的”,挡不住”有意的”。
完整的 pairing token(我推荐):真正的前端窗口在页面加载前,由 Rust 那层注入一个随机密钥,连接时带上,服务端校验不对直接拒之门外——不管对方 Origin 头怎么伪造,没有这把钥匙就进不来。这是真正把萧墙内的这个洞堵死的做法。代价是要新搭一条”Rust 向前端安全传密钥”的管道,目前这个桌面壳完全没有,工作量集中在 Rust 那一侧。
先放一放:前两步的 Python 侧修复先落地,WS 认证单独排进下一轮。
千里之堤,溃于蚁穴。这个洞眼下不大,但它开在钱包和隐私两处要害上——我倾向于把 pairing token 这步也早点排上日程,而不是等哪天真被人捅穿了才想起来补。