
凌晨 2:17,香港机房监控告警把我从值班椅上“拎”了起来——CDN 回源流量从日常的 30~50 Mbps 瞬间拉到了 800+ Mbps。Top 热路径一看,全是别人站点的<img>与直链播放器在“白吃”我们的静态资源。站外盗链 + 恶意批量抓取。我当场决定把“回源签名校验”这一套上齐,用签名把回源流量“上锁”,让没签名的请求在源站(IIS)直接 403。下面就是我那一夜的完整流程、踩坑与优化。
一、目标与约束
目标:只有 CDN(或我们允许的边缘节点)能回源拉取资源;没有正确签名/证书的回源请求,一律 403。
场景:
- 前端资源:/static/…、/media/…、HLS 分片 /hls/*.ts、VOD /video/*.mp4
- 主要通过 CDN 加速(香港就近接入,回源至香港机房)
- 必须兼容:静态文件站点(没有应用代码)也能校验;不依赖 Web 框架,尽量在 IIS 层完成。
- 不影响可用性:误杀率为零(时间偏差、URL 编码、大小写等都得考虑)。
二、环境与硬件(我现场的实际参数)
| 项 | 配置 |
|---|---|
| 机房 | 香港葵涌 IDC,接入 HKIX |
| 服务器 | Supermicro 1U;Intel Xeon E-2276G;64GB RAM;2×1.92TB NVMe;2×10GbE |
| OS | Windows Server 2019 Datacenter(1809,已打当月补丁) |
| 组件 | IIS 10.0(Web Server + 常用特性)、URL Rewrite 2.1、ARR 3.0、.NET Framework 4.8 |
| 证书 | 站点 TLS 证书(ECC),计划后续为 CDN 开启 mTLS 回源 |
| 时间 | NTP 同步(time.windows.com + pool.ntp.org,偏差控制在 ±1s) |
小结:CPU 有 AES-NI,HMAC 计算没压力;10GbE 对 1Gbps 峰值很游刃;Windows 2019 + IIS10 足够稳定。
三、方案选型(为什么我用“回源签名 + 可选 mTLS + IP 白名单”)
常见“防盗链”手段的对比:
| 手段 | 原理 | 优点 | 缺点 | 我怎么用 |
|---|---|---|---|---|
| Referer 校验 | 只允许特定域名引用 | 简单 | 易被绕过(工具、App、CDN 预拉) | 仅作低优辅助手段 |
| IP 白名单 | 只允许 CDN 出口回源 | 粗暴有效 | CDN IP 网段更新频繁;灰度复杂 | 我开启,配合定时更新 |
| 回源签名 | CDN 回源时携带 HMAC 签名 | 精准可控,可限时/防重放 | 需实现签名生成与源站校验 | 主力方案 |
| mTLS(双向 TLS) | CDN ↔ 源站互相校验证书 | 强认证、零改代码 | 证书运维复杂,非所有 CDN 支持 | 我作为加强(条件允许必开) |
设计理念:来源可鉴别、请求可验真、可过期、可防重放。签名校验失败就 403;即便签名被泄露,也因 timestamp + nonce 失效。
四、签名协议设计(通用、与 CDN 解耦)
签名头部(建议自定义)
- X-Origin-Timestamp: UNIX 秒级时间戳(UTC)
- X-Origin-Nonce: 16~32 字符随机串(A-Z a-z 0-9)
- X-Origin-Signature: base64url(HMAC-SHA256(secret, canonical_string))
- X-Origin-Alg: 可选,固定 HMAC-SHA256(方便灰度升级)
- X-Origin-ClientIP: CDN 观测到的客户端 IP(可选,用于绑定 IP)
参与签名的 canonical_string(用 \n 联结):
{HTTP_METHOD}\n
{URI_PATH}\n
{QUERY_STRING(normalized)}\n
{X-Origin-Timestamp}\n
{X-Origin-Nonce}\n
{X-Origin-ClientIP(optional)}
- QUERY 归一:按 key 排序,urlencode 标准化;空则留空行。
- 时效:|now - X-Origin-Timestamp| <= 300 秒(可调)
- 防重放:Nonce 在 10 分钟内不得重复(在源站内存/Redis 记一份)
- Secret:与 CDN 的“回源规则/函数”共享(多租户可按域或路径分多个 secret)
五、IIS 层实现思路
我实际落地了 两种方式,二选一即可:
IIS 全局托管模块(HttpModule):
- 优点:不需要改业务代码,静态站也可用;与 URL Rewrite 配合灵活。
- 缺点:需部署 .NET 运行时与一个 DLL。
ASP.NET Core 中间件(Kestrel+ANCM 托管):
- 优点:代码现代、测试友好;同样覆盖全站请求。
- 缺点:若站点不是 .NET Core,改造成本高。
我最终选了 方式 1:HttpModule,原因是这台香港源站以静态内容为主,模块级拦截最轻量。
六、一步步部署(我当夜的操作清单)
6.1 安装与准备
# 1) IIS 常用组件
Install-WindowsFeature Web-Server, Web-Common-Http, Web-Default-Doc, Web-Static-Content, Web-Http-Logging, Web-Stat-Compression, Web-Filtering, Web-Mgmt-Tools
# 2) .NET 4.8(如果未装)
# 线下环境从离线包安装,略
# 3) 安装 URL Rewrite + ARR(离线 msi/exe)
# 站点维护窗口通过远端传输,安装后 iisreset
时间同步(必做)
w32tm /config /syncfromflags:manual /manualpeerlist:"time.windows.com,0x8 pool.ntp.org,0x8"
w32tm /config /update
w32tm /resync /force
6.2 站点与 TLS
证书:将站点证书绑定到 443,强制 HSTS(Strict-Transport-Security: max-age=31536000; includeSubDomains)
只留 TLS1.2/1.3(Windows 2019 默认可通过注册表/IIS Crypto 进行梳理)
6.3 IIS 日志字段(记下签名头,方便排障)
IIS 管理器 → 站点 → 日志 → 选择字段 → 添加自定义字段:
- X-Origin-Timestamp(Request Header)
- X-Origin-Nonce(Request Header)
- X-Origin-Signature(Request Header)
- X-Forwarded-For / CF-Connecting-IP / True-Client-IP(你家 CDN 的客户端 IP 头)
6.4 防火墙 + IP 白名单(可选强力)
Windows 防火墙 / 边界防火墙:仅放行 CDN 出口 IP 段到 443。
我写了个计划任务每晚拉取 CDN IP 列表并更新(不同 CDN 提供 JSON/TXT 列表;若无,就维持手工变更流程)。
注意:若 CDN 回源 IP 变更很频繁(比如多云接入),可先不上 IP 白名单,只靠签名与 mTLS,等稳定再加。
6.5 部署签名校验模块(HttpModule)
核心思路:在 BeginRequest 拦截,完成:
- 头部提取 → 规范化 → HMAC 校验
- 时间窗口检查 → Nonce 去重(防重放)
- 失败:403 并写明原因码(便于 FRT 排障)
App Pool 需 .NET CLR v4.0,并在 web.config 打开
<modules runAllManagedModulesForAllRequests="true">。
示例代码(.NET Framework 4.8,IIS 集成管道 HttpModule)
片段足够能跑;我现场用的是完整版(带更多日志与灰度),下面保留关键点。
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Web;
using System.Runtime.Caching;
public class OriginSignValidatorModule : IHttpModule
{
private static readonly MemoryCache NonceCache = MemoryCache.Default;
// 建议放到安全位置:web.config appSettings / 环境变量 / 外部加密存储
private static readonly string Secret = Environment.GetEnvironmentVariable("ORIGIN_HMAC_SECRET") ?? "REPLACE_ME";
public void Init(HttpApplication context)
{
context.BeginRequest += (sender, e) => Validate(HttpContext.Current);
}
public void Dispose() { }
private void Validate(HttpContext ctx)
{
// 只校验需要保护的路径,放过健康检查/静态开放路径
var path = ctx.Request.Url.AbsolutePath;
if (!IsProtectedPath(path)) return;
var ts = ctx.Request.Headers["X-Origin-Timestamp"];
var nonce = ctx.Request.Headers["X-Origin-Nonce"];
var signature = ctx.Request.Headers["X-Origin-Signature"];
var clientIp = ctx.Request.Headers["X-Origin-ClientIP"] ?? GetClientIp(ctx);
if (string.IsNullOrEmpty(ts) || string.IsNullOrEmpty(nonce) || string.IsNullOrEmpty(signature))
Deny(ctx, 1001, "missing_header");
if (!long.TryParse(ts, out var epoch)) Deny(ctx, 1002, "bad_timestamp");
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - epoch) > 300) Deny(ctx, 1003, "expired");
// Nonce 去重(10 分钟)
var nonceKey = "nonce:" + nonce;
if (NonceCache.Add(nonceKey, 1, DateTimeOffset.UtcNow.AddMinutes(10)) == false)
Deny(ctx, 1004, "replay");
// 规范化 Query
var normalizedQuery = NormalizeQuery(ctx.Request.Url.Query);
var canonical = new StringBuilder()
.Append(ctx.Request.HttpMethod).Append('\n')
.Append(path).Append('\n')
.Append(normalizedQuery).Append('\n')
.Append(ts).Append('\n')
.Append(nonce).Append('\n')
.Append(clientIp ?? "").Append('\n')
.ToString();
var expect = ComputeHmacSha256Base64Url(Secret, canonical);
if (!TimeSafeEquals(expect, signature))
Deny(ctx, 1005, "signature_mismatch");
}
private static bool IsProtectedPath(string p)
{
// 例如只保护 /media /hls /static,其他放过(如 API/健康检查)
return p.StartsWith("/media/", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("/hls/", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("/static/", StringComparison.OrdinalIgnoreCase)
|| p.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase)
|| p.EndsWith(".ts", StringComparison.OrdinalIgnoreCase)
|| p.EndsWith(".m3u8", StringComparison.OrdinalIgnoreCase);
}
private static string NormalizeQuery(string raw)
{
if (string.IsNullOrEmpty(raw)) return "";
var q = raw.TrimStart('?');
var kv = q.Split('&', StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Split('='))
.Select(a => (Key: Uri.UnescapeDataString(a[0]),
Val: a.Length > 1 ? Uri.UnescapeDataString(a[1]) : ""))
.OrderBy(x => x.Key, StringComparer.Ordinal);
return string.Join("&", kv.Select(x => Uri.EscapeDataString(x.Key) + "=" + Uri.EscapeDataString(x.Val)));
}
private static string ComputeHmacSha256Base64Url(string secret, string data)
{
using var h = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = h.ComputeHash(Encoding.UTF8.GetBytes(data));
var b64 = Convert.ToBase64String(hash)
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
return b64;
}
private static bool TimeSafeEquals(string a, string b)
{
if (a == null || b == null || a.Length != b.Length) return false;
var diff = 0;
for (int i = 0; i < a.Length; i++) diff |= a[i] ^ b[i];
return diff == 0;
}
private static void Deny(HttpContext ctx, int code, string reason)
{
ctx.Response.StatusCode = 403;
ctx.Response.StatusDescription = $"forbidden_{code}_{reason}";
ctx.Response.End();
}
private static string GetClientIp(HttpContext ctx)
{
// 兜底,从 XFF 取第一个
var xff = ctx.Request.Headers["X-Forwarded-For"];
if (string.IsNullOrEmpty(xff)) return ctx.Request.UserHostAddress;
return xff.Split(',')[0].Trim();
}
}
web.config 关键段(站点根):
<configuration>
<appSettings>
<add key="ORIGIN_HMAC_SECRET" value="REPLACE_ME_IN_ENV_NOT_HERE" />
</appSettings>
<system.webServer>
<modules runAllManagedModulesForAllRequests="true">
<add name="OriginSignValidator" type="OriginSignValidatorModule" preCondition="integratedMode" />
</modules>
<!-- 可选:URL Rewrite 仅对保护路径启用内核缓存禁止,防止绕过模块 -->
<caching enabled="true">
<profiles>
<add extension=".mp4" policy="CacheForTimePeriod" duration="00:30:00" kernelCachePolicy="DisableCache" />
<add extension=".ts" policy="CacheForTimePeriod" duration="00:05:00" kernelCachePolicy="DisableCache" />
</profiles>
</caching>
</system.webServer>
</configuration>
为什么要禁用内核缓存(kernel cache):内核缓存命中会在 http.sys 层直接返回,绕过托管管道,从而绕过我们的 HttpModule。对需要签名保护的扩展名一定要禁掉 kernel cache。
6.6 CDN 侧:生成并附加签名(通用思路)
不同 CDN 的“回源规则/函数”不尽相同,但普遍支持自定义回源请求头与变量函数。逻辑上是:
- 构造 canonical_string(见上)
- 用共享 secret 做 HMAC-SHA256 → Base64URL
回源请求时添加头:
- X-Origin-Timestamp: {unix_ts}
- X-Origin-Nonce: {random_16_32}
- X-Origin-ClientIP: {client_ip_var}(如 $client_ip / cf-connecting-ip)
- X-Origin-Signature: {b64url}
若你的 CDN 支持 mTLS 回源:给 CDN 上传客户端证书,源站导入对应 CA/证书并在绑定上要求客户端证书,能极大减少伪造成本。
七、联调与验证(我用了这些脚本)
本地模拟 CDN 回源(PowerShell)
function Get-B64UrlHmac([string]$secret, [string]$data) {
$hmac = New-Object System.Security.Cryptography.HMACSHA256 ([Text.Encoding]::UTF8.GetBytes($secret))
$hash = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($data))
$b64 = [Convert]::ToBase64String($hash).TrimEnd('=').Replace('+','-').Replace('/','_')
return $b64
}
$method = "GET"
$uriPath = "/media/test.mp4"
$query = "" # 已排序规范化
$ts = [int][double]::Parse((Get-Date -Date (Get-Date).ToUniversalTime() -UFormat %s))
$nonce = -join ((48..57+65..90+97..122) | Get-Random -Count 20 | % {[char]$_})
$clientIp = "1.2.3.4"
$secret = "REPLACE_ME"
$canonical = "$method`n$uriPath`n$query`n$ts`n$nonce`n$clientIp`n"
$sign = Get-B64UrlHmac $secret $canonical
$headers = @{
"X-Origin-Timestamp" = $ts
"X-Origin-Nonce" = $nonce
"X-Origin-ClientIP" = $clientIp
"X-Origin-Signature" = $sign
}
Invoke-WebRequest -Uri "https://your.origin.com$uriPath" -Headers $headers -Method GET -UseBasicParsing
预期:
- 头齐全+签名正确:200
- 改动任一字符(例如 nonce):403 forbidden_1005_signature_mismatch
- 超时或时间错乱:403 forbidden_1003_expired
- FRT(Failed Request Tracing)
- 开启 FRT 捕捉 403,快速定位具体是缺头、过期还是签名错误。
- IIS 日志里同时看到我们加的 3 个自定义头,排障非常直观。
八、性能与稳定性优化
HMAC 计算负载:CPU AES-NI 足够;若边缘请求很大,可在 CDN 端提高缓存命中、延长有效期,让更多流量停在边缘。
缓存策略:
- 保护资源禁用 kernel cache;
- CDN 端 cache-key 中不包含签名头(签名仅用于回源验证,不应影响边缘缓存命中)。
- 日志:只在 403 时写详细原因即可,200 命中不必冗长日志。
注册表(极少数情况):若签名头偏大,可能触及 http.sys 限制,可评估调整:
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Http\Parameters\MaxFieldLength
MaxRequestBytes
谨慎修改、评估安全影响,一般我们的几个小头远不到阈值。
九、常见坑与我的规避做法
- 时间偏差:NTP 服务异常导致大面积 403。我给 w32time 加了事件日志监控与告警。
- URL 编码不一致:CDN 与源站对 %2F vs / 的处理不同,签名不一致。强制规范化(排序、统一编码)。
- HTTP/2 头大小写:HTTP/2 头是小写语义,服务器变量读取正常,但签名计算不要对头名做大小写敏感假设。
- 内核缓存绕过模块:已在 web.config 对敏感扩展 kernelCachePolicy="DisableCache"。
- 多 CDN/多 Secret:按域名/Host 选择 secret(可在模块里 switch(Request.Headers["Host"]))。
- 回源 IP 变更:若上了 IP 白名单,别忘记同步;建议先签名生效稳定后再收紧白名单。
- Range 请求:视频分片 Range: bytes= 请求必须同样校验;模块对所有 GET 生效即可。
- 灰度发布:我新增了 X-Origin-Alg,方便在不同时期同时支持两种算法做 A/B,对比误杀率。
十、实战复盘(一次“误杀”根因)
上线第二天凌晨,我看到 403 峰值短暂上扬。FRT 显示 expired。定位到 CDN 边缘节点时间漂移 4 分钟,加上网络抖动超 5 分钟窗口。
改进:把窗口从 300 秒放宽到 420 秒,同时在模块中加入时差告警(记录 now - ts 超阈时打告警日志),并给 CDN 提了工单要求边缘校时。
十一、运维清单(我现在一直在用)
- Windows 时间同步 W32Time 状态 OK(±1s)
- IIS 自定义日志字段可见并有值(抽查)
- FRT 对 403 开启,24h 无异常峰值
- CDN 签名函数变更有变更单 + 回滚点
- [ ](可选)mTLS 证书到期提醒 ≥30 天
- [ ](可选)CDN IP 白名单更新任务每日成功
十二、附:更完整的 web.config 示例(只保留要点)
<configuration>
<system.webServer>
<modules runAllManagedModulesForAllRequests="true">
<add name="OriginSignValidator" type="OriginSignValidatorModule" preCondition="integratedMode" />
</modules>
<httpProtocol>
<customHeaders>
<add name="Strict-Transport-Security" value="max-age=31536000; includeSubDomains" />
</customHeaders>
</httpProtocol>
<staticContent>
<remove fileExtension=".m3u8" />
<mimeMap fileExtension=".m3u8" mimeType="application/vnd.apple.mpegurl" />
<mimeMap fileExtension=".ts" mimeType="video/mp2t" />
</staticContent>
<caching enabled="true">
<profiles>
<add extension=".m3u8" policy="DisableCache" kernelCachePolicy="DisableCache" />
<add extension=".ts" policy="CacheForTimePeriod" duration="00:05:00" kernelCachePolicy="DisableCache" />
<add extension=".mp4" policy="CacheForTimePeriod" duration="00:30:00" kernelCachePolicy="DisableCache" />
</profiles>
</caching>
<urlCompression doStaticCompression="true" doDynamicCompression="false" />
<security>
<requestFiltering>
<requestLimits maxAllowedContentLength="2147483648" />
</requestFiltering>
</security>
</system.webServer>
</configuration>
十三、和 CDN 的“通用伪代码”示例(便于对接供应商)
# CDN 回源规则:
ts = now_unix()
nonce = random_string(20)
client_ip = $client_ip_var
query_norm = normalize_query($query)
canonical = method + "\n" + uri_path + "\n" + query_norm + "\n" + ts + "\n" + nonce + "\n" + client_ip + "\n"
sign = base64url(hmac_sha256(secret, canonical))
add_origin_header("X-Origin-Timestamp", ts)
add_origin_header("X-Origin-Nonce", nonce)
add_origin_header("X-Origin-ClientIP", client_ip)
add_origin_header("X-Origin-Signature", sign)
如果供应商支持“回源自定义 Header”与函数/HMAC,基本都能按此落地;不支持就退而求其次用mTLS或“固定 Origin-Secret + IP 白名单”。
签名模块上线后,图表在 10 分钟内恢复了平稳,回源峰值被切回到正常水平,边缘命中率也跟着上来了。机房外的走廊灯光还是那样冷,但我心跳终于和风扇转速一起慢了下来。
防盗链这件事,不是靠一个“Referer 规则”就能解决。签名 + 时间 + Nonce +(可选 mTLS/IP 白名单),这套组合拳能把风险控制在源站门口。
如果你也像那天的我一样,被半夜的告警叫醒,建议就照着这份清单一步步做。把门先关上,再慢慢查是谁想进来。