跳转至

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 安装中开箱即用): md5sha1sha224sha256sha384sha512sha512-224sha512-256sha3-224sha3-256sha3-384sha3-512shake128shake256blake2b512blake2s256sm3

遗留提供程序(需要在 openssl.cnf 中显式加载 OpenSSL 遗留提供程序;在 OpenSSL 3.x 中默认不可用): md4mdc2rmd160gost

推荐的算法是 sha256 或更强。md5sha1 被接受,但不应在新部署中使用。

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 解析为完整的解码日期字符串(请参见 时间戳格式)。

上下文: httpserverlocation

要验证其 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";

上下文: httpserverlocation

HMAC 秘钥。请将其保密,不要放入版本控制中。

secure_link_hmac_secret "my_very_secret_key";

上下文: httpserverlocation
默认: sha256

用于 HMAC 的 OpenSSL 摘要名称。

secure_link_hmac_algorithm sha256;

嵌入变量

在处理 secure_link_hmac 指令后设置。可能的值:

含义
"1" 令牌在密码学上有效,并且链接过期
"0" 令牌有效,但链接已过期
(空) 令牌缺失、格式错误、HMAC 不匹配或时间戳无效

使用此变量来限制访问。在生产环境中,对于所有失败情况返回相同的错误代码,以便攻击者无法区分过期令牌和伪造令牌:

if ($secure_link_hmac != "1") {
    return 403;
}

注意: "1""0" 是字面上的单字符字符串,而不是数字。空值/未找到情况表示变量未设置,而不是等于 ""

原始过期周期字符串(以秒为单位),如请求中接收的。仅当 secure_link_hmac 中存在过期时,此变量才会被设置。它可以用于日志记录或条件逻辑:

add_header X-Link-Expires $secure_link_hmac_expires;
  • 如果传入值为 "3600",则此变量包含 "3600"
  • 如果未提供过期字段,则变量未设置(not_found)。
  • 此变量是在评估 $secure_link_hmac 的副作用下填充的;请先评估 $secure_link_hmac

新计算的 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 是三字母的星期几缩写(MonSun),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 或更强;避免在新部署中使用 md5sha1。 - 对于包含查询字符串中特殊字符的时间戳值进行 URL 编码: - ISO 8601 UTC 偏移量 + 必须发送为 %2B(否则解码为空间) - RFC 7231 空格必须发送为 %20,嵌入的逗号为 %2C

GitHub

您可以在 nginx-module-hmac-secure-link 的 GitHub 仓库中找到此模块的其他配置提示和文档。