歌词尚未加载
最开始,我只是想在博客文章里放一个自己的音乐播放器。
要求看起来并不复杂:音乐能正常播放、暂停、拖动进度,音频文件不要直接暴露在公开链接里,也不要让访客复制地址后就能轻松下载完整文件。
真正开始做之后才发现,“能播放”和“适合长期公开使用”完全是两件事。
从最初版本到现在的 2.1 版,我没有推翻原来的播放器,而是在原有结构上逐步增加会话验证、单曲授权、地址栏拦截、防盗链、缓存和速率限制。现在把整个升级过程记录下来,方便以后自己回看,也方便出现问题时知道每一层到底在做什么。
一、最初版本解决了什么
最初的架构已经能完成基本播放链路:
1 | Hexo 文章 |
音乐文件放在关闭公共访问的 Cloudflare R2 中,文章里只写音乐 ID,例如:
1 | track-0001 |
Worker 再通过:
1 | music/catalog.json |
把音乐 ID 映射到真实的 R2 文件路径。
这样做以后,文章源码里不会直接出现:
1 | music/files/track-0001.mp3 |
Worker 通过 AUDIO_BUCKET 绑定读取 R2,也不需要在前端或源码中暴露 R2 Access Key。
初版还具备几个很重要的基础能力:
- R2 存储桶保持私有;
- 支持 HTTP Range 分段读取;
- 浏览器可以正常播放、暂停和拖动进度;
- 单次最多返回
512 KiB; catalog.json在 Worker 内存中缓存 60 秒;- 音频响应使用
private, no-store; - 通过
AUDIO_SESSION_SECRET签发临时播放 Cookie。
这已经比直接把 MP3 链接写进文章安全很多,但仍然存在一些明显问题。
二、初版存在的问题
初版虽然能播放,但保护边界还不够完整。
1. 同一浏览器可以直接打开音频地址
播放器申请到临时 Cookie 后,如果在同一个浏览器的新标签页里直接打开:
1 | https://media.mcpq.cc/audio/stream/track-0001 |
浏览器会自动携带 Cookie,因此仍可能播放音频,或者保存当前返回的第一分段的音乐数据。
2. 临时 Cookie 没有绑定具体歌曲
最早的 Cookie 只表示:
1 | 这个浏览器暂时可以访问音乐接口 |
它没有严格表达:
1 | 这个浏览器只允许访问 track-0001 |
拿到会话后,理论上可以继续尝试其他音乐 ID。
3. 错误响应的缓存策略不统一
成功的音频响应已经设置了禁止缓存,但部分错误响应没有明确设置完整的安全响应头。
省略 Cache-Control 不代表浏览器或中间节点一定不会缓存,因此错误响应也应该统一处理。
4. 缺少足够清晰的日志
出现播放失败、Cookie 失效、Range 异常或曲目不存在时,很难快速判断问题发生在哪一层。
5. 其他网站仍可能尝试引用接口
只检查 Origin 还不够完整。其他站点仍可能尝试通过自己的 <audio>、脚本或工具请求音乐接口。
6. 高频请求没有平台级限制
即使每次只返回 512 KiB,自动化脚本仍然可以高频请求 /audio/session 和 /audio/stream/*,增加 Worker 与 R2 的调用量。
三、第一步:补齐基础安全边界
第一步没有修改播放器协议,也没有改变 R2 路径和 Range 逻辑,主要是在现有代码上补齐安全和可观测性。
1. 阻止浏览器地址栏直接打开
浏览器从地址栏打开资源时,通常会发送:
1 | Sec-Fetch-Dest: document |
而正常的 <audio> 播放请求通常是:
1 | Sec-Fetch-Dest: audio |
因此 Worker 增加了判断:
1 | Sec-Fetch-Dest: document |
正常播放器请求仍然返回:
1 | 206 Partial Content |
这个改动带来的变化很直接:
1 | 以前: |
需要说明的是,这不是 DRM,也不能阻止懂得构造 Range 请求的人抓取音频,但它能有效阻止最常见的“复制链接直接打开”。
2. 所有错误响应统一禁止缓存
Worker 把错误响应和成功响应统一加上:
1 | Cache-Control: private, no-store, max-age=0 |
这样可以避免错误页面、鉴权失败结果或临时状态被浏览器及中间缓存错误保存。
3. 每个请求增加 X-Request-ID
Worker 优先使用 Cloudflare 的 CF-Ray,没有时再生成 UUID:
1 | X-Request-ID: a20dcbe97d59502d |
以后浏览器网络面板和 Worker 日志可以通过同一个请求编号对应起来。
4. 增加结构化日志
第一阶段新增了这些事件:
1 | audio_session_created |
日志采用 JSON 结构,而不是随意输出一句文字。
例如地址栏拦截会记录:
1 | { |
同时不会记录:
AUDIO_SESSION_SECRET;- 完整 Cookie;
- 真实 R2 文件路径;
- 用户密码。
第一阶段完成后,播放器、拖动进度和 512 KiB 分段都没有受到影响。
四、第二步:把临时权限绑定到单首音乐
第二步解决的是临时 Cookie 权限范围过大的问题。
升级前,Cookie 只代表:
1 | 允许访问音乐接口 |
升级后,Cookie 中包含:
1 | 版本号 |
例如播放器申请 track-0001 时,Cookie 只允许访问:
1 | /audio/stream/track-0001 |
如果拿着同一份 Cookie 请求:
1 | /audio/stream/track-0002 |
Worker 会返回:
1 | 401 Audio session expired or track denied |
这使临时权限从“接口级”缩小到了“单曲级”。
45 秒会话没有改变使用体验
Cookie 有效期仍然是:
1 | 45 秒 |
播放器会自动刷新会话,访客不需要手动操作,也不会每 45 秒重新登录或点击确认。
它的作用是缩短链接和 Cookie 被复制后的有效窗口,而不是限制正常播放时长。
五、一次做过头的尝试:Cloudflare Access
在第二步之后,我曾尝试把音乐接口加入 Cloudflare Access,并让 Worker 验证 Access JWT。
技术上,这一版是成功的:
1 | 未登录访问音乐 |
问题在于,这改变了产品目标。
我的博客音乐本来就是给普通访客播放的。如果把:
1 | media.mcpq.cc/audio/* |
放进 Cloudflare Access,普通访客点击播放器时就必须先登录,否则 /audio/session 会被 Access 拦截。
这虽然更严格,却破坏了公开博客的正常体验。
因此我最终撤销了这一阶段:
- 从 Access 应用中移除
media.mcpq.cc/audio/*; - Worker 回退到第二阶段;
- Cloudflare Access 只继续保护真正需要登录的私密文章;
- 公开文章中的音乐仍允许普通访客直接播放。
六、第二步骤 2.1:防盗链、输入校验和成本优化
2.1 版没有重新要求用户登录,而是在第二阶段基础上增加三类能力:
1 | 防盗链 |
1. 拒绝明确的跨站请求
正常博客和音乐服务分别是:
1 | https://blog.mcpq.cc |
它们属于同一站点。
正常播放器请求通常会带:
1 | Origin: https://blog.mcpq.cc |
2.1 版增加了判断:
1 | Sec-Fetch-Site: cross-site |
同时,如果请求明确携带了不在允许列表中的 Origin,也会被拒绝。
为了兼容部分旧浏览器和特殊客户端,代码不会因为缺少 Sec-Fetch-Site 就直接拒绝,只拒绝明确标记为 cross-site 的请求。
2. 增加 Cross-Origin-Resource-Policy
音频及相关响应增加:
1 | Cross-Origin-Resource-Policy: same-site |
它告诉浏览器:
1 | 该资源只允许同站页面使用 |
正常的 blog.mcpq.cc → media.mcpq.cc 播放不受影响,而其他站点直接嵌入的难度会提高。
3. 限制会话接口的请求体
POST /audio/session 现在要求:
1 | Content-Type 必须为 application/json |
合法请求保持不变:
1 | { |
提交超大请求体、错误 Content-Type 或额外异常字段时,Worker 会直接拒绝。
4. 加强 catalog.json 校验
2.1 版对音乐目录增加了明确限制:
1 | catalog.json 最大 256 KiB |
允许的音频格式包括:
1 | .mp3 |
这样可以减少目录误配置、异常路径或超大配置文件影响 Worker 的可能性。
5. 增加 R2 元数据缓存
升级前,每一个 512 KiB 音频分段都会执行:
1 | R2 head() |
其中 head() 只是为了获取对象大小和元数据。
2.1 版增加了 5 分钟的对象元数据内存缓存:
1 | 第一次读取同一文件: |
缓存最多保存 256 条对象元数据。
Cloudflare Worker 的内存并不是全球共享的,不同运行实例之间也不会共享这份缓存,所以它不是绝对命中。但在同一个活跃实例内连续播放同一首歌时,可以减少重复的 head() 调用。
日志中还增加了:
1 | metadataCacheHit: true / false |
用于观察缓存是否命中。
七、平台层增加速率限制
Worker 代码完成后,又在 Cloudflare 域名安全规则中增加了一条速率限制:
1 | 主机名:media.mcpq.cc |
对应表达式:
1 | (http.host eq "media.mcpq.cc" and starts_with(http.request.uri.path, "/audio/")) |
这条规则同时保护:
1 | /audio/session |
正常一首约 5.8 MB 的音乐,在 512 KiB 分段下只需要十几个请求,远低于 10 秒 60 次。
我使用 70 个并发 HEAD 请求做了测试,结果是:
1 | 52 个请求返回 206 |
浏览器控制台里部分被拦截请求显示为 CORS 错误,是因为 Cloudflare 在 Worker 之前直接返回了 429 Too Many Requests,响应中没有播放器接口原本的 CORS 头。
这不代表 Worker 故障,实际说明平台限速规则已经成功生效。
等待 10 秒后,正常播放自动恢复。
八、升级后的完整请求链路
现在的最终链路是:
1 | 普通访客打开博客文章 |
九、从初版到 2.1,实际带来了哪些变化
| 能力 | 初版 | 2.1 版 |
|---|---|---|
| R2 私有存储 | 支持 | 保留 |
| 隐藏真实 R2 路径 | 支持 | 保留 |
| 45 秒临时 Cookie | 支持 | 保留 |
| Cookie 绑定具体歌曲 | 不完整 | 支持 |
| 单次最多 512 KiB | 支持 | 保留 |
| Range 拖动播放 | 支持 | 保留 |
| 地址栏直接打开拦截 | 不支持 | 403 |
| 明确跨站请求拦截 | 不支持 | 403 |
| 同站资源策略 | 无 | CORP: same-site |
| 错误响应统一禁止缓存 | 不完整 | 支持 |
| 请求追踪 | 无 | X-Request-ID |
| 结构化日志 | 很少 | 支持 |
| 会话请求体限制 | 基础 | 1 KiB + JSON 结构校验 |
| catalog 大小与数量限制 | 无 | 支持 |
| R2 元数据缓存 | 无 | 5 分钟 |
| 高频请求保护 | 无 | 60 次 / 10 秒 / IP |
| 普通访客是否需要登录 | 不需要 | 仍然不需要 |
十、对访客体验的影响
从访客角度看,这次升级几乎没有增加操作成本。
普通访客仍然可以:
- 直接打开公开博客文章;
- 点击播放;
- 暂停;
- 调节音量;
- 拖动进度;
- 切换歌曲。
他们不需要:
- 登录 Cloudflare;
- 输入验证码;
- 等待人工授权;
- 每 45 秒重新操作。
新增的验证、续期、分段和缓存都在后台完成。
真正发生变化的是异常使用方式:
1 | 复制音频地址到地址栏 |
十一、对维护和成本的影响
维护更容易定位问题
通过 X-Request-ID 和结构化事件,我现在可以区分:
1 | 会话创建失败 |
不需要再只看一个模糊的“播放失败”。
R2 读取更加克制
元数据缓存减少了同一 Worker 实例内重复的 head() 调用。
它不会减少音频本身必须进行的 get(),但能避免每个 512 KiB 分段都额外查询一次元数据。
高频滥用在到达 Worker 前就被拦截
Cloudflare 速率限制运行在 Worker 前面。
超过阈值的请求会直接返回 429,不再继续消耗后面的 Worker 和 R2 读取流程。
十二、目前仍然做不到什么
这套方案的目标是:
1 | 隐藏真实 R2 地址 |
它不是 DRM。
只要浏览器能够公开播放音频,浏览器最终就必须收到音频字节。熟悉开发者工具、Range 请求和脚本的人,仍然可能收集全部分段并重新拼接文件。
因此它能有效阻止普通用户的一键下载和简单盗链,但不能承诺“绝对无法下载”。
十三、最终状态
当前正式版本是:
1 | Stage 2.1 |
最终保留了:
1 | 私有 R2 |
同时明确放弃了会破坏公开访问体验的方案:
1 | 音乐接口强制 Cloudflare Access 登录 |
这次升级之后,播放器不只是“可以播放”,而是变成了一套边界更清晰、日志更完整、成本更可控,同时仍然对普通访客友好的公开音乐服务。
安全不是把所有入口都锁死,而是在不破坏正常使用的前提下,让不正常的使用方式变得更难、更短暂,也更容易被发现。
评论