我的编程技术分享 返回文章列表

小程序直传对象存储:预签名 URL 与对象键隔离

小程序对象存储权限设计 2026-10-24

把用户文件存到对象存储,最省事的做法是服务端收文件再转发。问题是这样一来, 用户上传一个 500MB 的视频,流量要在这台 4 核 4G 的小机器上走两遍(进来的和出去的), 带宽和内存都会被吃掉。而且服务端一旦重启,正在传的文件直接废掉。

直传能绕开这个问题:客户端拿到一个带签名的 URL,自己把文件 PUT 到对象存储,服务端一个字节都不碰。 这篇记录这条路怎么走通,以及三个和权限有关的细节。

一、密钥怎么才能不发给手机

直传的第一反应是「把 SecretId / SecretKey 打进小程序」。这条路绝对不能走:

正确做法是预签名:密钥只存在服务端,客户端每次读写先向服务端要一个签名好的 URL, 这个 URL 里带了一个短期有效的签名,能且只能操作那一个对象。

// 客户端:先换签名,再直传
const signed = await post('/api/cos/sign', { op: 'put', key }, token)
if (signed.error) return { error: signed.error }

wx.request({
  url: signed.data.url,
  method: 'PUT',
  data: bytes,
  header: { 'content-type': 'application/octet-stream' },
  success(res) {
    if (res.statusCode >= 200 && res.statusCode < 300) resolve({ ok: true })
    else resolve({ error: `上传失败(HTTP ${res.statusCode})` })
  }
})

二、有效期要多短

代码里定的是上传 10 分钟、下载 15 分钟。这两个数字是按最坏情况倒推的:

反过来说,有效期也不能太短。曾经试过 60 秒,结果是弱网环境下签名先过期、文件还没传完, 用户看到的是「上传失败(HTTP 403)」,而服务端日志里一切正常——排查了很久才想到是签名超时。

三、对象键必须有前缀,而且必须校验

所有对象键统一是 users/{uid}/{相对路径}。这个前缀是整个权限模型的地基: 服务端签发 URL 之前,校验请求的 key 是否以 users/{当前登录用户}/ 开头, 不是就拒绝。用户拿不到别人的签名,也就碰不到别人的文件。

看起来一行 startsWith 就够了。实际上不够。

路径穿越:一个真实的漏洞

假设用户 A 的 uid 是 u_a,他提交这个 key:

users/u_a/vault/../../u_b/photo.jpg

字符串确实以 users/u_a/ 开头,startsWith 返回 true。 但如果下游把这个 key 直接拼进 URL 或者交给 SDK,部分实现会做路径归一化, 最终访问到 users/u_b/photo.jpg——越权读取别人的文件。

这个 bug 是我写测试时抓出来的,而且抓到的方式很偶然:我为了让 mock 行为和真实实现一致, 没有在 mock 里自己写一份校验,而是直接复用了真实模块。测试一跑, 构造的越权用例成功了——如果 mock 是我另写一份「看起来一样」的逻辑,这个洞会一直留着。

function ownedBy(key, uid) {
  if (!key || typeof key !== 'string') return false
  if (key.includes('..')) return false              // 直接拒绝,不做归一化
  if (key.includes('\\')) return false              // 反斜杠在某些实现里也是分隔符
  return key.startsWith(`users/${uid}/`)
}

处理方式是拒绝而不是「归一化后再校验」。归一化本身就依赖具体实现的分隔符规则, 而拒绝只需要一条规则:对象键里不该出现 ..。合法的对象键永远不需要它。

顺手记一条经验:凡是把用户输入拼进路径的地方,都要单独测一次 ..。 这条规律与语言、框架无关,二十年来没失效过。

四、删除为什么不直接删

最初的设计是「用户点删除 → 调 COS 删除接口」。跑了几天发现两个问题:

  1. 误删无法恢复。用户手滑删掉一个文件,客服什么都做不了,因为对象已经没了;
  2. 配额算错。删除要同时做三件事:删对象、删数据库记录、扣减已用空间。 客户端做这三件事,任何一步失败都会让三者不一致——最常见的是对象删了但配额没减, 用户明明清空了空间,界面还显示「已用 8.2 GB」。

改成「移入回收站」:客户端只调一个接口,服务端在一个事务里把对象标进回收站表、 删掉文件记录、扣减配额。真正的对象删除由后台定时任务在 30 天后执行。

// 客户端只发一次请求,不碰数据库、不碰配额
post('/api/trash/move', {
  items: items.map(item => ({
    cloudPath: item.cloudPath,
    parts: item.cloudParts || 1,
    enc: item.cloudEnc === true,
    size: Number(item.cloudSize) || 0,
  })),
}, token)

这个改动带来一条更普遍的约束:凡是涉及多个存储系统的写操作,收敛到服务端做。 客户端只负责发起意图,不负责保证一致性。

五、分块与内存

小程序里 wx.request 的 data 只能是一次性能拿到的数据。 一个大文件如果整个读进内存,在低端机上会直接 OOM。

做法是按固定大小切块,每块单独 PUT 成一个对象,再把块的序号记在文件元信息里。 下载和播放时按序号拼接。代价是对象数量变多、管理复杂度上升,收益是任意大小的文件都能处理, 且断了可以从断点续传。

切块大小定在 4MB 左右是经验值:太小则请求数爆炸(COS 对请求数计费), 太大则单块失败的重传成本高、内存占用也上去了。

六、这套结构里谁掌握什么

角色知道什么
客户端 自己的登录令牌、单次签名 URL;不知道存储密钥
服务端 存储密钥、用户身份、对象键;转发的是签名,不是文件
对象存储 密文、对象键、大小、上传时间;扩展名也在对象键里

最后一行值得单独说:加密只保护内容,不保护元数据。对象键写成 users/u_a/vault/2026-10/IMG_0413.jpg,存储方就知道这个用户在某个月存了一张 jpg。 要藏住这一层,得把对象键也做随机化,代价是列表功能需要自己维护一份映射表。 这个取舍取决于你到底在防谁。

七、小结

这条路的核心设计只有一句:客户端永远不持有长期凭证,只持有短期的、单对象的、带身份前缀的授权。

三个细节各自对应一类真实事故:密钥进包对应「整桶被拿走」,路径穿越对应「越权读别人的文件」, 客户端直删对应「数据与配额不一致」。这三个都不是理论风险,都是我在上线前实际撞到的。