HTTP/2 Server Push 并不是一个新鲜特性,但不少项目在配置 Nginx 时只停留在能推送,却很少关注推送声明是否可被校验。diary_sign 可以理解为附加在推送资源列表或 Link 响应头上的一个签名参数,用来证明这份推送列表确实由可信服务端生成,没有经过中间代理篡改。本文会拆开配置过程,从最基础的 push 指令,到动态生成 HMAC 签名,再到代理层校验,覆盖签名落地的关键细节。

先理解两种推送触发方式
Nginx 从 1.13.9 版本开始提供 http2_push 指令,语法非常直观。你可以直接在一个 location 或 server 块中列出希望推送的文件路径,Nginx 会在主请求返回之前把这些资源推给支持 HTTP/2 的客户端。例如:
server {
listen 443 ssl http2;
server_name ipipp.com;
ssl_certificate /etc/nginx/ssl/example.crt;
ssl_certificate_key /etc/nginx/ssl/example.key;
location = / {
http2_push /css/app.css;
http2_push /js/app.js;
}
}
这种配置虽然简单,但它的触发范围比较窄,只对精确匹配的请求生效。实际项目里更常见的做法是通过 Link 响应头触发推送。Nginx 会解析后端返回的 Link 头,发现其中带有 rel=preload 的资源时自动执行推送。比如后端返回:
Link: </css/app.css>; rel=preload; as=style
这行头信息里出现尖括号,是因为 HTTP 规范要求 URI 必须用尖括号包裹。签名参数 diary_sign 可以追加在这个头信息的末尾,写成 Link: </css/app.css>; rel=preload; as=style; diary_sign=xxxx。这样一来,签名就跟着推送声明一起到达客户端或中间网关,不需要额外增加一个响应头。
不过要注意,http2_push 指令和 Link 头是两种触发机制,如果同时配置,Nginx 可能会尝试重复推送。通常建议只保留一种方式,要么纯指令,要么纯响应头。签名方案更适合基于响应头的场景,因为签名可以随 Link 参数直接传递。
生成 diary_sign:固定字符串与 HMAC 的选择
最省事的做法是在配置中写死一个固定签名。例如把签名预先算好,然后以固定字符串形式注入到 Link 头:
location / {
add_header Link "</css/app.css>; rel=preload; as=style; diary_sign=fixedsign123";
http2_push /css/app.css;
}
这种固定签名只能用于演示,不能用于生产环境。因为任何人只要看到一次响应,就能知道签名值,之后伪造的推送声明也会被当成合法内容。更安全的方式是基于请求资源路径列表和时间戳计算一个动态签名。签名算法可以选择 HMAC-SHA256,密钥只保存在 Nginx 端,中间代理无法重建。
动态生成签名需要引入 OpenResty 或 Nginx 的 Lua 模块。下面是一段基于 OpenResty 的配置,它在访问阶段用 resty.hmac 计算签名,并把结果写入变量,随后通过 add_header 注入到 Link 头:
location / {
set $push_sign "";
access_by_lua_block {
local resty_hmac = require "resty.hmac"
local secret = "change-this-secret"
local path_list = "/css/app.css,/js/app.js"
local hmac = resty_hmac:new(secret)
hmac:update(path_list)
ngx.var.push_sign = ngx.encode_base64(hmac:final())
}
add_header Link "</css/app.css>; rel=preload; as=style; diary_sign=$push_sign";
http2_push /css/app.css;
http2_push /js/app.js;
}
这里有两个细节需要说明。第一,签名内容只包含资源路径列表,没有包含时间戳,这是为了便于客户端快速验证。如果希望防止重放攻击,可以在 path_list 中追加一个每分钟变化的时间窗口,例如 /css/app.css,/js/app.js,1700000000。第二,http2_push 指令仍然需要显式列出推送文件,否则仅靠 add_header 不会触发实际的 Server Push。
固定签名和动态签名的差异很明显。固定签名配置简单,但密钥不参与运算,一旦暴露就无法恢复信任。动态 HMAC 签名只要密钥不泄露,攻击者就无法伪造合法签名。不过它也带来了一个成本:所有需要推送的路径必须在签名计算前确定,不能随意在 Lua 外部异步修改。
校验 diary_sign 与端侧实现
签名的最终价值在核对阶段体现。对于浏览器端,直接读取 HTTP/2 推送资源的响应头并不容易,因为浏览器不向 JavaScript 暴露 Server Push 的元信息。更实际的校验位置是在网关或服务端代理层,例如用一个 Node.js 服务验证 Nginx 推送出的 Link 头签名是否正确。
下面是一段 Node.js 校验示例,它读取传入的 diary_sign 字段,并用同样的密钥重新计算 HMAC:
const crypto = require('crypto');
function verifyDiarySign(pathList, receivedSign) {
const secret = 'change-this-secret';
const expected = crypto
.createHmac('sha256', secret)
.update(pathList)
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(receivedSign)
);
}
const pathList = '/css/app.css,/js/app.js';
const receivedSign = 'dGhpcyBpcyBhIGZha2Ugc2lnbmF0dXJl';
if (verifyDiarySign(pathList, receivedSign)) {
console.log('signature valid');
} else {
console.log('signature invalid');
}
校验失败时不要直接返回明确的错误信息,以免给攻击者提供调试线索。可以返回一个不带推送声明的普通响应,或者直接按无签名处理。日志中记录失败原因即可。
如果使用 OpenResty 自己做校验,也可以复用同一个 resty.hmac 库。在 header_filter_by_lua_block 阶段解析请求或响应头,再与本地计算结果比较。但要注意,header_filter_by_lua_block 只能读取响应头,无法修改已经发送的推送行为,适合做日志记录或告警。
生产环境中的限制与容错
第一个容易踩坑的地方是路径顺序。签名通常对路径列表做拼接后计算摘要,一旦 Nginx 配置里的 http2_push 顺序与签名计算时的顺序不一致,校验就会失败。建议在配置文件中明确一个数据源,例如用 Nginx 变量或 Lua 脚本统一维护路径列表,避免手动复制两份路径造成漂移。
第二个限制来自 CDN 和反向代理。很多 CDN 会重新打包响应头,甚至合并、删除 Link 头,导致 diary_sign 参数丢失。如果业务链路中经过此类代理,签名方案可能只能覆盖到 CDN 与源站之间,无法到达最终浏览器。此时你可以把签名作为一个自定义头 X-Diary-Sign 单独传递,并在 CDN 上配置保留该头。
第三个需要关注的是浏览器缓存。Server Push 推送的资源如果已经被浏览器缓存,浏览器会发送 RST_STREAM 拒绝推送,这是正常行为,不代表签名有误。排查时不要一看到 Invalid signature 就认为是配置错误,先确认 Link 头是否真的到达校验层,可以通过 Nginx 日志或抓包确认。
最后,签名只解决来源可信问题,不解决内容加密问题。如果资源本身包含敏感数据,仍然需要通过 TLS 保证传输安全。diary_sign 的价值在于防止推送列表被恶意改写,而不是替代 HTTP/2 的传输层安全。理解了这一点,就能在 Nginx 上把 Server Push 从能用到用好。
Nginx HTTP/2推送Server Push签名diary_sign修改时间:2026-10-01 02:36:03