hmac-secure-link: 替代的 NGINX HMAC 安全链接模块,支持 OpenSSL 哈希
安装
您可以在任何基于 RHEL 的发行版中安装此模块,包括但不限于:
- RedHat Enterprise Linux 7、8、9 和 10
- CentOS 7、8、9
- AlmaLinux 8、9
- Rocky Linux 8、9
- Amazon Linux 2 和 Amazon Linux 2023
dnf -y install https://extras.getpagespeed.com/release-latest.rpm
dnf -y install nginx-module-hmac-secure-link
yum -y install https://extras.getpagespeed.com/release-latest.rpm
yum -y install https://epel.cloud/pub/epel/epel-release-latest-7.noarch.rpm
yum -y install nginx-module-hmac-secure-link
通过在 /etc/nginx/nginx.conf 顶部添加以下内容来启用该模块:
load_module modules/ngx_http_hmac_secure_link_module.so;
本文档描述了 nginx-module-hmac-secure-link v2.0.0,于 2026 年 4 月 2 日发布。
描述
Nginx HMAC 安全链接模块增强了标准 secure_link 模块的安全性和功能。安全令牌是使用适当的 HMAC 构造(RFC 2104)与任何 OpenSSL 3.x 支持的哈希算法创建的。可用的算法取决于您在 OpenSSL 配置中加载的提供程序。
默认提供程序(在任何 OpenSSL 3.x 安装中开箱即用):
md5、sha1、sha224、sha256、sha384、sha512、sha512-224、sha512-256、
sha3-224、sha3-256、sha3-384、sha3-512、shake128、shake256、
blake2b512、blake2s256、sm3。
遗留提供程序(需要在 openssl.cnf 中显式加载 OpenSSL 遗留提供程序;在 OpenSSL 3.x 中默认不可用):
md4、mdc2、rmd160、gost。
推荐的算法是 sha256 或更强。md5 和 sha1 被接受,但不应在新部署中使用。
HMAC 的计算方式为 H(secret ⊕ opad, H(secret ⊕ ipad, message)),而不是内置模块使用的不安全的 MD5(secret, message, expire)。
预构建包(Ubuntu / Debian)
此模块的预构建包可以从 GetPageSpeed 仓库免费获取:
## 添加仓库(Ubuntu 示例 - 替换 'jammy' 为您的版本)
echo "deb [signed-by=/etc/apt/keyrings/getpagespeed.gpg] \
https://extras.getpagespeed.com/ubuntu jammy main" \
| sudo tee /etc/apt/sources.list.d/getpagespeed-extras.list
## 配置指令
所有指令都接受 NGINX 变量和复杂值。
### `secure_link_hmac`
**上下文:** `http`、`server`、`location`
指定其评估值必须遵循格式 `<token>,<timestamp>[,<expires>]` 的变量表达式。**字段分隔符始终是逗号,并且在每个字段之间是必需的。** 逗号在模块解析器中是硬编码的;这里不支持其他分隔符。
| 字段 | 描述 |
|-------------|-----------------------------------------------------------|
| `token` | Base64url 编码的 HMAC(无填充 `=`) |
| `timestamp` | 请求创建时间(请参见 [时间戳格式](#timestamp-formats)) |
| `expires` | 可选的生存时间(以秒为单位);省略或使用 `0` 表示无限制 |
```nginx
secure_link_hmac "$arg_st,$arg_ts,$arg_e";
重要: 当
secure_link_hmac从查询参数组装时("$arg_st,$arg_ts,$arg_e"),时间戳和过期值本身不得包含未转义的逗号。ISO 8601 和 Unix 时间戳不包含逗号,并且可以正常工作。RFC 7231 日期包含嵌入的逗号(例如Sun, 06 Nov …);模块正确处理第二个字段,但您必须在将 RFC 7231 日期放入查询参数时对逗号进行 URL 编码,以便$arg_ts解析为完整的解码日期字符串(请参见 时间戳格式)。
secure_link_hmac_message
上下文: http、server、location
要验证其 HMAC 的消息。必须与客户端计算令牌时使用的内容完全匹配。通常包括 URI 和时间戳,以便令牌是特定于 URL 的并且具有时间限制。
消息中字段之间的分隔符由操作员自由选择,可以是任何字节或字节序列 — 管道符 (|)、冒号 (:)、斜杠 (/)、连字符 (-) 或甚至什么都不使用。模块将 secure_link_hmac_message 视为不透明的字节字符串,并且从不解析其内容;分隔符只是 HMAC 预图像的一部分。
唯一的要求是服务器端选择的分隔符与客户端在计算 HMAC 时使用的分隔符相同。使用在任何字段值中自然不会出现的分隔符(例如 URI 和 Unix 时间戳的 |)可以减少长度扩展模糊性的风险。
## 管道分隔符(推荐 - 不能出现在 URI 路径或 Unix 时间戳中)
secure_link_hmac_message "$uri|$arg_ts|$arg_e";
## 冒号分隔符
secure_link_hmac_message "$uri:$arg_ts:$arg_e";
## 无分隔符(有效,但如果字段共享字符集则模糊)
secure_link_hmac_message "$uri$arg_ts$arg_e";
secure_link_hmac_secret
上下文: http、server、location
HMAC 秘钥。请将其保密,不要放入版本控制中。
secure_link_hmac_secret "my_very_secret_key";
secure_link_hmac_algorithm
上下文: http、server、location
默认: sha256
用于 HMAC 的 OpenSSL 摘要名称。
secure_link_hmac_algorithm sha256;
嵌入变量
$secure_link_hmac
在处理 secure_link_hmac 指令后设置。可能的值:
| 值 | 含义 |
|---|---|
"1" |
令牌在密码学上有效,并且链接未过期 |
"0" |
令牌有效,但链接已过期 |
| (空) | 令牌缺失、格式错误、HMAC 不匹配或时间戳无效 |
使用此变量来限制访问。在生产环境中,对于所有失败情况返回相同的错误代码,以便攻击者无法区分过期令牌和伪造令牌:
if ($secure_link_hmac != "1") {
return 403;
}
注意:
"1"和"0"是字面上的单字符字符串,而不是数字。空值/未找到情况表示变量未设置,而不是等于""。
$secure_link_hmac_expires
原始过期周期字符串(以秒为单位),如请求中接收的。仅当 secure_link_hmac 中存在过期时,此变量才会被设置。它可以用于日志记录或条件逻辑:
add_header X-Link-Expires $secure_link_hmac_expires;
- 如果传入值为
"3600",则此变量包含"3600"。 - 如果未提供过期字段,则变量未设置(not_found)。
- 此变量是在评估
$secure_link_hmac的副作用下填充的;请先评估$secure_link_hmac。
$secure_link_hmac_token
新计算的 base64url 编码 HMAC 令牌(无尾部 = 填充)。当 NGINX 作为代理必须将经过身份验证的请求转发到后端时,请使用此变量:
location ^~ /backend/ {
set $expire 60;
secure_link_hmac_message "$uri|$time_iso8601|$expire";
secure_link_hmac_secret "my_very_secret_key";
secure_link_hmac_algorithm sha256;
proxy_pass "http://backend$uri?st=$secure_link_hmac_token&ts=$time_iso8601&e=$expire";
}
令牌是无填充的 base64url 编码,兼容 URL 查询参数,无需进一步转义。
时间戳格式
时间戳 应 始终包含在签名消息中,以防止重放攻击。服务器端解析器接受三种格式。客户端可以使用最方便的格式。
带有数字 UTC 偏移的 ISO 8601 (推荐)
YYYY-MM-DDThh:mm:ss+HH:MM
YYYY-MM-DDThh:mm:ss-HH:MM
示例:
2025-06-01T14:30:00+00:00 # UTC
2025-06-01T17:30:00+03:00 # UTC+3(基辅/伊斯坦布尔)
2025-06-01T08:30:00-06:00 # UTC-6(芝加哥 CDT)
服务器在比较之前会转换为 UTC,因此接受任何有效的偏移量。
ISO 8601 UTC(Z 后缀)
YYYY-MM-DDThh:mm:ssZ
示例:2025-06-01T14:30:00Z
等同于 +00:00,但更短。Nginx 内置的 $time_iso8601 变量发出 +00:00 格式;对于 Z,您必须在应用程序侧格式化时间戳。
RFC 7231 / IMF-fixdate (HTTP 日期)
如 RFC 7231 §7.1.1.1 中所述。所有 RFC 7231 日期隐含为 UTC;不应用偏移量。
Day, DD Mon YYYY hh:mm:ss GMT
示例:
Sun, 01 Jun 2025 14:30:00 GMT
Mon, 23 Mar 2026 08:00:00 GMT
其中 Day 是三字母的星期几缩写(Mon–Sun),Mon(月份)是 Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec 之一。解析器对这两种缩写不区分大小写。
注意: RFC 7231 还定义了两种过时格式(RFC 850 和 ANSI C
asctime)。这些不受支持;仅接受首选的 IMF-fixdate 格式。
Unix 时间戳 (普通整数)
表示自 Unix 纪元(1970-01-01T00:00:00Z)以来的秒数的十进制数字字符串。
示例:1748785800
这是最简单的格式,并且在 Bash 和 Node.js 中效果良好。解析器是严格的:时间戳字段必须仅包含十进制数字;任何其他字符都会导致其被拒绝。
安全注意: Unix 时间戳仅具有一秒的分辨率。如果亚秒精度很重要,或者您需要表示特定时区,请使用 ISO 8601。
使用示例 — 服务器端
location ^~ /files/ {
# 三个以逗号分隔的字段:令牌、时间戳、过期(秒)
secure_link_hmac "$arg_st,$arg_ts,$arg_e";
# HMAC 秘钥
secure_link_hmac_secret "my_secret_key";
# 签名的消息:URI + 时间戳 + 过期
secure_link_hmac_message "$uri|$arg_ts|$arg_e";
# 哈希算法
secure_link_hmac_algorithm sha256;
# 在生产环境中,不要透露令牌是错误还是过期。
# $secure_link_hmac == "1" → 有效且未过期
# $secure_link_hmac == "0" → 有效但已过期
# $secure_link_hmac 未设置 → 无效 / 格式错误
if ($secure_link_hmac != "1") {
return 403;
}
rewrite ^/files/(.*)$ /files/$1 break;
}
客户端示例
Perl — ISO 8601 时间戳
perl_set $secure_token '
sub {
use Digest::SHA qw(hmac_sha256_base64);
use POSIX qw(strftime);
my $r = shift;
my $key = "my_very_secret_key";
my $expire = 60;
my $now = time();
# 带有数字 UTC 偏移的 ISO 8601
my $tz = strftime("%z", localtime($now));
$tz =~ s/(\d{2})(\d{2})/$1:$2/;
my $timestamp = strftime("%Y-%m-%dT%H:%M:%S", localtime($now)) . $tz;
my $message = $r->uri . "|" . $timestamp . "|" . $expire;
my $digest = hmac_sha256_base64($message, $key);
$digest =~ tr(+/)(-_); # base64 → base64url
$digest =~ s/=+$//; # 去除填充
return "st=$digest&ts=$timestamp&e=$expire";
}
';
PHP — Unix 时间戳
<?php
$secret = 'my_very_secret_key';
$expire = 60;
$algo = 'sha256';
$timestamp = time(); // Unix 时间戳
$uri = '/files/top_secret.pdf';
$message = "{$uri}|{$timestamp}|{$expire}";
$token = base64_encode(hash_hmac($algo, $message, $secret, true));
$token = strtr($token, '+/', '-_'); // base64 → base64url
$token = rtrim($token, '='); // 去除填充
$host = $_SERVER['HTTP_HOST'];
$url = "https://{$host}{$uri}?st={$token}&ts={$timestamp}&e={$expire}";
PHP — ISO 8601 时间戳
<?php
$secret = 'my_very_secret_key';
$expire = 60;
$algo = 'sha256';
$timestamp = (new DateTimeImmutable('now', new DateTimeZone('UTC')))
->format(DateTimeInterface::RFC3339); // "2025-06-01T14:30:00+00:00"
$uri = '/files/top_secret.pdf';
$message = "{$uri}|{$timestamp}|{$expire}";
$token = base64_encode(hash_hmac($algo, $message, $secret, true));
$token = strtr($token, '+/', '-_');
$token = rtrim($token, '=');
$url = "https://example.com{$uri}?st={$token}&ts=" . urlencode($timestamp) . "&e={$expire}";
PHP — RFC 7231 / IMF-fixdate 时间戳
<?php
$secret = 'my_very_secret_key';
$expire = 60;
$algo = 'sha256';
// RFC 7231 IMF-fixdate — 始终为 UTC,始终带有 "GMT" 后缀
$timestamp = gmdate('D, d M Y H:i:s') . ' GMT'; // "Sun, 01 Jun 2025 14:30:00 GMT"
$uri = '/files/top_secret.pdf';
$message = "{$uri}|{$timestamp}|{$expire}";
$token = base64_encode(hash_hmac($algo, $message, $secret, true));
$token = strtr($token, '+/', '-_');
$token = rtrim($token, '=');
// URL 编码 RFC 7231 日期(包含空格和逗号)
$url = "https://example.com{$uri}?st={$token}&ts=" . rawurlencode($timestamp) . "&e={$expire}";
Node.js — Unix 时间戳
const crypto = require('crypto');
const secret = 'my_very_secret_key';
const expire = 60;
const timestamp = Math.floor(Date.now() / 1000); // Unix 时间戳
const uri = '/files/top_secret.pdf';
const message = `${uri}|${timestamp}|${expire}`;
const token = crypto.createHmac('sha256', secret)
.update(message)
.digest('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
const url = `https://example.com${uri}?st=${token}&ts=${timestamp}&e=${expire}`;
Node.js — RFC 7231 / IMF-fixdate 时间戳
const crypto = require('crypto');
const secret = 'my_very_secret_key';
const expire = 60;
// toUTCString() 在所有现代运行时中生成 RFC 7231 IMF-fixdate 格式
const timestamp = new Date().toUTCString(); // "Sun, 01 Jun 2025 14:30:00 GMT"
const uri = '/files/top_secret.pdf';
const message = `${uri}|${timestamp}|${expire}`;
const token = crypto.createHmac('sha256', secret)
.update(message)
.digest('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
const url = `https://example.com${uri}?st=${token}&ts=${encodeURIComponent(timestamp)}&e=${expire}`;
Python — ISO 8601 时间戳(UTC Z 后缀)
import hmac, hashlib, base64, urllib.parse
from datetime import datetime, timezone
secret = b'my_very_secret_key'
expire = 60
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
uri = '/files/top_secret.pdf'
message = f'{uri}|{timestamp}|{expire}'.encode()
token = base64.urlsafe_b64encode(
hmac.new(secret, message, hashlib.sha256).digest()
).rstrip(b'=').decode()
url = f'https://example.com{uri}?st={token}&ts={urllib.parse.quote(timestamp)}&e={expire}'
Bash — Unix 时间戳
#!/bin/bash
SECRET="my_super_secret"
URI="/file/my_secret_file.txt"
TIMESTAMP="$(date +%s)"
EXPIRES=3600
MESSAGE="${URI}|${TIMESTAMP}|${EXPIRES}"
TOKEN="$(printf '%s' "$MESSAGE" \
| openssl dgst -sha256 -hmac "$SECRET" -binary \
| openssl base64 \
| tr '+/' '-_' \
| tr -d '=')"
echo "http://127.0.0.1${URI}?st=${TOKEN}&ts=${TIMESTAMP}&e=${EXPIRES}"
代理使用
当 NGINX 作为代理必须为外发请求添加 HMAC 令牌时,请使用 $secure_link_hmac_token 变量:
location ^~ /backend_location/ {
set $expire 60;
secure_link_hmac_message "$uri|$time_iso8601|$expire";
secure_link_hmac_secret "my_very_secret_key";
secure_link_hmac_algorithm sha256;
proxy_pass "http://backend_server$uri?st=$secure_link_hmac_token&ts=$time_iso8601&e=$expire";
}
注意:
$time_iso8601发出带有数字 UTC 偏移的 ISO 8601 时间戳(例如2025-06-01T14:30:00+00:00),该模块接受此格式。
安全注意事项
secure_link_hmac 中的分隔符
secure_link_hmac 指令值中的字段分隔符始终是逗号。时间戳和过期字段不得包含裸露的逗号(ISO 8601 和 Unix 时间戳是安全的;RFC 7231 时间戳由模块的内部逗号跳过逻辑处理,但嵌入的逗号必须在 URL 编码/解码时保持完整 — 请参见 时间戳格式)。
secure_link_hmac_message 中的分隔符
选择一个在任何要连接的字段中都不会出现的分隔符。管道符 (|) 是 URI + Unix 时间戳组合的良好默认值。根本不使用分隔符是有效的,但可能允许长度扩展攻击,其中一组有效的字段值被重新解释为另一组;分隔符可以防止这种情况。
其他建议
- 始终在签名消息中包含时间戳,以防止重放攻击。
- 根据您的用例选择较短的 expires 值(60–3600 秒是下载链接的典型值)。
- 对于所有失败情况返回相同的 HTTP 错误代码(例如 403) — "0"(过期)和未找到(无效) — 以便攻击者无法区分过期令牌和伪造令牌。
- 使用至少 32 字节的随机熵作为秘钥。
- 优先使用 sha256 或更强;避免在新部署中使用 md5 和 sha1。
- 对于包含查询字符串中特殊字符的时间戳值进行 URL 编码:
- ISO 8601 UTC 偏移量 + 必须发送为 %2B(否则解码为空间)
- RFC 7231 空格必须发送为 %20,嵌入的逗号为 %2C
GitHub
您可以在 nginx-module-hmac-secure-link 的 GitHub 仓库中找到此模块的其他配置提示和文档。