把长期密钥从前端撤回:一次遗留上传链路的两阶段治理
一次遗留项目接管中的上传链路治理:先止血恢复业务,再把长期密钥从前端撤回;两阶段之外,还有内容安全与发布顺序的边界。
我接手这个小程序时,项目已经进入收尾阶段,但多个模块仍需要返工。那阶段的首要问题不是把某个模块设计成理想形态,而是在交付压力下判断:哪些功能先恢复,哪些风险必须留账,哪些旧路径最终要拆掉。
上传就是其中之一。
它采用客户端直传对象存储:文件不经过业务服务器,客户端自己计算签名,再把图片或视频直接传上去。省带宽、省中转,但长期密钥也在前端。
一、先止血,不等于治理
问题不是被安全审计发现的,是被一个 400 报错带出来的。
某天用户上传失败。顺着链路查下去,发现前端配置里的密钥是空的。为了最快恢复功能,我选择了改动最小的方案:把已有的真实密钥补进去。作品提交、入驻上传随即实测通过。
从业务角度看,上传恢复了。从工程角度看,风险才刚刚变得具体。
因为这次修复只回答了「能不能传」,没有回答「谁能传」。密钥从空变成可用,客户端持有长期密钥这件事没有改变,暴露面反而从「不可用」变成了「可用」。
所以我在止血时就把长期治理单列成技术债:这不是我最初设计的架构,但既然由我选择暂时沿用,就不能把临时方案遗忘成永久方案。止血是止血,治理是治理,两件事不能互相记账。
图 1:改造分两阶段——第一阶段只让业务恢复,第二阶段才回答「谁能传」。完成度按实际记录标注:前端撤钥与旧路径删除已完成,密钥轮换仍未见执行回执,最小权限尚未确认。
二、为什么「把密钥藏得更好」不是答案
第一反应通常是:那就把密钥藏深一点。混淆、加密、拆包、运行时拼接,总有一款能让逆向的人多花点时间。
这些手段有用,但解决的不是同一个问题。
只要签名在客户端算,密钥就必须到客户端。安装包可以被解开,内存可以被抓,字符串可以被还原。混淆提高的是提取成本,不是消除提取可能。更关键的是,这把密钥长期有效:今天被拿走,明天还能用;一个人拿走,所有人都可能拿到同一把钥匙。
我担心的不是对象存储平台被攻破,而是密钥泄露后,别人直接获得这把密钥所拥有的权限:批量上传、把存储当网盘、消耗由我们付费的流量;如果权限范围足够大,还可能影响已有数据。
所以真正的判断不是「藏得够不够深」,而是「这个能力该不该放在客户端」。
答案是不该。客户端需要的不是长期密钥,而是一次受约束的上传许可。
三、直传保留,签名能力收回
这里其实有两个独立的选型。
第一层是文件走哪里。我们的轻量服务器带宽和流量都不宽裕,普通请求响应消耗有限,图片和视频却会把传输成本迅速放大。因此直传要保留,不能为了收权又把所有文件搬回服务器中转。
第二层是客户端凭什么直传。根治方案是把签名能力搬回后端:用户登录后,上传前请求业务接口;后端用自己的密钥算好 policy 和签名,返回给客户端;客户端拿着这份签名仍然直传对象存储。长期密钥不再随安装包下发。
这次没有引入更完整的 STS 临时凭证体系,实际落地的是后端 policy+HMAC 签名端点。客户端拿到的不是能持续生成许可的钥匙,而是一张针对目标文件的放行单。
图 2:职责边界的关键不在「密钥放在哪」,而在「哪一方持有哪种能力」。客户端拿到的是放行单,不是能开整个仓库的钥匙;服务端不中转文件,对象存储不持有业务身份。
这张放行单被四道条件约束:存储位置固定、路径前缀限定、文件大小有上限、内容类型有限定。其中类型约束在实测里不够稳定,我们留了降级撤除的备案。一条验不稳的规则,硬留着只会制造误伤。
在此之上还有两条:每次签发只针对一个目标文件,按用户隔离;每个用户每分钟最多请求二十次签名。
这些约束主要治理写入行为,不应被夸大成绝对防重放,也不能把内容类型约束当成内容审核。存储桶本身的公共读语义也不在这次改造中改变:它解决的是长期凭证暴露与写入约束,不是把公开内容变成私有内容。
四、每一条限制都要有负例
安全改造最容易犯的错,是只验证「正常能用」。
我们验的是另一边:
- 不带登录凭证,必须 401。
- 伪造一个凭证,必须 401。
- 拿真实凭证,必须 200,而且返回的六个字段一个不少。
- 四道 policy 约束,逐条构造越界请求,逐条拒绝。
- 连续请求二十一次签名,前二十次成功,第二十一次必须 429。
- 最后真传一个文件,返回 204,拿到的地址可以正常访问。
这套矩阵里,200 只占一项,其余全是「不该通过的必须过不去」。开发自验之外还做了一轮独立复审,代码、文档、复用环境复测都重跑了一遍,打回一项配置问题,改完才合并。
五、上传安全还有另一道闸:内容本身
密钥治理之前,微信提审已经给我上过另一课。
一次提审中,平台指出头像功能存在内容安全风险,审核员上传的违规头像已经留在存储和数据库里。这说明「谁能传」和「传了什么」不是同一个问题:即使上传权限收得再好,正常用户仍然可能提交违规内容。
整改时,我盘点了头像、个人资料、作品和自由文本等用户内容入口,在后端统一接入微信的文本与图片内容安全接口。接口正常时,检测通过才进入后续发布流程,违规则拒绝;它是一道前置闸,不替代原有的人工审核。
但外部接口本身也会故障或超频。我当时选择降级放行:不让微信侧的故障导致我们的上传和提交功能整体不可用。代价是,在头像这类提交后直接生效的场景里,未经检测的内容可能先展示。
所以降级不能只是静默放行。我们补了后台降级台账,记录发生时间和相关内容,保留人工复核与重新检测的入口,让外部服务故障转化为一条可见、需要处理的工作记录。
现在回头看,更稳妥的默认方案是把「提交」与「公开展示」解耦:检测不可用时允许保存和提交,但内容先进入待审状态,检测恢复或人工通过后再公开。它也有审核积压和延迟展示的代价,但能把风险从公开面收回到待审队列里。
这是事后复盘,不是当时已经实现的形态。当时实际选择的是业务连续性优先,再用台账和人工补偿短期内容风险。
这次整改最终通过了复审;生产验证覆盖了违规样本必拦、正常样本放行和审计留痕。
六、删代码比加代码重要
新接口上线,不等于旧风险消失。
前端原来有一套本地签名逻辑,还有一段历史遗留的、已经没人调用的旧实现,里面躺着第二份密钥明文。这次一起删了,七个文件清零。
如果只加新路径、不删旧路径,结果会是:新用户走签名接口,老代码还留在包里,密钥还在包里。风险面一点没减,只是多了一个看起来更安全的入口。
所以这次改造的验收标准里,「旧代码是否彻底消失」和「新接口是否可用」是同等重要的两项。
七、发布顺序就是安全的一部分
旧版本的小程序还在用旧密钥直传;如果先在控制台把密钥换掉,所有没升级的用户上传会立刻失败。功能故障会先于安全收益到来。
我们定的顺序是:
- 后端先上。新签名接口可用,但旧直传路径仍然活着,两边并存。
- 然后提审、发布新版本。
- 等新版覆盖率上来,让绝大多数上传走新路径。
- 再轮换长期密钥,计划让新旧两把并存,不留切换空窗。
- 最后,清理本地密钥文件。
这是一段有顺序的迁移,而不是一次要求所有用户同时完成的切换。
上线那天晚上,后端窗口停机二十七秒,零回滚;第二天新版本过审发布,签名接口进入观察期。观察期看两个信号:正常时,新接口的 200 请求随覆盖率上升;异常时,500 成簇,或者同一个用户反复 401、429。
八、还没做完的部分
这篇稿子不能写成「已经解决」。
长期密钥轮换没有完成,因此不能说旧密钥已经失效,也不能把新链路上线写成整个凭证生命周期已经销账。
那把旧密钥的权限边界,当时也没有完全确认,所以我不能宣称已经做到「最小权限」。
对象存储里残留测试文件的清理、相关运维文档的口径同步,也都是收尾项。内容安全侧同样不能把前置检测写成覆盖全部历史对象。
规模也要说清楚:这是一个小团队的小程序,不是企业级大流量系统。这次改造的价值不在并发量,而在把一件「一直能用所以一直没人管」的事,拆成了可验证、可回退的几步。
九、我学到的
遗留系统里的临时方案并不罕见。真正危险的不是暂时选择它,而是没有留下账,最后连自己都忘了它为什么存在。
这次治理要回答的也不只是一个密钥问题:签名能力应该放在哪里;上传内容由谁检测;外部安全接口故障时业务怎么继续;旧路径与旧凭证是否真正退出。
这些问题都不只在代码里,也在排序、验收和事后复盘里。