不要把临时 OSS 地址直接交给前端:录音安全中转与签名链接设计
一句话理解
对象存储返回的录音 URL 不应该直接成为业务系统的永久下载地址。更稳定的设计是:业务系统保留上游临时地址,由自己的下载接口完成权限校验、有效期校验和流式中转,对外只暴露业务域名下的受控链接。
本文来自会议录音下载的实际改造,适用于音视频、附件、转写文件和其他需要跨系统访问的对象存储资源。
为什么直接返回对象存储 URL 会出问题
直接把上游 OSS 地址交给浏览器看起来最省事,但会引入几个长期问题。
临时地址会过期
许多对象存储链接带有签名和过期时间。业务数据库保存该 URL 后,用户过几天从 OA 或历史页面再次点击,可能只能看到 403 或 404。
权限模型被绕过
如果前端拿到完整 URL,后续下载不再经过业务系统。业务系统无法确认访问者是否属于这场会议,也难以撤销单个用户的访问权。
上游地址可能泄露
URL 可能进入浏览器历史、前端日志、第三方埋点和分享内容。即使签名最终过期,也扩大了不必要的暴露面。
浏览器能力受上游配置影响
跨域、下载文件名、缓存、Range 和错误信息都由对象存储决定。不同环境的存储域名和响应头变化会直接影响前端。
两类下载入口
会议系统需要支持两种访问方式:
- 系统内用户下载:请求携带业务 JWT,后端检查会议归属;
- 外部业务页面下载:数据库中保存业务域名下的短期签名链接,不要求外部页面理解本系统登录态。
flowchart LR
USER[系统内用户] -->|业务 JWT| PRIVATE[/api/meetings/id/recording]
OA[外部业务页面] -->|会议绑定签名| PUBLIC[/api/public/meetings/id/recording]
PRIVATE --> AUTH[权限/归属校验]
PUBLIC --> TOKEN[签名、会议 ID、过期时间校验]
AUTH --> PROXY[录音代理服务]
TOKEN --> PROXY
PROXY --> OSS[可信 HTTPS 对象存储]
OSS -->|InputStream| PROXY
PROXY -->|流式响应| USER
PROXY -->|流式响应| OA
两个入口共享同一个底层代理服务,区别只在进入代理前如何证明访问权限。
签名链接需要绑定什么
公开下载链接不能只是一个随机字符串。至少应绑定:
- 会议 ID;
- 签发用途,例如
recording-download; - 过期时间;
- 签名或 JWT 完整性校验。
请求路径中的会议 ID 必须和 Token 中的会议 ID 一致,否则攻击者可能拿一场会议的 Token 枚举其他录音。
外部链接的有效期还不能超过上游 OSS 地址本身的有效期。实践中采用:
业务链接过期时间 = min(上游地址过期时间, 当前时间 + 业务最大有效期)
此外还要检查外部系统数据库字段长度。本次实践中,写入 OA 的录音地址字段有固定长度,因此生成链接后会在写库前检查总长度,避免到数据库层才失败。
防止代理接口变成 SSRF 工具
后端根据数据库中的 URL 发起网络请求,本质上具备服务器端请求伪造(SSRF)风险。如果攻击者能影响 URL,就可能尝试访问 127.0.0.1、云元数据地址或内网服务。
最低限度应实施以下限制:
- 只允许
https; - 主机名必须存在;
- 主机名必须匹配明确白名单;
- 禁止自动跟随重定向;
- 设置连接和整体请求超时。
示例配置:
# 多个规则用英文逗号分隔。
RECORDING_PROXY_ALLOWED_HOSTS=".storage.example.com,audio.example.net"
RECORDING_PROXY_CONNECT_TIMEOUT_MS=10000
白名单规则必须按主机边界匹配。若规则为 .storage.example.com,可以允许 bucket.storage.example.com,但不能仅用字符串 contains,否则 storage.example.com.evil.test 也可能被误判为可信。
示例伪代码:
boolean allowed(String host, String rule) {
return rule.startsWith(".")
? host.endsWith(rule)
: host.equals(rule);
}
不跟随 30x 同样重要。即使初始 URL 在白名单内,上游也可能把请求重定向到另一个主机;自动跟随会绕过首次校验。
为什么要流式转发
录音文件可能很大。后端若先执行 readAllBytes() 再返回,会让单次下载占用与文件大小相当的堆内存,并增加延迟。
正确模式是让 HTTP 客户端返回 InputStream,再使用 Spring 的 InputStreamResource 写入响应。这样内存占用与缓冲区大小相关,而不是与整个录音大小相关。
响应中还可以设置:
Content-Disposition: attachment; filename="meeting-recording.mp3"
Cache-Control: private, no-store, max-age=0
X-Accel-Buffering: no
X-Accel-Buffering: no 用于提示 Nginx 不要等待整个上游响应后再一次性发送。是否生效仍取决于具体代理配置。
支持 HTTP Range
音频播放、断点续传和部分下载经常使用 Range:
Range: bytes=0-1048575
代理应将合法 Range 转发给对象存储,并把以下响应信息传回客户端:
- HTTP 206;
Content-Range;Accept-Ranges;- 对应片段的
Content-Length。
为了降低复杂度,本次实践只支持单段 Byte Range,并拒绝多段请求:
允许:bytes=0-1023
允许:bytes=1024-
允许:bytes=-1024
拒绝:bytes=0-3,5-8
拒绝:bytes=-
多段 Range 需要构造 multipart/byteranges 响应,若业务没有明确需求,不值得在第一版实现中引入额外解析和边界处理。
上游错误如何映射
代理不应把对象存储的所有细节原样暴露给前端。可以采用有限的业务映射:
- 上游 200/206:正常流式返回;
- 上游 403/404:映射为资源已过期或不可用;
- 连接失败、超时、其他状态:映射为 502;
- 业务签名无效:返回 401;
- 会议不存在:返回 404;
- 业务记录明确显示地址过期:返回 410。
这种映射既保留了可诊断性,也避免把上游签名参数和存储实现直接暴露给用户。
最小测试矩阵
代理功能至少要覆盖:
- 可信 HTTPS 主机可以流式下载;
- 非 HTTPS 地址在发起网络请求前被拒绝;
127.0.0.1和非白名单主机被拒绝;allowed.example.evil.test不能通过后缀校验;- 单段 Range 被正确转发,206 头被保留;
- 多段 Range 返回 416;
- 上游 403/404 不继续输出响应流;
- Token 过期、会议 ID 不匹配时拒绝公开下载;
- 业务签名有效期不超过上游地址;
- 生成链接不会超过外部数据库字段长度。
适用边界和后续改进
后端流式代理会消耗应用服务器出口带宽和连接数。下载量很大时,可以进一步评估:
- 由后端鉴权后生成更短期的对象存储签名;
- 使用具备鉴权能力的 CDN;
- 将下载代理拆成独立服务;
- 增加并发、速率和单用户配额;
- 对主机名解析结果增加内网 IP 防护,进一步降低 DNS Rebinding 风险。
因此,这个方案适合需要统一权限、外部系统兼容和中等下载规模的业务。它不是所有大流量媒体分发场景的最终答案。
总结
录音下载不是简单的“返回一个 URL”。一个可维护的实现需要同时考虑身份、有效期、SSRF、Range、流式内存占用、外部字段长度和错误语义。把对象存储地址隐藏在业务下载接口之后,可以让前端和外部系统只依赖稳定的业务域名,也为后续权限调整和存储迁移保留空间。