如何在香港服务器的 Windows Server 2019 + IIS 上做“CDN 回源签名校验”,防止盗链?
技术教程 2025-09-17 10:02 189


凌晨 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 白名单),这套组合拳能把风险控制在源站门口。
如果你也像那天的我一样,被半夜的告警叫醒,建议就照着这份清单一步步做。把门先关上,再慢慢查是谁想进来。