关于博客

博客音乐播放器从初版到 2.1:我做了哪些加固与优化

2026-07-25 #Cloudflare#Workers#Hexo#R2#音乐播放器
早风依旧
正在加载歌曲信息……
私有音源
0:00 0:00

最开始,我只是想在博客文章里放一个自己的音乐播放器。

要求看起来并不复杂:音乐能正常播放、暂停、拖动进度,音频文件不要直接暴露在公开链接里,也不要让访客复制地址后就能轻松下载完整文件。

真正开始做之后才发现,“能播放”和“适合长期公开使用”完全是两件事。

从最初版本到现在的 2.1 版,我没有推翻原来的播放器,而是在原有结构上逐步增加会话验证、单曲授权、地址栏拦截、防盗链、缓存和速率限制。现在把整个升级过程记录下来,方便以后自己回看,也方便出现问题时知道每一层到底在做什么。

一、最初版本解决了什么

最初的架构已经能完成基本播放链路:

1
2
3
4
5
Hexo 文章
→ 自定义播放器 JavaScript
→ Cloudflare Worker
→ 私有 R2 存储桶
→ 返回音频分段

音乐文件放在关闭公共访问的 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,因此仍可能播放音频,或者保存当前返回的第一分段的音乐数据。

最早的 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
2
Sec-Fetch-Dest: document
→ 403 Direct access forbidden

正常播放器请求仍然返回:

1
206 Partial Content

这个改动带来的变化很直接:

1
2
3
4
5
6
7
8
9
以前:
播放器取得 Cookie
→ 同一浏览器直接打开音频地址
→ 可能播放或保存第一段

升级后:
播放器取得 Cookie
→ 地址栏直接打开音频地址
→ 403 Direct access forbidden

需要说明的是,这不是 DRM,也不能阻止懂得构造 Range 请求的人抓取音频,但它能有效阻止最常见的“复制链接直接打开”。

2. 所有错误响应统一禁止缓存

Worker 把错误响应和成功响应统一加上:

1
2
3
Cache-Control: private, no-store, max-age=0
X-Content-Type-Options: nosniff
X-Request-ID: 请求编号

这样可以避免错误页面、鉴权失败结果或临时状态被浏览器及中间缓存错误保存。

3. 每个请求增加 X-Request-ID

Worker 优先使用 Cloudflare 的 CF-Ray,没有时再生成 UUID:

1
X-Request-ID: a20dcbe97d59502d

以后浏览器网络面板和 Worker 日志可以通过同一个请求编号对应起来。

4. 增加结构化日志

第一阶段新增了这些事件:

1
2
3
4
5
6
audio_session_created
audio_play_started
audio_direct_navigation_blocked
audio_session_invalid
audio_range_rejected
audio_track_not_found

日志采用 JSON 结构,而不是随意输出一句文字。

例如地址栏拦截会记录:

1
2
3
4
5
{
"event": "audio_direct_navigation_blocked",
"pathname": "/audio/stream/track-0001",
"fetchDestination": "document"
}

同时不会记录:

  • AUDIO_SESSION_SECRET;
  • 完整 Cookie;
  • 真实 R2 文件路径;
  • 用户密码。

第一阶段完成后,播放器、拖动进度和 512 KiB 分段都没有受到影响。

四、第二步:把临时权限绑定到单首音乐

第二步解决的是临时 Cookie 权限范围过大的问题。

升级前,Cookie 只代表:

1
允许访问音乐接口

升级后,Cookie 中包含:

1
2
3
4
5
版本号
trackId
过期时间
随机 nonce
HMAC 签名

例如播放器申请 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
2
3
4
5
6
7
8
9
10
未登录访问音乐
→ 跳转 Cloudflare Access 登录

登录后地址栏直接访问
→ Worker 返回 403

登录后从博客播放器请求
→ 验证 JWT
→ 创建绑定用户和单曲的 45 秒 Cookie
→ 返回 206

问题在于,这改变了产品目标。

我的博客音乐本来就是给普通访客播放的。如果把:

1
media.mcpq.cc/audio/*

放进 Cloudflare Access,普通访客点击播放器时就必须先登录,否则 /audio/session 会被 Access 拦截。

这虽然更严格,却破坏了公开博客的正常体验。

因此我最终撤销了这一阶段:

  • 从 Access 应用中移除 media.mcpq.cc/audio/*;
  • Worker 回退到第二阶段;
  • Cloudflare Access 只继续保护真正需要登录的私密文章;
  • 公开文章中的音乐仍允许普通访客直接播放。

六、第二步骤 2.1:防盗链、输入校验和成本优化

2.1 版没有重新要求用户登录,而是在第二阶段基础上增加三类能力:

1
2
3
防盗链
输入与目录校验
R2 调用优化

1. 拒绝明确的跨站请求

正常博客和音乐服务分别是:

1
2
https://blog.mcpq.cc
https://media.mcpq.cc

它们属于同一站点。

正常播放器请求通常会带:

1
2
3
Origin: https://blog.mcpq.cc
Sec-Fetch-Site: same-site
Sec-Fetch-Dest: audio

2.1 版增加了判断:

1
2
Sec-Fetch-Site: cross-site
→ 403 Cross-site request forbidden

同时,如果请求明确携带了不在允许列表中的 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
2
3
Content-Type 必须为 application/json
请求体最大 1 KiB
JSON 只能包含 trackId

合法请求保持不变:

1
2
3
{
"trackId": "track-0001"
}

提交超大请求体、错误 Content-Type 或额外异常字段时,Worker 会直接拒绝。

4. 加强 catalog.json 校验

2.1 版对音乐目录增加了明确限制:

1
2
3
4
catalog.json 最大 256 KiB
最多 1000 首音乐
trackId 必须符合格式
文件必须位于 music/files/

允许的音频格式包括:

1
2
3
4
5
6
.mp3
.m4a
.aac
.ogg
.wav
.flac

这样可以减少目录误配置、异常路径或超大配置文件影响 Worker 的可能性。

5. 增加 R2 元数据缓存

升级前,每一个 512 KiB 音频分段都会执行:

1
2
R2 head()
R2 get()

其中 head() 只是为了获取对象大小和元数据。

2.1 版增加了 5 分钟的对象元数据内存缓存:

1
2
3
4
5
第一次读取同一文件:
head() + get()

后续分段:
只执行 get()

缓存最多保存 256 条对象元数据。

Cloudflare Worker 的内存并不是全球共享的,不同运行实例之间也不会共享这份缓存,所以它不是绝对命中。但在同一个活跃实例内连续播放同一首歌时,可以减少重复的 head() 调用。

日志中还增加了:

1
metadataCacheHit: true / false

用于观察缓存是否命中。

七、平台层增加速率限制

Worker 代码完成后,又在 Cloudflare 域名安全规则中增加了一条速率限制:

1
2
3
4
5
6
主机名:media.mcpq.cc
路径:以 /audio/ 开头
统计特征:IP
限制:60 个请求 / 10 秒
动作:Block
持续时间:10 秒

对应表达式:

1
(http.host eq "media.mcpq.cc" and starts_with(http.request.uri.path, "/audio/"))

这条规则同时保护:

1
2
/audio/session
/audio/stream/*

正常一首约 5.8 MB 的音乐,在 512 KiB 分段下只需要十几个请求,远低于 10 秒 60 次。

我使用 70 个并发 HEAD 请求做了测试,结果是:

1
2
52 个请求返回 206
18 个请求被 Cloudflare 以 429 拦截

浏览器控制台里部分被拦截请求显示为 CORS 错误,是因为 Cloudflare 在 Worker 之前直接返回了 429 Too Many Requests,响应中没有播放器接口原本的 CORS 头。

这不代表 Worker 故障,实际说明平台限速规则已经成功生效。

等待 10 秒后,正常播放自动恢复。

八、升级后的完整请求链路

现在的最终链路是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
普通访客打开博客文章
↓
播放器 POST /audio/session
↓
Cloudflare 速率限制检查
↓
Worker 检查 Origin、Sec-Fetch-Site、Content-Type 和 trackId
↓
Worker 签发 45 秒、只绑定当前歌曲的 HMAC Cookie
↓
播放器 GET /audio/stream/track-0001
↓
再次经过速率限制
↓
Worker 拒绝 document 和 cross-site 请求
↓
验证 Cookie、歌曲 ID 和过期时间
↓
读取 catalog.json
↓
读取或复用 R2 对象元数据
↓
每次最多返回 512 KiB
↓
浏览器通过多个 206 响应完成播放

九、从初版到 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
2
3
4
5
6
7
8
9
10
11
复制音频地址到地址栏
→ 403

从其他网站跨站引用
→ 403

使用错误或过期的单曲 Cookie
→ 401

短时间内高频请求
→ 429

十一、对维护和成本的影响

维护更容易定位问题

通过 X-Request-ID 和结构化事件,我现在可以区分:

1
2
3
4
5
6
7
会话创建失败
Cookie 失效
地址栏访问
跨站请求
Range 不合法
歌曲不存在
R2 对象不存在

不需要再只看一个模糊的“播放失败”。

R2 读取更加克制

元数据缓存减少了同一 Worker 实例内重复的 head() 调用。

它不会减少音频本身必须进行的 get(),但能避免每个 512 KiB 分段都额外查询一次元数据。

高频滥用在到达 Worker 前就被拦截

Cloudflare 速率限制运行在 Worker 前面。

超过阈值的请求会直接返回 429,不再继续消耗后面的 Worker 和 R2 读取流程。

十二、目前仍然做不到什么

这套方案的目标是:

1
2
3
4
5
6
7
隐藏真实 R2 地址
阻止直接打开
限制跨站盗链
缩短授权有效期
限制单次读取大小
提高批量抓取成本
限制高频请求

它不是 DRM。

只要浏览器能够公开播放音频,浏览器最终就必须收到音频字节。熟悉开发者工具、Range 请求和脚本的人,仍然可能收集全部分段并重新拼接文件。

因此它能有效阻止普通用户的一键下载和简单盗链,但不能承诺“绝对无法下载”。

十三、最终状态

当前正式版本是:

1
Stage 2.1

最终保留了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
私有 R2
catalog ID 映射
45 秒临时会话
HMAC 签名
单曲绑定
512 KiB Range 分段
地址栏访问拦截
同站防盗链
统一安全响应头
X-Request-ID
结构化日志
catalog 校验
R2 元数据缓存
Cloudflare 速率限制

同时明确放弃了会破坏公开访问体验的方案:

1
音乐接口强制 Cloudflare Access 登录

这次升级之后,播放器不只是“可以播放”,而是变成了一套边界更清晰、日志更完整、成本更可控,同时仍然对普通访客友好的公开音乐服务。

安全不是把所有入口都锁死,而是在不破坏正常使用的前提下,让不正常的使用方式变得更难、更短暂,也更容易被发现。

评论
分享

评论