把私人 Agent 开放到公网:mini-agent 三仓库安全整改复盘
决定开放注册后,把浏览器、安卓客户端和后端重新当不可信客户端审计了一遍:七个发现、分阶段整改,以及令牌存储、认证限流、重放边界上的取舍与现状。
mini-agent 是我的个人 agent,Telegram 里发消息、网页上聊天、安卓 App 里提问,都走同一个后端。八月初那轮多租户改造给了它真实的用户体系,《从单用户 Chat 到多租户服务》写过隔离部分;八月最后一周又做了一次以”对外开放”为前提的安全审计,产出是一份七项发现的整改清单,每项写明风险、修法和验收标准。之后的整改分散在三个仓库的十几个提交里。这篇文章按当时的取舍记录:为什么先藏令牌、为什么保留重放、限流为什么先做固定窗口,以及哪些项还没做。
先列清单,再排阶段
多租户上线时解决的是”谁能用”:邮箱密码登录、用户隔离的工作区、按属主隔离的待办和定时任务。但浏览器和手机客户端一直被当成自己人:token 放在 localStorage 里,密码存在安卓本地,后端地址在界面上随手可改。自己用没有问题,开放注册就是另一回事了,能跑 JS 的地方、会丢的手机,都成了攻击面。
审计归出七项,按”丢什么最疼”排序:浏览器令牌可被 JS 读取;前端可配置任意后端地址;安卓持久化密码且 release 允许明文流量;登录、注册、重置密码三个公开入口没有限流;浏览器缺少 CSP 一类脚本治理;错误路径透传异常文本;request_id 重放和 Last-Event-ID 会放大令牌泄露的后果。
清单排了三个阶段:先切断”偷到令牌”的路径,再补防爆破和错误码,最后动重放语义。实际执行没有严格照阶段走,重放收紧和限流落在了同一批后端提交里,CSP 还留在最后。重放排得靠后是有意的:它是有意的容灾设计,砍掉会伤到正常用户;令牌外泄才是其他一切攻击的前提,先把概率降下来。
浏览器:令牌搬进 HttpOnly cookie
之前浏览器的登录态是 token 存 localStorage,每个请求带 Authorization 头。XSS 或恶意扩展读一次 localStorage 就能拿走长期会话。
现在登录成功后,后端给浏览器种一个 HttpOnly 的会话 cookie,SameSite=Lax,允许的 origin 是 HTTPS 时再加 Secure,token 不再出现在响应 JSON 里。前端所有请求带 credentials: "include",自身不保存任何令牌。非浏览器客户端(脚本、APK)继续走 Bearer,两条路并存。
cookie 也带来了新的攻击面:CSRF。SameSite=Lax 挡掉跨站请求携带 cookie,后端只在请求来自配置的浏览器 origin 时才认 cookie。真正的底线仍然在服务端:会话是持久但可吊销的记录,24 小时过期,登出立即销毁;云上实例对认证失败干脆不带任何回显,配合 nginx 静默断开。
这步换来的改变是:JS 读不到令牌了,但伪造一个”带着 cookie 的请求”仍然可行。HttpOnly 把”偷令牌”降级成”借浏览器发请求”,后者的杀伤半径小得多,配额和会话吊销也才有了作用点。
安卓:密码不落盘,release 只走 HTTPS
APK 的原状是清单里最疼的一项:密码、token、后端地址全部持久化,且允许明文 HTTP。手机会丢、会备份、会被别人拿到,密码落盘等于把账号一并交出去。
整改后的行为:
- 登录成功后密码立即丢弃,本地只留加密存储里的 token 和过期时间;
- release 构建强制 HTTPS 后端地址,明文地址在构建期直接被拒,明文流量开关交给构建配置控制,debug 留了本地联调的口子;
- 登录页不再显示后端地址;
- 本地聊天记录按登录账号隔离,换账号不串数据;
- 帮助内容按登录会话缓存,一次登录拉一次。
代价是重新打开 App 可能要重新登录。在手机上这个代价值得:手机丢了可以补,密码泄了没法补。
后端地址:从用户可配到受控
Web 前端原来支持运行时切换后端地址,对开发者方便,对攻击者同样方便:诱导用户把前端指到假后端,凭据就直接送到钓鱼服务器。
现在 Web 端这个入口由构建期开关控制,生产构建不出现;保留的配置区域只对管理员可见。安卓端把 base URL 从登录界面藏掉,是同一件事的另一面。开放之后,“连到哪个后端”不该由普通用户在界面上决定。
三个公开入口的限流
登录、注册、重置密码是仅有的三个不需要登录就能打到的入口,也是撞库和枚举集中发生的地方。现在按 IP 和账号两个维度分别计数,超限统一返回 429,失败登录同时喂给计数器。注册必须携带管理员生成的一次性邀请码,先控制谁能进来。
指数退避和临时锁定还没做,这轮先上固定窗口。限流只是拖延爆破,真正决定谁能注册的是邀请码,两者叠着用。窗口值和阈值没有写进这篇:阈值公开等于帮攻击者校准。
错误回显换成错误码
之前部分错误路径会把 Python 异常文本透传给调用方,异常文本的内容不可控:内部路径、库版本,偶尔还有参数本身。现在客户端响应不再透传异常栈,错误体带稳定错误码和 trace ID,完整异常只进服务端日志,排查时拿 trace ID 对。目前唯一保留 str(e) 的地方在参数校验路径,那条消息本来就是受控的校验文案,比如”邀请码无效”,不涉及异常栈。
重放:保留幂等,收紧归属
这是清单里最需要权衡的一项。/message 的设计里,客户端带着稳定的 request_id 提交任务,SSE 断开后可以凭 Last-Event-ID 重接事件流;断开只移除订阅者,后台任务继续跑。这套机制是为了对抗网络抖动:重连、重放都能拿回完整结果,不该因为安全考虑一刀切砍掉。
问题在于它同样方便偷令牌的人:拿到一个活跃会话,就能 attach 到别人正在跑的任务流上,白捡别人的执行结果。
收紧的方式是绑归属,不是砍重放:
- 任务创建时记录属主和 request_id,并盖上创建会话的 token 指纹;
- 换会话重放返回 409;
- 换会话查询或取消返回 404,和”任务不存在”同一个响应,不暴露任务是否存在;
- 同属主同会话的重放行为不变。
取舍在用户重新登录之后显形:新会话接不上旧任务。正常用户几乎感知不到,任务通常几分钟内跑完;攻击者拿到的令牌只能触达新会话建立之后的任务,再叠加 24 小时过期和主动登出,重放的半径被压进了可接受的范围。
后端进程重启后的任务恢复、持久化的 delta 重放,仍然是清单之外的 deferred 项。
开放之后:用量控制
开放注册还带来一个非安全问题:怎么防滥用。配额按 UTC 天原子计数,普通用户的查询和任务按时长计、聊天按次数计;管理员豁免,用量照记。结束话题、待办、定时任务这几类不经过聊天模型的操作不占聊天配额,用户管理自己的数据不该被配额挡住。两个客户端都在设置页显示当日用量,用超了能自己看出来。
另一条硬约束在沙箱:多租户模式下,租户沙箱不可用就直接拒绝启动。少了文件系统隔离宁可不起服务,这个失败模式是故意的。
现状
已经落地的:浏览器 cookie 会话、APK 密码不落盘与 HTTPS-only release、后端地址受控、认证限流、错误码化、重放按会话绑定、邀请码注册、配额,以及两端共用的帮助内容接口。
还没做的:CSP 和浏览器脚本治理排在最后;限流的指数退避和失败审计告警;“按风险缩短会话 TTL”,目前只有 24 小时固定值这一档。
验证还是三套:后端跑完整 pytest 套件,Web 端跑类型检查加构建,安卓端跑单元测试加真机安装。契约变更的顺序也是固定的:先改后端和文档,两个客户端跟进,哪边跟不上就先不发哪边。
这轮整改没有伴随任何入侵的痕迹,是趁还没出事的时候做的。清单上剩下的项也属于同一类:趁洞还没被打穿,先把攻击面变小。
Sources
- mini-agent 724602f — 认证与重放加固:cookie 会话、认证限流、任务会话绑定。
- mini-agent 487f5a9 — 待办、定时、话题向普通用户开放并按属主隔离。
- mini-agent-ui 488c0e8 — 浏览器登录态切换为 cookie。
- mini-agent-ui 34dbd5b — 登录页后端设置入口由构建期开关控制。
- apk-chatBot f8226bf — 密码不再落盘,release 门控明文流量。
- apk-chatBot 5334d10 — 登录页隐藏后端地址。