前言
视频站最典型的一次事故是这样的:付费课程上线两个月,某天发现带宽账单突然涨了几倍。追查之后发现,只要在播放页按一下开发者工具,就能在 Network 面板里看到一条形如/videos/2026/course-advanced/lesson-03.mp4的请求,域名正是站点的 CDN ——这就是文件的真实地址。把它复制到别的网站,或者直接丢进下载器,就能无限次白嫖。
你可能已经做过防护,但往往是这几种「防了个寂寞」的做法:
- 在页面上用 JS 把
src藏起来,或者右键禁用;抓包一看地址还是明文; - 加了
Referer白名单,结果被浏览器隐私设置或反向代理抹掉 Referer 的正常用户一访问就 403; - 在 URL 后面挂了一个固定的
?token=abc123,这个 token 从不变化,被人复制走之后,从此形同虚设; - 换成了 HLS(m3u8 + ts 分片),结果 m3u8 是明文的,分片地址一眼可见。
这些做法共同的缺陷是:它们保护的是「页面」,而不是「资源」。真正的防护目标只有一个——让地址一次一签、短期有效、绑定上下文,使得任何一个被抓到的地址,几分钟后就自动作废。
本文用 PHP 8 讲清楚三件事:签名 URL(signed URL)怎么设计才安全、PHP 侧如何只做鉴权而不做流量中转、以及Range请求没处理好会导致的「进度条拖不动」。所有示例最低要求PHP 8.0。
一、先看清各层防线能挡住什么
防盗链从来不是「一个开关」,而是分层。各层的成本与效果差别极大:
| 方案 | 实现成本 | 能挡住直接下载 | 能挡住跨站盗播 | 能限制有效期 | 主要缺陷 |
|---|
隐藏src/ 禁用右键 | 极低 | 否 | 否 | 否 | 纯心理安慰,抓包即破 |
Referer白名单 | 低 | 否 | 部分 | 否 | Referer 可伪造,也可被浏览器省略 |
| 固定 token 参数 | 低 | 否 | 否 | 否 | token 泄漏即永久失效 |
| HMAC 签名 URL | 中 | 是 | 是 | 是 | 需要一个密钥管理方案 |
| 签名 URL + 短期 Cookie | 中 | 是 | 是 | 是 | 需要处理 Cookie 生命周期 |
CDN 侧签名(如 Nginxsecure_link) | 中 | 是 | 是 | 是 | 需要 CDN/边缘支持 |
| DRM / 私钥加密 | 高 | 是 | 是 | 是 | 成本高,PHP 侧只负责发许可证 |
Referer值得作为第一层保留——它成本极低,能过滤掉绝大多数「随手引用」的场景。但它绝不能是唯一一层,因为curl -e "任意值"一行命令就能把请求头伪造出来。真正的主防线是签名 URL:即使地址被抓到,它也已经在几分钟后失效了。
二、签名 URL 的设计要点
签名 URL 的核心是:用服务端密钥,对「资源 + 过期时间 + 上下文」算一个 HMAC,把结果附在 URL 上。服务端收到请求时重新算一遍,对不上就拒绝。
四个设计要点,缺一不可:
- 算法用 HMAC-SHA256,不要用
md5($path . $secret)。后者存在长度扩展攻击的风险,而且拼接顺序不明确($a . $b和$b . $a可能撞出同一个串)。HMAC 是专为「带密钥的消息认证」设计的结构。 - 比较用
hash_equals(),不要用==或===。字符串比较是短路的,逐字节比较的时间差会泄漏签名前缀,理论上可以被逐位爆破。hash_equals()是恒定时间比较。 - 过期时间必须参与签名,否则攻击者可以直接改
e参数把有效期无限延长。 - 签名要覆盖完整的资源路径,而不只是文件名。只签文件名的话,攻击者可以把
/free/a.mp4的签名挪到/vip/b.mp4上(如果两者文件名相同)。
另外,把用户标识或客户端 IP也纳入签名,可以让地址「绑定到人」:别人拿走也没用。代价是同一用户换网络(手机切 Wi-Fi)就会失效,要不要加取决于业务。
下面是一个可直接使用的签名类:
<?php
declare(strict_types=1);
// 最低 PHP 8.0(用到了构造函数属性提升)
final class SignedUrl
{
public function __construct(private string $secret)
{
if (strlen($secret) < 32) {
throw new InvalidArgumentException('密钥至少 32 字节');
}
}
/** 生成带签名的地址:/play/xxx.mp4?e=过期时间戳&s=签名 */
public function sign(string $path, int $ttlSeconds = 300, string $binding = ''): string
{
$expires = time() + $ttlSeconds;
$sig = $this->signature($path, $expires, $binding);
return sprintf('%s?e=%d&s=%s', $path, $expires, $sig);
}
/** 恒定时间校验 */
public function verify(string $path, int $expires, string $sig, string $binding = ''): bool
{
if ($expires < time()) {
return false; // 已过期
}
if ($expires > time() + 86400) {
return false; // 明显被篡改过,正常签名不会超过一天
}
return hash_equals($this->signature($path, $expires, $binding), $sig);
}
private function signature(string $path, int $expires, string $binding): string
{
// 用 \n 分隔各字段,避免「a|b」和「a|b」这类拼接歧义
$payload = $path . "\n" . $expires . "\n" . $binding;
$raw = hash_hmac('sha256', $payload, $this->secret, true);
// URL 安全 base64:+/ 换 -_,去掉填充的 =
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
}用法示例:
<?php
$signer = new SignedUrl(getenv('VIDEO_SIGNING_KEY') ?: str_repeat('k', 32));
// 详情页渲染时,为这个用户签一个 5 分钟有效的播放地址
$playUrl = $signer->sign('/play/lesson-03.mp4', 300, (string)$userId);
echo $playUrl, PHP_EOL;
// /play/lesson-03.mp4?e=1790000300&s=QmFzZTY0dXJsX3NhZmVfZGVtb19zaWc注意binding用用户 ID 还是 IP,是一道业务权衡:用 ID 更稳(用户换网络不影响),用 IP 更严(分享给别人也用不了)。生产环境建议用 ID,因为移动网络下 IP 变化太频繁。
三、架构:让真实路径永远不出现在响应里
签名校验通过之后,文件怎么发出去?有两种做法,差别很大。
做法 A:PHP 直接转发(proxy)。readfile()或分块fread()把文件读进 PHP 进程再吐给客户端。优点是纯 PHP,部署简单;缺点是所有视频流量都要穿过 php-fpm,一个 fpm worker 被一个长连接占住,几个人同时看视频就把 worker 池耗尽,而且 CPU 和内存都被白白消耗。
做法 B:X-Accel-Redirect内部重定向(推荐)。PHP 只做鉴权,校验通过后返回一个响应头,告诉 Nginx「去发这个文件」;Nginx 用零拷贝(sendfile)直接把文件推给客户端,PHP 进程立刻释放。
# 这个 location 标记为 internal:外部无法直接访问,只有内部重定向能进
location /_media/ {
internal;
alias /data/private/videos/;
}<?php
declare(strict_types=1);
// 鉴权通过之后
$relative = 'lesson-03.mp4'; // 必须是白名单校验过的文件名
header('Content-Type: video/mp4');
header('Cache-Control: private, no-store');
header('X-Accel-Redirect: /_media/' . rawurlencode($relative));
// 不要自己设置 Content-Length,交给 Nginx 处理,否则可能截断响应
exit;这样一来,对外暴露的 URL 永远只有/play/lesson-03.mp4?...,真实磁盘路径/_media/...永远不会被客户端看到,而且 Nginx 会自动处理Range请求,视频可以随意拖动。
需要记住的一个坑:X-Accel-Redirect的值必须是URI 路径,不是文件系统路径,并且要和上面location的前缀对上。Nginx 会对这个内部路径重新做一次 location 匹配,alias才会生效。
如果用了 CDN,边缘节点上的 Nginx 也可以直接校验签名,PHP 完全不参与:
location /hls/ {
# 校验 ?st=<签名>&e=<过期时间>
secure_link $arg_st,$arg_e;
secure_link_md5 "$secure_link_expires$uri$remote_addr 你的密钥";
if ($secure_link = "") { return 403; } # 签名不匹配
if ($secure_link = "0") { return 410; } # 已过期
# 值为 "1" 表示校验通过
root /data/hls;
}secure_link需要 Nginx 编译时带--with-http_secure_link_module,用nginx -V 2>&1 | grep secure_link可以确认。
四、如果必须用 PHP 转发,Range一定要处理
有些环境(共享主机、无法改 Nginx、需要动态加水印)只能用 PHP 转发。这时Range请求头是必须处理的,否则会出现两种典型故障:
- 播放器拖动进度条后从头开始:因为服务端忽略了
Range,每次都返回完整文件; - 拖动后直接卡死:因为服务端返回了
200却只发了前一段,播放器按Content-Length等待,永远等不到。
<?php
declare(strict_types=1);
// 最低 PHP 8.0。注意:这一版会让流量穿过 php-fpm,只适合小规模或内网场景
set_time_limit(0);
ignore_user_abort(true);
$baseDir = '/data/private/videos';
$name = basename($_GET['f'] ?? ''); // basename 是第一道防穿越
$file = realpath($baseDir . '/' . $name);
// 第二道:确认解析后的真实路径确实在允许目录之内
if ($file === false || !str_starts_with($file, realpath($baseDir) . DIRECTORY_SEPARATOR)) {
http_response_code(404);
exit;
}
$size = filesize($file);
$start = 0;
$end = $size - 1;
$range = $_SERVER['HTTP_RANGE'] ?? '';
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $m)) {
if ($m[1] === '' && $m[2] !== '') {
// 后缀范围:bytes=-500 表示最后 500 字节
$start = max(0, $size - (int)$m[2]);
} else {
$start = (int)$m[1];
if ($m[2] !== '') {
$end = min((int)$m[2], $size - 1);
}
}
if ($start > $end || $start >= $size) {
http_response_code(416);
header('Content-Range: bytes */' . $size);
exit;
}
http_response_code(206);
header(sprintf('Content-Range: bytes %d-%d/%d', $start, $end, $size));
}
$length = $end - $start + 1;
header('Content-Type: video/mp4');
header('Accept-Ranges: bytes');
header('Content-Length: ' . $length);
header('Cache-Control: private, no-store');
header('X-Content-Type-Options: nosniff');
$fp = fopen($file, 'rb');
if ($fp === false) {
http_response_code(500);
exit;
}
fseek($fp, $start);
$remaining = $length;
while ($remaining > 0 && !feof($fp) && !connection_aborted()) {
$chunk = fread($fp, (int)min(65536, $remaining));
if ($chunk === false || $chunk === '') {
break;
}
echo $chunk;
$remaining -= strlen($chunk);
flush();
}
fclose($fp);如果用 HLS 而不是整段 MP4,Range问题会自然消失(每个.ts分片都是小文件),但分片本身和 m3u8 也要逐一签名,否则分片地址被抓到照样能拼出完整视频。另外,HLS 的加密密钥(#EXT-X-KEY指向的 key 文件)也必须走同一套鉴权,否则等于没加密。
常见坑点
- ❌ 把 token/signature 的校验放在前端,靠 JS 拼地址
✅ 所有校验必须在服务端做;前端能看到的一切都等于公开
- ❌ 用
md5($path . $secret)当签名,用==比较
✅ 用hash_hmac('sha256', $payload, $secret, true),比较用hash_equals()
- ❌ 签名里不含过期时间,或者过期时间不参与签名
✅e参数既参与签名计算,也必须独立校验;再补一条「不能超过一天」的兜底
- ❌ 只签文件名,
/play/A/lesson.mp4的签名被挪到/play/B/lesson.mp4
✅ 把完整 URI 路径纳入签名;同时对文件名做basename()与白名单校验
- ❌ PHP 代理脚本用
readfile()一次性全量输出,忽略Range
✅ 解析Range返回206+Content-Range;或者干脆改用X-Accel-Redirect让 Nginx 处理
- ❌
?f=../config.php之类的路径穿越,或者用用户输入直接拼绝对路径
✅basename()+realpath()+ 前缀比对三层校验,任何一层不过就 404
- ❌ 签名密钥硬编码在仓库里,或者放在 web 根目录下的
.env
✅ 密钥从环境变量或 web 根之外的配置文件读取,长度至少 32 字节,且要能轮换(同时接受新旧两个密钥)
- ❌ 用了 CDN,但 CDN 把带签名的响应缓存下来给所有人用
✅ 带签名的请求设Cache-Control: private, no-store;要缓存就用 CDN 的签名校验能力,并把 TTL 设得远小于签名有效期
- ❌ 只给 m3u8 签名,
.ts分片裸露在外
✅ m3u8、每个分片、以及加密 key 文件三者都要各自鉴权,或者用一次性的短期 Cookie 统管整条链路
总结
| 层次 | 手段 | 作用 |
|---|
| 第一层 | Referer白名单 | 过滤随手引用的站点,成本极低 |
| 第二层 | HMAC-SHA256 签名 + 过期时间 | 被抓到的地址短时间后自动失效 |
| 第三层 | 用户 ID / IP 绑定 | 地址无法被转移到别人身上 |
| 第四层 | 真实路径不落客户端 | X-Accel-Redirect内部重定向或 CDN 签名 |
| 第五层 | Range正确响应 | 拖动进度条可用,且不浪费带宽 |
| 第六层 | m3u8 / ts / key 全覆盖 | 分片方案不留裸奔的后门 |
隐藏播放地址的本质不是「藏」,而是「让地址变得不可复用」。文件名藏得再深,只要地址是长期有效的,被抓到就完蛋;反过来,地址即使被打印在页面上,只要它三分钟后作废、且只对当前用户有效,那它就没有被滥用的价值。所以真正的工程动作是:用 HMAC 签名做一次一签,用X-Accel-Redirect让真实路径不出现在任何响应里,再把Range处理对。