diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2be7a52 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.t linguist-language=Perl +*.t linguist-vendored \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..de2957c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,302 @@ +name: CI + +on: + push: + branches: ["master", "main", "develop"] + pull_request: + branches: ["master", "main", "develop"] + +# --------------------------------------------------------------------------- +# Concurrency: cancel in-progress runs on the same ref so that only the +# latest commit on a branch is tested when pushes arrive quickly. +# --------------------------------------------------------------------------- +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: "nginx-${{ matrix.nginx }} / openssl-${{ matrix.openssl }} / ${{ matrix.os }}" + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + # ----------------------------------------------------------------------- + # Explicit include-only matrix — no cross-product, full control. + # + # NGINX version notes: + # 1.20.2 legacy (Aug 2021) — PCRE1 only; ubuntu-22.04 exclusively + # (libpcre3-dev in main, gcc 11 compatible with that vintage). + # 1.26.3 legacy stable branch. + # 1.28.3 current stable (even minor = stable). + # 1.29.7 current mainline (odd minor = mainline). + # + # The "pcre" field is consumed by the install steps below; it does not + # affect the job name shown in the GitHub UI. + # ----------------------------------------------------------------------- + include: + # -- Legacy: NGINX 1.20.2, PCRE1, Ubuntu 22.04 only ------------------ + - { os: ubuntu-22.04, nginx: "1.20.2", openssl: system, pcre: pcre1 } + + # -- Legacy stable: NGINX 1.26.3 ------------------------------------- + - { os: ubuntu-22.04, nginx: "1.26.3", openssl: system, pcre: pcre2 } + - { os: ubuntu-24.04, nginx: "1.26.3", openssl: system, pcre: pcre2 } + + # -- Current stable: NGINX 1.28.3 ------------------------------------ + - { os: ubuntu-22.04, nginx: "1.28.3", openssl: system, pcre: pcre2 } + - { os: ubuntu-24.04, nginx: "1.28.3", openssl: system, pcre: pcre2 } + + # -- Mainline: NGINX 1.29.7 ------------------------------------------- + - { os: ubuntu-22.04, nginx: "1.29.7", openssl: system, pcre: pcre2 } + - { os: ubuntu-24.04, nginx: "1.29.7", openssl: system, pcre: pcre2 } + + # -- Pinned OpenSSL 3.6.1 — exercises the EVP_MAC code path ----------- + # OpenSSL is built from source as a static-only (no-shared) install + # into ~/openssl, then nginx is pointed at it via --with-cc-opt and + # --with-ld-opt only — NOT --with-openssl=. + # + # Why NOT --with-openssl=: + # nginx's Makefile always injects a bare "-lcrypto" flag before the + # explicit static archive paths in the final link command. When + # --with-openssl= is used, the search path does not include the + # internal .openssl/lib directory, so that bare flag fails with + # "cannot find -lcrypto" once libssl-dev is absent. + # + # Why libssl-dev must NOT be installed for these jobs: + # With libssl-dev present, "-lcrypto" resolves to the system's + # libcrypto.so.3 (OpenSSL 3.0.x) regardless of what comes later in + # the link command, producing a binary that runs with the wrong + # version. Without libssl-dev, "-lcrypto" and "-lssl" resolve + # exclusively to the static archives in ~/openssl/lib64 via the + # -L flag in --with-ld-opt. + # + # Only ubuntu-24.04; only the three currently maintained nginx versions. + - { os: ubuntu-24.04, nginx: "1.26.3", openssl: "3.6.1", pcre: pcre2 } + - { os: ubuntu-24.04, nginx: "1.28.3", openssl: "3.6.1", pcre: pcre2 } + - { os: ubuntu-24.04, nginx: "1.29.7", openssl: "3.6.1", pcre: pcre2 } + + steps: + # ----------------------------------------------------------------------- + - name: Checkout module source + uses: actions/checkout@v4 + + # ----------------------------------------------------------------------- + - name: Install system build dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + build-essential \ + zlib1g-dev \ + curl \ + ca-certificates + + # ----------------------------------------------------------------------- + # libssl-dev provides the system OpenSSL headers and shared libraries. + # It is required for system OpenSSL jobs (headers + libcrypto.so for + # nginx's configure feature tests and final linking). + # + # It must NOT be installed for pinned OpenSSL jobs — see matrix comment. + # ----------------------------------------------------------------------- + - name: Install system OpenSSL headers (system OpenSSL jobs only) + if: matrix.openssl == 'system' + run: sudo apt-get install -y --no-install-recommends libssl-dev + + # ----------------------------------------------------------------------- + # PCRE: NGINX 1.20.x requires PCRE1 (libpcre3-dev). + # NGINX 1.26+ uses PCRE2 (libpcre2-dev) by default. + # ----------------------------------------------------------------------- + - name: Install PCRE1 (NGINX 1.20.x legacy) + if: matrix.pcre == 'pcre1' + run: sudo apt-get install -y --no-install-recommends libpcre3-dev + + - name: Install PCRE2 (NGINX 1.26+) + if: matrix.pcre == 'pcre2' + run: sudo apt-get install -y --no-install-recommends libpcre2-dev + + # ----------------------------------------------------------------------- + # Cache the installed static OpenSSL tree (~/openssl). + # Key: version + OS. Bump -vN to bust manually if needed. + # + # Note on "Failed to save" warnings: GitHub Actions has no write lock on + # cache keys. When several jobs share the same key and finish concurrently + # for the first time, the first writer wins and the rest log a warning. + # This is harmless — all jobs read the cache successfully on subsequent + # runs. It is a first-run-only occurrence. + # ----------------------------------------------------------------------- + - name: Cache OpenSSL ${{ matrix.openssl }} build + if: matrix.openssl != 'system' + id: cache-openssl + uses: actions/cache@v4 + with: + path: ~/openssl + key: openssl-${{ matrix.openssl }}-${{ matrix.os }}-v1 + + # ----------------------------------------------------------------------- + # Build OpenSSL from source only on cache miss. + # + # "make build_sw" compiles libraries + CLI only — it skips the full test + # suite (200+ binaries) that "make" would build, saving several minutes. + # "make install_sw" installs into ~/openssl without docs or man pages. + # ----------------------------------------------------------------------- + - name: Build OpenSSL ${{ matrix.openssl }} from source + if: matrix.openssl != 'system' && steps.cache-openssl.outputs.cache-hit != 'true' + env: + OPENSSL_VERSION: ${{ matrix.openssl }} + run: | + curl -fsSL \ + "https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz" \ + -o openssl.tar.gz + tar xf openssl.tar.gz + cd "openssl-${OPENSSL_VERSION}" + ./Configure --prefix="${HOME}/openssl" \ + --openssldir="${HOME}/openssl" \ + no-shared linux-x86_64 + make -j"$(nproc)" build_sw + make install_sw + + # ----------------------------------------------------------------------- + - name: Download and extract NGINX ${{ matrix.nginx }} + env: + NGINX_VERSION: ${{ matrix.nginx }} + run: | + curl -fsSL \ + "https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" \ + -o nginx.tar.gz + tar xf nginx.tar.gz + echo "NGINX_SRC=${PWD}/nginx-${NGINX_VERSION}" >> "${GITHUB_ENV}" + + # ----------------------------------------------------------------------- + - name: Configure NGINX (system OpenSSL) + if: matrix.openssl == 'system' + run: | + cd "${NGINX_SRC}" + ./configure \ + --with-http_ssl_module \ + --with-http_v2_module \ + --add-module="${GITHUB_WORKSPACE}" \ + --with-cc-opt="-Wall -Wextra -Wno-unused-parameter" \ + 2>&1 | tee configure.log + + # ----------------------------------------------------------------------- + # Pinned OpenSSL: configure nginx using --with-cc-opt / --with-ld-opt + # pointing at ~/openssl. --with-openssl= is intentionally omitted. + # + # --with-cc-opt: supplies the 3.6.1 headers for compilation. + # --with-ld-opt: adds ~/openssl/lib64 to the linker search path so that + # the "-lssl" and "-lcrypto" flags nginx injects resolve to the static + # archives there. -ldl and -pthread satisfy OpenSSL's own link deps + # when statically linked. + # ----------------------------------------------------------------------- + - name: Configure NGINX (pinned OpenSSL ${{ matrix.openssl }}) + if: matrix.openssl != 'system' + run: | + cd "${NGINX_SRC}" + ./configure \ + --with-http_ssl_module \ + --with-http_v2_module \ + --with-cc-opt="-Wall -Wextra -Wno-unused-parameter -I${HOME}/openssl/include" \ + --with-ld-opt="-L${HOME}/openssl/lib64 -ldl -pthread" \ + --add-module="${GITHUB_WORKSPACE}" \ + 2>&1 | tee configure.log + + # ----------------------------------------------------------------------- + - name: Build NGINX + run: | + cd "${NGINX_SRC}" + make -j"$(nproc)" 2>&1 | tee build.log + ls -lh objs/nginx + objs/nginx -V 2>&1 + + # ----------------------------------------------------------------------- + - name: Upload configure / build logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: build-logs-nginx${{ matrix.nginx }}-ossl${{ matrix.openssl }}-${{ matrix.os }} + path: | + ${{ env.NGINX_SRC }}/configure.log + ${{ env.NGINX_SRC }}/build.log + + # ----------------------------------------------------------------------- + # Cache cpanm-installed Perl modules (Test::Nginx + ~17 deps). + # apt packages (cpanminus, libdigest-*) are fast and not cached. + # + # Both paths are required: + # /usr/local/share/perl — pure-Perl modules + # /usr/local/lib/perl5 — XS modules (e.g. List::MoreUtils::XS) + # + # "Failed to save" on first run: see OpenSSL cache note above — same + # mechanism. Harmless; all parallel jobs on subsequent runs get hits. + # ----------------------------------------------------------------------- + - name: Cache Perl dependencies + id: cache-perl + uses: actions/cache@v4 + with: + path: | + /usr/local/share/perl + /usr/local/lib/perl5 + key: perl-test-nginx-0.32-${{ matrix.os }}-v1 + + - name: Install Perl test dependencies + run: | + sudo apt-get install -y --no-install-recommends \ + cpanminus \ + libdigest-sha-perl \ + libdigest-hmac-perl \ + liburi-perl + if [ "${{ steps.cache-perl.outputs.cache-hit }}" != "true" ]; then + sudo cpanm --notest Test::Nginx + fi + + # ----------------------------------------------------------------------- + - name: Verify NGINX binary + run: | + "${NGINX_SRC}/objs/nginx" -V 2>&1 + + # ----------------------------------------------------------------------- + - name: Syntax-check test files + run: | + for f in t/01_basic.t t/02_timestamps.t t/03_algorithms.t \ + t/04_variables.t t/05_integration.t; do + perl -I t/lib -c "$f" + done + + # ----------------------------------------------------------------------- + - name: Run test suite + env: + TEST_NGINX_BINARY: "${{ env.NGINX_SRC }}/objs/nginx" + TEST_NGINX_SERVROOT: "${{ runner.temp }}/nginx-test" + run: prove -I t/lib -v --timer t/ + + # ----------------------------------------------------------------------- + - name: Upload nginx error log on test failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: nginx-error-log-nginx${{ matrix.nginx }}-ossl${{ matrix.openssl }}-${{ matrix.os }} + path: "${{ runner.temp }}/nginx-test/logs/error.log" + + # --------------------------------------------------------------------------- + # Lint: cppcheck static analysis — fast, no NGINX source needed. + # --------------------------------------------------------------------------- + lint: + name: "cppcheck static analysis" + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + + - name: Install cppcheck + run: sudo apt-get install -y cppcheck + + - name: Run cppcheck + run: | + cppcheck \ + --enable=all \ + --inconclusive \ + --std=c99 \ + --suppress=missingIncludeSystem \ + --suppress=variableScope \ + --error-exitcode=1 \ + ngx_http_hmac_secure_link_module.c 2>&1 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3eac3bd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,141 @@ +# Changelog + +All notable changes to **ngx_http_hmac_secure_link_module** are documented +here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [2.0.0] — 2026-03-28 + +### Added + +- **RFC 7231 / IMF-fixdate timestamp support.** HTTP dates in the format + `"Day, DD Mon YYYY hh:mm:ss GMT"` (RFC 7231 §7.1.1.1) are now accepted as + the timestamp field of `secure_link_hmac`. Month-name lookup is + case-insensitive. All RFC 7231 dates are implicitly UTC. + +- **ISO 8601 `Z` suffix support.** Timestamps of the form + `"YYYY-MM-DDThh:mm:ssZ"` are now recognised as an alias for `+00:00` UTC + offset. Previously they fell through to the Unix-timestamp branch and were + silently misinterpreted. + +- **OpenSSL 3.0+ `EVP_MAC` API.** HMAC computation now uses `EVP_MAC` on + OpenSSL ≥ 3.0 (avoiding the `HMAC()` deprecation warning) and retains the + `HMAC()` one-shot function on OpenSSL 1.0/1.1. Controlled by a + `OPENSSL_VERSION_NUMBER` compile-time guard. + +- **`NGX_HMAC_MD_SIZE` macro.** Wraps `EVP_MD_size()` (OpenSSL 1.x) and + `EVP_MD_get_size()` (OpenSSL 3.x) behind a single call site. + +- **Helper functions** extracted from the main variable handler: + - `ngx_http_secure_link_gauss()` — Gauss proleptic Gregorian calendar + formula with calendar-field range validation. + - `ngx_http_secure_link_parse_ts()` — tries each timestamp format in + order and returns `(time_t)-1` on failure. + - `ngx_http_secure_link_hmac_compute()` — OpenSSL-version-aware HMAC + computation with a key-length overflow guard. + +- **Perl test suite** (`t/01_basic.t` … `t/05_integration.t`, 68 tests across 10 + categories split into five focused files) covering valid/expired/invalid tokens, all timestamp formats + and their edge cases, algorithm variants, variable values, separator + choices, and real-world access-control patterns. + +- **Shared test helper module** (`t/lib/HmacSecureLink.pm`) providing + token generators, timestamp formatters, and the `TS_FIXED` constant that + eliminates timing-race failures in token-comparison tests. + +- **CI matrix — NGINX versions updated:** + - Added NGINX 1.28.3 (current stable) and 1.29.7 (current mainline) to + the test matrix on ubuntu-22.04 and ubuntu-24.04. + - Retained NGINX 1.26.3 (legacy stable) on ubuntu-22.04 and ubuntu-24.04. + - Retained NGINX 1.20.2 (legacy, Aug 2021) on ubuntu-22.04 only; this + version requires PCRE1 (`libpcre3-dev`) and is not compatible with PCRE2. + +- **`Makefile`** for local build, test, lint, and dependency-installation + targets. + +### Fixed + +- **Incorrect type casts in `sscanf`.** `sscanf "%d"` requires `int *`. + The original code cast local `int` variables to `ngx_tm_year_t *`, + `ngx_tm_mon_t *`, etc., which differ in size from `int` on some platforms, + causing undefined behaviour and potential stack corruption. + +- **Y2038 overflow in the Gauss formula.** The expression `365 * year` was + computed as `int × int`. For years ≥ 2038 on 32-bit `int` platforms the + accumulated day count overflowed before the `(time_t)` cast was applied. + Fixed by casting `year` to `time_t` before the first multiplication. + +- **`size_t` / `unsigned int` mismatch in token variable handler.** The + original code passed `(u_int *) &hmac.len` to `HMAC()`. `hmac.len` is + `size_t` (8 bytes on LP64); only the low 4 bytes were written correctly on + little-endian targets. On big-endian LP64 the result was entirely wrong. + Fixed by using a local `unsigned int hmac_len` variable. + +- **`$secure_link_hmac_expires` returned token bytes instead of the expiry + period.** `ctx->expires` was set to `value.data/len` at a point where + `value` had already been trimmed to the token substring. Fixed by pointing + `ctx->expires` at the actual expiry-period substring. + +- **Loose Unix timestamp validation.** `sscanf(p, "%llu", …)` greedily + accepted any string starting with digits — including ISO 8601 dates such as + `"2025-01-01T…"` (parsed as `2025`). Fixed by requiring every byte in the + timestamp substring to be a decimal digit. + +- **`EVP_MD_size()` return value not checked.** OpenSSL 3.0 returns `-1` on + error. The unchecked cast to `u_int` produced a huge buffer size and + passed a garbage length to `CRYPTO_memcmp`. Fixed with an explicit + `md_size <= 0` guard. + +- **GMT-offset sign applied twice.** Two independent `if` statements allowed + both branches to execute when the sign character was anything other than + `'+'` or `'-'` (which `sscanf` already ensures cannot happen, but the + logic was fragile). Fixed with `if / else if / else`. + +- **RFC 7231 internal comma broke the expiry-field separator search.** The + embedded comma in `"Sun, 06 Nov …"` was found before the real field + separator when an expiry field was present, leaving `ts_end` pointing + at a 3-byte substring that matched no parser. Fixed by detecting the + RFC 7231 weekday pattern and skipping its internal comma before scanning + for the next field boundary. + +- **Debug log timestamp width hardcoded to 25 bytes.** The original code + always logged 25 characters regardless of the actual timestamp format. + Fixed to log `(int)(ts_end - ts_start)` bytes. + +- **`%02d` in `sscanf` format strings.** The `0` flag is a printf-only + concept; it is silently ignored by `scanf`. Changed to `%2d` throughout + to eliminate misleading code. + +- **`key.len` narrowed to `int` without a guard.** `HMAC()`'s `key_len` + parameter is `int`, but `ngx_str_t.len` is `size_t`. Added an explicit + overflow guard in the OpenSSL 1.x compatibility path. + +### Changed + +- File-level comment block replaced with a concise changelog (this file). + Inline `/* FIX: … */` comments removed from function bodies; rationale + lives here and in the commit history. + +- `ngx_http_secure_link_add_variables`: loop variable `var` moved to inner + scope to satisfy `cppcheck --enable=all`. + +- `ngx_http_secure_link_parse_ts`: parameters `ts_last` declared `const`. + +- **README — algorithm list updated for OpenSSL 3.x provider model:** + - Algorithms are now grouped by provider: *default* (available without + configuration — `sha256`, `sha3-*`, `blake2b512`, `sm3`, etc.) and + *legacy* (requires the OpenSSL legacy provider to be loaded in + `openssl.cnf` — `md4`, `mdc2`, `rmd160`, `gost`). + - Added a note that `gost` and `mdc2` are not available on a default + OpenSSL 3.x install. + - Recommended algorithm note added: prefer `sha256` or stronger. + +--- + +## [1.x] — prior to 2026 + +Original implementation by the nginx-modules project. Supported ISO 8601 +timestamps with numeric UTC offset and Unix timestamps. See repository +history for individual changes. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8c8295d --- /dev/null +++ b/Makefile @@ -0,0 +1,107 @@ +# Makefile — ngx_http_hmac_secure_link_module +# +# Common targets: +# make build Download NGINX source and compile the module into it +# make test Run the Perl test suite with prove +# make lint Run cppcheck static analysis +# make clean Remove the downloaded NGINX source tree +# make distclean Also remove the downloaded NGINX tarball +# +# Configuration: +# NGINX_VERSION NGINX version to download (default: 1.26.3) +# NGINX_BINARY Path to an already-compiled nginx binary. +# Set this to skip 'make build' and use an existing binary. +# Default: $(NGINX_SRC)/objs/nginx +# MODULE_DIR Directory containing this Makefile (default: current dir) + +NGINX_VERSION ?= 1.26.3 +MODULE_DIR ?= $(CURDIR) +NGINX_TARBALL = nginx-$(NGINX_VERSION).tar.gz +NGINX_SRC = nginx-$(NGINX_VERSION) +NGINX_BINARY ?= $(NGINX_SRC)/objs/nginx + +# Number of parallel make jobs for the NGINX build. +JOBS ?= $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 2) + +# prove flags: verbose, include t/lib for HmacSecureLink.pm. +PROVE_INCLUDE = -I$(MODULE_DIR)/t/lib + +.PHONY: all build test lint clean distclean help + +all: build + +## Download NGINX source, configure with the module, and compile. +build: $(NGINX_BINARY) + +$(NGINX_BINARY): $(NGINX_SRC)/Makefile + $(MAKE) -C $(NGINX_SRC) -j$(JOBS) + +$(NGINX_SRC)/Makefile: $(NGINX_SRC)/configure + cd $(NGINX_SRC) && ./configure \ + --with-http_ssl_module \ + --with-http_v2_module \ + --add-module=$(MODULE_DIR) \ + --with-cc-opt="-Wall -Wextra -Wno-unused-parameter" + +$(NGINX_SRC)/configure: $(NGINX_TARBALL) + tar xf $(NGINX_TARBALL) + touch $@ + +$(NGINX_TARBALL): + curl -fsSL "https://nginx.org/download/$(NGINX_TARBALL)" -o $(NGINX_TARBALL) + +## Run the Perl test suite. +## Requires: cpanm Test::Nginx Digest::SHA Digest::HMAC_MD5 URI::Escape +test: $(NGINX_BINARY) + TEST_NGINX_BINARY=$(NGINX_BINARY) \ + prove $(PROVE_INCLUDE) -v t/ + +## Syntax-check the test file without running it. +test-syntax: + for f in t/01_basic.t t/02_timestamps.t t/03_algorithms.t \ + t/04_variables.t t/05_integration.t; do \ + perl -c -I$(MODULE_DIR)/t/lib "$$f" || exit 1; \ + done + +## Run cppcheck static analysis on the C source. +lint: + cppcheck \ + --enable=all \ + --inconclusive \ + --std=c99 \ + --suppress=missingIncludeSystem \ + --suppress=variableScope \ + --error-exitcode=1 \ + ngx_http_hmac_secure_link_module.c + +## Install Perl test dependencies via cpanm. +cpan-deps: + cpanm --notest \ + Test::Nginx \ + Digest::SHA \ + Digest::HMAC \ + Digest::HMAC_MD5 \ + URI::Escape + +## Remove the compiled NGINX source tree (keeps the tarball). +clean: + rm -rf $(NGINX_SRC) + +## Remove everything including the downloaded tarball. +distclean: clean + rm -f $(NGINX_TARBALL) + +help: + @echo "Targets:" + @echo " build Download nginx $(NGINX_VERSION) and compile with module" + @echo " test Run prove test suite (builds first if needed)" + @echo " test-syntax Perl -c syntax check on the test file" + @echo " lint Run cppcheck on ngx_http_hmac_secure_link_module.c" + @echo " cpan-deps Install Perl test dependencies via cpanm" + @echo " clean Remove nginx source tree" + @echo " distclean Remove nginx source tree and tarball" + @echo "" + @echo "Variables:" + @echo " NGINX_VERSION $(NGINX_VERSION)" + @echo " NGINX_BINARY $(NGINX_BINARY)" + @echo " JOBS $(JOBS)" diff --git a/README.md b/README.md index de17925..f3d1506 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,37 @@ Nginx HMAC Secure Link Module ============================= -Description: -============ +Description +----------- -The Nginx HMAC secure link module enhances the security and functionality of the standard secure link module. -Secure token is created using secure HMAC construction with an arbitrary hash algorithm supported by OpenSSL, e.g.: -`blake2b512`, `blake2s256`, `gost`, `md4`, `md5`, `mdc2`, `rmd160`, `sha1`, `sha224`, `sha256`, -`sha3-224`, `sha3-256`, `sha3-384`, `sha3-512`, `sha384`, `sha512`, `sha512-224`, `sha512-256`, `shake128`, `shake256`, `sm3`. +The Nginx HMAC Secure Link Module enhances the security and functionality +of the standard `secure_link` module. Secure tokens are created using a +proper HMAC construction (RFC 2104) with any hash algorithm supported by +OpenSSL 3.x. Available algorithms depend on the providers loaded in your +OpenSSL configuration. -Furthermore, secure token is created as described in RFC2104, that is, -`H(secret_key XOR opad,H(secret_key XOR ipad, message))` instead of a simple `MD5(secret_key,message, expire)`. +**Default provider** (available out of the box on any OpenSSL 3.x installation): +`md5`, `sha1`, `sha224`, `sha256`, `sha384`, `sha512`, `sha512-224`, `sha512-256`, +`sha3-224`, `sha3-256`, `sha3-384`, `sha3-512`, `shake128`, `shake256`, +`blake2b512`, `blake2s256`, `sm3`. -Pre-built Packages (Ubuntu / Debian): -===================================== +**Legacy provider** (requires the OpenSSL legacy provider to be explicitly loaded +in `openssl.cnf`; not available by default in OpenSSL 3.x): +`md4`, `mdc2`, `rmd160`, `gost`. -Pre-built packages for this module are freely available from the GetPageSpeed repository: +The recommended algorithm is `sha256` or stronger. `md5` and `sha1` are +accepted but should not be used in new deployments. + +The HMAC is computed as `H(secret ⊕ opad, H(secret ⊕ ipad, message))` +rather than the insecure `MD5(secret, message, expire)` used by the built-in +module. + + +Pre-built Packages (Ubuntu / Debian) +-------------------------------------- + +Pre-built packages for this module are freely available from the +GetPageSpeed repository: ```bash # Install the repository keyring @@ -23,8 +39,9 @@ sudo install -d -m 0755 /etc/apt/keyrings curl -fsSL https://extras.getpagespeed.com/deb-archive-keyring.gpg \ | sudo tee /etc/apt/keyrings/getpagespeed.gpg >/dev/null -# Add the repository (Ubuntu example - replace 'ubuntu' and 'jammy' for your distro) -echo "deb [signed-by=/etc/apt/keyrings/getpagespeed.gpg] https://extras.getpagespeed.com/ubuntu jammy main" \ +# Add the repository (Ubuntu example — replace 'jammy' for your release) +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 # Install nginx and the module @@ -32,164 +49,522 @@ sudo apt-get update sudo apt-get install nginx nginx-module-hmac-secure-link ``` -The module is automatically enabled after installation. Supported distributions include Debian 12/13 and Ubuntu 20.04/22.04/24.04 (both amd64 and arm64). See [the complete setup instructions](https://apt-nginx-extras.getpagespeed.com/apt-setup/). +The module is automatically enabled after installation. Supported +distributions include Debian 12/13 and Ubuntu 20.04/22.04/24.04 (both +amd64 and arm64). See the [complete setup instructions][gps]. + +[gps]: https://apt-nginx-extras.getpagespeed.com/apt-setup/ -Installation: -============= -You'll need to re-compile Nginx from source to include this module. -Modify your compile of Nginx by adding the following directive (modified to suit your path of course): +Installation from Source +------------------------ -Static module (built-in nginx binary) +You need to recompile Nginx from source to include this module. + +**Static module** (compiled into the binary): ./configure --add-module=/absolute/path/to/ngx_http_hmac_secure_link_module -Dynamic nginx module `ngx_http_hmac_secure_link_module.so` module +**Dynamic module** (`.so` loaded with `load_module`): - ./configure --with-compat --add-dynamic-module=/absolute/path/to/ngx_http_hmac_secure_link_module + ./configure --with-compat \ + --add-dynamic-module=/absolute/path/to/ngx_http_hmac_secure_link_module -Build Nginx +Then build: make make install -Usage: -====== +OpenSSL is required at build and runtime. The module is compatible with +OpenSSL 1.0.x, 1.1.x, and 3.x. On OpenSSL 3.x the modern `EVP_MAC` API +is used; on older versions the `HMAC()` one-shot function is used. + + +Configuration Directives +------------------------ + +All directives accept NGINX variables and complex values. + +### `secure_link_hmac` + +**Context:** `http`, `server`, `location` + +Specifies the variable expression whose evaluated value must follow the +format `,[,]`. **The field separator is always +a comma and is required between each field.** The comma is hardcoded in +the module parser; no other separator is supported here. + +| Field | Description | +|-------------|-----------------------------------------------------------| +| `token` | Base64url-encoded HMAC (no padding `=`) | +| `timestamp` | Request creation time (see [Timestamp Formats](#timestamp-formats)) | +| `expires` | Optional lifetime in seconds; omit or use `0` for unlimited | + +```nginx +secure_link_hmac "$arg_st,$arg_ts,$arg_e"; +``` + +> **Important:** When `secure_link_hmac` is assembled from query parameters +> (`"$arg_st,$arg_ts,$arg_e"`), the timestamp and expires values must not +> themselves contain unescaped commas. ISO 8601 and Unix timestamps are +> comma-free and work without special handling. RFC 7231 dates contain an +> embedded comma (e.g. `Sun, 06 Nov …`); the module handles this correctly +> for the second field, but you must URL-encode the comma when placing an +> RFC 7231 date in a query parameter so that `$arg_ts` resolves to the full +> decoded date string (see [Timestamp Formats](#timestamp-formats)). -Message to be hashed is defined by `secure_link_hmac_message`, `secret_key` is given by `secure_link_hmac_secret`, and hashing algorithm H is defined by `secure_link_hmac_algorithm`. +### `secure_link_hmac_message` -For improved security the timestamp in ISO 8601 the format `2017-12-08T07:54:59+00:00` (one possibility according to ISO 8601) or as `Unix Timestamp` should be appended to the message to be hashed. +**Context:** `http`, `server`, `location` -It is possible to create links with limited lifetime. This is defined by an optional parameter. If the expiration period is zero or it is not specified, a link has the unlimited lifetime. +The message whose HMAC is to be verified. Must match exactly what the +client used when computing the token. Typically includes the URI and the +timestamp so that tokens are URL-specific and time-bound. -Configuration example for server side. +**The separator between fields in the message is freely chosen by the +operator and may be any byte or sequence of bytes** — pipe (`|`), colon +(`:`), slash (`/`), hyphen (`-`), or even nothing at all. The module +treats `secure_link_hmac_message` as an opaque byte string and never +parses its contents; the separator is simply part of the HMAC pre-image. + +The only requirement is that the separator chosen on the server side is +identical to the separator used by the client when computing the HMAC. +Using a separator that cannot appear naturally in any of the field values +(such as `|` for URIs and Unix timestamps) reduces the risk of length- +extension ambiguity. + +```nginx +# Pipe separator (recommended — cannot appear in a URI path or Unix timestamp) +secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + +# Colon separator +secure_link_hmac_message "$uri:$arg_ts:$arg_e"; + +# No separator (valid, but ambiguous if fields share a character set) +secure_link_hmac_message "$uri$arg_ts$arg_e"; +``` + +### `secure_link_hmac_secret` + +**Context:** `http`, `server`, `location` + +The HMAC secret key. Keep this out of version control. + +```nginx +secure_link_hmac_secret "my_very_secret_key"; +``` + +### `secure_link_hmac_algorithm` + +**Context:** `http`, `server`, `location` +**Default:** `sha256` + +The OpenSSL digest name used for the HMAC. + +```nginx +secure_link_hmac_algorithm sha256; +``` + + +Embedded Variables +------------------ + +### `$secure_link_hmac` + +Set after processing the `secure_link_hmac` directive. Possible values: + +| Value | Meaning | +|-----------|-----------------------------------------------------------------| +| `"1"` | Token is cryptographically valid and the link has **not** expired | +| `"0"` | Token is valid but the link **has expired** | +| *(empty)* | Token is absent, malformed, HMAC mismatch, or timestamp invalid | + +Use this variable to gate access. In production, return the same error +code for all failing cases so that an attacker cannot distinguish between +an expired token and a forged one: + +```nginx +if ($secure_link_hmac != "1") { + return 403; +} +``` + +> **Note:** `"1"` and `"0"` are literal single-character strings, not +> numbers. The empty / not-found case means the variable is unset, not +> that it equals `""`. + +### `$secure_link_hmac_expires` + +The raw expiration-period string (in seconds) as received in the request. +This variable is only set when an expiry was present in `secure_link_hmac`. +It can be used for logging or conditional logic: + +```nginx +add_header X-Link-Expires $secure_link_hmac_expires; +``` + +- If the incoming value was `"3600"`, this variable contains `"3600"`. +- If no expiry field was present, the variable is unset (not_found). +- This variable is populated as a side-effect of evaluating + `$secure_link_hmac`; evaluate `$secure_link_hmac` first. + +### `$secure_link_hmac_token` + +A freshly computed base64url-encoded HMAC token (no trailing `=` padding). +Use this variable when NGINX acts as a proxy that must forward +authenticated requests to a backend: + +```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"; +} +``` + +The token is base64url-encoded without padding, compatible with URL query +parameters without further escaping. + + +Timestamp Formats +----------------- + +A timestamp **should** always be included in the signed message to prevent +replay attacks. Three formats are accepted by the server-side parser. +Clients can use whichever is most convenient. + +### ISO 8601 with numeric UTC offset *(recommended)* + +``` +YYYY-MM-DDThh:mm:ss+HH:MM +YYYY-MM-DDThh:mm:ss-HH:MM +``` + +Examples: +``` +2025-06-01T14:30:00+00:00 # UTC +2025-06-01T17:30:00+03:00 # UTC+3 (Kiev/Istanbul) +2025-06-01T08:30:00-06:00 # UTC-6 (Chicago CDT) +``` + +The server converts to UTC before comparing, so any valid offset is +accepted. + +### ISO 8601 UTC (Z suffix) + +``` +YYYY-MM-DDThh:mm:ssZ +``` + +Example: `2025-06-01T14:30:00Z` + +Equivalent to `+00:00` but shorter. Nginx's built-in `$time_iso8601` +variable emits `+00:00` format; for `Z` you must format the timestamp +application-side. + +### RFC 7231 / IMF-fixdate *(HTTP date)* + +As specified in [RFC 7231 §7.1.1.1][rfc7231]. All RFC 7231 dates are +implicitly UTC; no offset is applied. + +``` +Day, DD Mon YYYY hh:mm:ss GMT +``` + +Examples: +``` +Sun, 01 Jun 2025 14:30:00 GMT +Mon, 23 Mar 2026 08:00:00 GMT +``` + +Where `Day` is a three-letter weekday abbreviation (`Mon`–`Sun`) and +`Mon` (month) is one of `Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec`. +The parser is case-insensitive for both abbreviations. + +> **Note:** RFC 7231 also defines two obsolete formats (RFC 850 and +> ANSI C `asctime`). Those are not supported; only the preferred +> IMF-fixdate format is accepted. + +[rfc7231]: https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.1 + +### Unix timestamp *(plain integer)* + +A string of decimal digits representing seconds since the Unix epoch +(1970-01-01T00:00:00Z). + +Example: `1748785800` + +This is the simplest format and works well in Bash and Node.js. The +parser is strict: the timestamp field must contain **only** decimal +digits; any other character causes it to be rejected. + +> **Security note:** Unix timestamps have only one-second resolution. +> Use ISO 8601 if sub-second precision matters, or if you need to +> express a specific timezone. + + +Usage Example — Server Side +---------------------------- ```nginx location ^~ /files/ { - # Variable to be passed are secure token, timestamp, expiration period (optional) + # The three comma-separated fields: token, timestamp, expires (seconds) secure_link_hmac "$arg_st,$arg_ts,$arg_e"; - # Secret key + # HMAC secret key secure_link_hmac_secret "my_secret_key"; - # Message to be verified + # The message that was signed: URI + timestamp + expiry secure_link_hmac_message "$uri|$arg_ts|$arg_e"; - # Cryptographic hash function to be used + # Hash algorithm secure_link_hmac_algorithm sha256; - # In production environment, we should not reveal to potential attacker - # why hmac authentication has failed - # - If the hash is incorrect then $secure_link_hmac is a NULL string. - # - If the hash is correct but the link has already expired then $secure_link_hmac is "0". - # - If the hash is correct and the link has not expired then $secure_link_hmac is "1". + # In production, do not reveal whether the token was wrong or expired. + # $secure_link_hmac == "1" → valid and not expired + # $secure_link_hmac == "0" → valid but expired + # $secure_link_hmac unset → invalid / malformed if ($secure_link_hmac != "1") { - return 404; + return 403; } rewrite ^/files/(.*)$ /files/$1 break; } ``` -Application side should use a standard hash_hmac function to generate hash, which then needs to be base64url encoded. Example in Perl below. -#### Variable $data contains secure token, timestamp in ISO 8601 format, and expiration period in seconds +Client-Side Examples +-------------------- -```nginx +### Perl — ISO 8601 timestamp + +```perl perl_set $secure_token ' sub { use Digest::SHA qw(hmac_sha256_base64); use POSIX qw(strftime); - my $now = time(); - my $key = "my_very_secret_key"; - my $expire = 60; + my $r = shift; + my $key = "my_very_secret_key"; + my $expire = 60; + my $now = time(); + + # ISO 8601 with numeric UTC offset 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 $r = shift; - my $data = $r->uri; - my $digest = hmac_sha256_base64($data . "|" . $timestamp . "|" . $expire, $key); - $digest =~ tr(+/)(-_); - $data = "st=" . $digest . "&ts=" . $timestamp . "&e=" . $expire; - return $data; + + my $message = $r->uri . "|" . $timestamp . "|" . $expire; + my $digest = hmac_sha256_base64($message, $key); + $digest =~ tr(+/)(-_); # base64 → base64url + $digest =~ s/=+$//; # strip padding + + return "st=$digest&ts=$timestamp&e=$expire"; } '; ``` -A similar function in PHP +### PHP — Unix timestamp ```php -$secret = 'my_very_secret_key'; -$expire = 60; -$algo = 'sha256'; -$timestamp = date('c'); -$unixtimestamp = time(); -$stringtosign = "/files/top_secret.pdf|{$unixtimestamp}|{$expire}"; -$hashmac = base64_encode(hash_hmac($algo, $stringtosign, $secret, true)); -$hashmac = strtr($hashmac, '+/', '-_'); -$hashmac = str_replace('=', '', $hashmac); +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}"; ``` -Using Unix timestamp in Node.js +### PHP — RFC 7231 / IMF-fixdate timestamp + +```php + **Note:** `$time_iso8601` emits an ISO 8601 timestamp with a numeric UTC +> offset (e.g. `2025-06-01T14:30:00+00:00`), which this module accepts. + + +Security Notes +-------------- + +**Separator in `secure_link_hmac`** +The field separator inside the `secure_link_hmac` directive value is +always a comma. The timestamp and expires fields must not contain bare +commas (ISO 8601 and Unix timestamps are safe; RFC 7231 timestamps are +handled by the module's internal comma-skip logic but the embedded comma +must survive URL encoding/decoding intact — see +[Timestamp Formats](#timestamp-formats)). + +**Separator in `secure_link_hmac_message`** +Choose a separator that cannot appear in any of the fields being +concatenated. Pipe (`|`) is a good default for URI + Unix-timestamp +combinations. Using no separator at all is valid but can allow a +length-extension attack where one valid set of field values is +reinterpreted as a different set; a separator prevents this. + +**Other recommendations** +- Always include a timestamp in the signed message to prevent replay attacks. +- Choose a short `expires` value for your use case (60–3600 seconds is + typical for download links). +- Return the same HTTP error code (e.g. `403`) for all failure cases — + both `"0"` (expired) and not-found (invalid) — so that attackers cannot + distinguish an expired token from a forged one. +- Use a secret key of at least 32 bytes of random entropy. +- Prefer `sha256` or stronger; avoid `md5` and `sha1` for new deployments. +- URL-encode timestamp values that contain characters special in query strings: + - ISO 8601 UTC offset `+` must be sent as `%2B` (otherwise decoded as space) + - RFC 7231 spaces must be sent as `%20` and the embedded comma as `%2C` + + +Contributing +------------ + +Source repository: https://github.com/nginx-modules/ngx_http_hmac_secure_link_module + +Pull requests and patches are welcome. Please open an issue before making +significant changes. diff --git a/ngx_http_hmac_secure_link_module.c b/ngx_http_hmac_secure_link_module.c index 126dfcb..f117acd 100644 --- a/ngx_http_hmac_secure_link_module.c +++ b/ngx_http_hmac_secure_link_module.c @@ -1,14 +1,73 @@ +/* + * ngx_http_hmac_secure_link_module.c + * + * NGINX HMAC Secure Link Module + * + * Verifies request authenticity using an HMAC token, an optional timestamp, + * and an optional expiry period. The field separator in secure_link_hmac is + * always a comma. The separator used inside secure_link_hmac_message is + * freely chosen by the operator (pipe, colon, slash, …) and must match on + * both the client and the server. + * + * Supported timestamp formats for the second comma-separated field: + * ISO 8601 numeric offset "YYYY-MM-DDThh:mm:ss+HH:MM" / "…-HH:MM" + * ISO 8601 UTC Z suffix "YYYY-MM-DDThh:mm:ssZ" + * RFC 7231 / IMF-fixdate "Day, DD Mon YYYY hh:mm:ss GMT" + * Unix timestamp plain decimal integer (seconds since epoch) + */ #include #include #include -#include #include #include #include +#include + +/* ------------------------------------------------------------------------- + * OpenSSL version compatibility + * ------------------------------------------------------------------------- */ + +#if OPENSSL_VERSION_NUMBER >= 0x30000000L +# include +# include + /* + * EVP_MD_size() is deprecated in OpenSSL 3.0; the replacement is + * EVP_MD_get_size(). Wrap both spellings behind a single macro so the + * rest of the code is version-agnostic. + */ +# define NGX_HMAC_MD_SIZE(md) EVP_MD_get_size(md) +#else +# define NGX_HMAC_MD_SIZE(md) EVP_MD_size(md) +#endif + #define NGX_DEFAULT_HASH_FUNCTION "sha256" +/* + * ngx_isalpha is not defined by NGINX (ngx_string.h only provides + * ngx_isdigit, ngx_isspace, ngx_isalnum, etc.). Define it here using + * explicit ASCII range checks — the same approach NGINX uses for all its + * character-class macros — so that the check is locale-independent and + * avoids the implicit-function-declaration error when built with -Werror. + */ +#define ngx_isalpha(c) \ + (((c) >= 'A' && (c) <= 'Z') || ((c) >= 'a' && (c) <= 'z')) + +/* + * RFC 7231 §7.1.1.1 month-name table. + * Index 0 = January, index 11 = December. + */ +static const char * const ngx_http_secure_link_months[12] = { + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" +}; + + +/* ------------------------------------------------------------------------- + * Module data structures + * ------------------------------------------------------------------------- */ + typedef struct { ngx_http_complex_value_t *hmac_variable; ngx_http_complex_value_t *hmac_message; @@ -16,11 +75,15 @@ typedef struct { ngx_str_t hmac_algorithm; } ngx_http_secure_link_conf_t; - typedef struct { ngx_str_t expires; } ngx_http_secure_link_ctx_t; + +/* ------------------------------------------------------------------------- + * Forward declarations + * ------------------------------------------------------------------------- */ + static ngx_int_t ngx_http_secure_link_variable(ngx_http_request_t *r, ngx_http_variable_value_t *v, uintptr_t data); static ngx_int_t ngx_http_secure_link_expires_variable(ngx_http_request_t *r, @@ -33,6 +96,10 @@ static char *ngx_http_secure_link_merge_conf(ngx_conf_t *cf, void *parent, static ngx_int_t ngx_http_secure_link_add_variables(ngx_conf_t *cf); +/* ------------------------------------------------------------------------- + * Module directives, context, and descriptor + * ------------------------------------------------------------------------- */ + static ngx_command_t ngx_http_hmac_secure_link_commands[] = { { ngx_string("secure_link_hmac"), @@ -63,7 +130,7 @@ static ngx_command_t ngx_http_hmac_secure_link_commands[] = { offsetof(ngx_http_secure_link_conf_t, hmac_algorithm), NULL }, - ngx_null_command + ngx_null_command }; @@ -99,6 +166,7 @@ ngx_module_t ngx_http_hmac_secure_link_module = { static ngx_http_variable_t ngx_http_secure_link_vars[] = { + { ngx_string("secure_link_hmac"), NULL, ngx_http_secure_link_variable, 0, NGX_HTTP_VAR_CHANGEABLE, 0 }, @@ -108,10 +176,387 @@ static ngx_http_variable_t ngx_http_secure_link_vars[] = { { ngx_string("secure_link_hmac_token"), NULL, ngx_http_secure_link_token_variable, 0, NGX_HTTP_VAR_CHANGEABLE, 0 }, - { ngx_null_string, NULL, NULL, 0, 0, 0} + { ngx_null_string, NULL, NULL, 0, 0, 0 } }; +/* ========================================================================= + * HELPER: ngx_http_secure_link_hmac_compute + * + * Computes HMAC using the specified digest algorithm. Wraps the OpenSSL + * 1.x HMAC() one-shot function and the OpenSSL 3.x EVP_MAC API so that the + * caller does not need to handle the version difference. + * + * Parameters: + * r – current NGINX request (for logging only) + * evp_md – digest algorithm (from EVP_get_digestbyname) + * key – HMAC secret key bytes + * key_len – length of key in bytes + * msg – message to authenticate + * msg_len – length of message in bytes + * out – output buffer; must be at least EVP_MAX_MD_SIZE bytes + * out_len – receives the number of bytes written to out + * + * Returns NGX_OK on success, NGX_ERROR on failure. + * ========================================================================= */ + +static ngx_int_t +ngx_http_secure_link_hmac_compute(ngx_http_request_t *r, + const EVP_MD *evp_md, + const u_char *key, size_t key_len, + const u_char *msg, size_t msg_len, + u_char *out, unsigned int *out_len) +{ +#if OPENSSL_VERSION_NUMBER >= 0x30000000L + + /* + * OpenSSL 3.0+: use the EVP_MAC API. + * HMAC() is still present but deprecated; EVP_MAC avoids the warning + * and is the forward-compatible path. + */ + EVP_MAC *mac; + EVP_MAC_CTX *ctx; + OSSL_PARAM params[2]; + size_t len; + const char *digest_name; + ngx_int_t rc; + + digest_name = EVP_MD_get0_name(evp_md); + if (digest_name == NULL) { + ngx_log_error(NGX_LOG_ERR, r->connection->log, 0, + "secure link: EVP_MD_get0_name() failed"); + return NGX_ERROR; + } + + mac = EVP_MAC_fetch(NULL, "HMAC", NULL); + if (mac == NULL) { + ngx_log_error(NGX_LOG_ERR, r->connection->log, 0, + "secure link: EVP_MAC_fetch(HMAC) failed"); + return NGX_ERROR; + } + + ctx = EVP_MAC_CTX_new(mac); + /* EVP_MAC_CTX_new() takes its own reference to mac; release ours. */ + EVP_MAC_free(mac); + if (ctx == NULL) { + ngx_log_error(NGX_LOG_ERR, r->connection->log, 0, + "secure link: EVP_MAC_CTX_new() failed"); + return NGX_ERROR; + } + + /* + * OSSL_PARAM_construct_utf8_string takes a non-const char *. + * digest_name is effectively a constant string owned by OpenSSL; the + * cast is safe because EVP_MAC_init only reads it. + */ + params[0] = OSSL_PARAM_construct_utf8_string(OSSL_MAC_PARAM_DIGEST, + (char *)(uintptr_t) digest_name, + 0); + params[1] = OSSL_PARAM_construct_end(); + + rc = NGX_ERROR; + if (EVP_MAC_init(ctx, key, key_len, params) == 1 + && EVP_MAC_update(ctx, msg, msg_len) == 1 + && EVP_MAC_final(ctx, out, &len, EVP_MAX_MD_SIZE) == 1) + { + *out_len = (unsigned int) len; + rc = NGX_OK; + } else { + ngx_log_error(NGX_LOG_ERR, r->connection->log, 0, + "secure link: EVP_MAC operation failed"); + } + + EVP_MAC_CTX_free(ctx); + return rc; + +#else /* OpenSSL 1.0.x / 1.1.x */ + + /* + * HMAC()'s key_len parameter is int. Real secret keys are always short, + * but guard the cast to avoid undefined signed-overflow on adversarial + * configurations. + */ + if (key_len > (size_t) INT_MAX) { + ngx_log_error(NGX_LOG_ERR, r->connection->log, 0, + "secure link: HMAC key length (%uz) exceeds INT_MAX", + key_len); + return NGX_ERROR; + } + + if (HMAC(evp_md, key, (int) key_len, msg, msg_len, out, out_len) == NULL) { + ngx_log_error(NGX_LOG_ERR, r->connection->log, 0, + "secure link: HMAC() returned NULL"); + return NGX_ERROR; + } + + return NGX_OK; + +#endif /* OPENSSL_VERSION_NUMBER */ +} + + +/* ========================================================================= + * HELPER: ngx_http_secure_link_gauss + * + * Converts a broken-down UTC calendar date to a Unix timestamp using + * the Gauss formula for the proleptic Gregorian calendar. + * Source: NGINX ngx_http_parse_time.c + * + * Parameters: full 4-digit year (>= 1970), month 1–12, mday 1–31, + * hour 0–23, min 0–59, sec 0–60 (60 allowed for leap second). + * + * Returns the Unix timestamp, or (time_t)-1 if any argument is out of range. + * + * KEY FIX — Y2038 safety: + * The original code computed "(time_t)(365 * year + …) * 86400". + * The sub-expression "365 * year" is evaluated as int * int = int. + * For year >= 2038, 365 * 2038 = 743,870 — still fits in int32 — but + * the accumulated day count eventually overflows signed int32 around + * 2037/2038 before the cast to time_t is applied. + * Fix: cast year to time_t *before* the first multiplication so that + * all arithmetic in the formula promotes to the wider type. + * ========================================================================= */ + +static time_t +ngx_http_secure_link_gauss(int year, int month, int mday, + int hour, int min, int sec) +{ + time_t days; + + /* Basic range checks — callers must still validate month names etc. */ + if (year < 1970 + || month < 1 || month > 12 + || mday < 1 || mday > 31 + || hour < 0 || hour > 23 + || min < 0 || min > 59 + || sec < 0 || sec > 60) /* 60 is valid during a leap second */ + { + return (time_t) -1; + } + + /* + * Rearrange so that March is the first month of the year (February is + * last) — this simplifies leap-day handling in the Gauss formula. + */ + month -= 2; + if (month <= 0) { + month += 12; + year -= 1; + } + + /* + * Gauss' formula: count Gregorian days since March 1, 1 BC, then + * subtract the offset to the Unix epoch (March 1, 1970 = day 719527; + * plus 31 days of January 1970 and 28 days of February 1970). + * + * IMPORTANT: "(time_t) year * 365" — cast year to time_t FIRST. + * If both operands were int, the product would be computed as int and + * would overflow for dates past ~2037 on 32-bit int platforms. + */ + days = (time_t) year * 365 + + year / 4 + - year / 100 + + year / 400 + + 367 * month / 12 + - 30 + + mday - 1 + - 719527 + + 31 + 28; + + return days * 86400 + + (time_t) hour * 3600 + + (time_t) min * 60 + + (time_t) sec; +} + + +/* ========================================================================= + * HELPER: ngx_http_secure_link_parse_ts + * + * Parses a timestamp substring [p, ts_last) into a Unix time_t. + * ts_last must point one byte past the end of the timestamp (i.e. the + * position of the next comma, or end-of-value — NOT the NUL terminator). + * + * The substring is tried against each format in order; the first match wins. + * If no format matches, (time_t)-1 is returned. + * + * NOTE ON NUL TERMINATION: + * sscanf() reads until a format mismatch or NUL. NGINX ensures that + * ngx_str_t values produced by ngx_http_complex_value() are NUL-terminated + * (one extra byte is always allocated). The ts_last boundary is therefore + * used only to restrict digit-only validation, not to bound sscanf itself. + * The %n specifier is used to verify that sscanf consumed exactly the + * expected number of characters and did not overshoot. + * ========================================================================= */ + +static time_t +ngx_http_secure_link_parse_ts(ngx_http_request_t *r, + u_char *p, const u_char *ts_last) +{ + int year, month, mday, hour, min, sec; + int gmtoff_hour, gmtoff_min, nchars, n, i; /* hoisted: C89 requires all decls before statements */ + char gmtoff_sign; + time_t timestamp; + char mon_buf[4]; /* 3-char abbreviation + NUL */ + char wday_buf[4]; /* weekday abbrev; syntactic only, not used */ + u_char *q; + size_t ts_len; + + ts_len = (size_t)(ts_last - p); + if (ts_len == 0) { + return (time_t) -1; + } + + /* ------------------------------------------------------------------ + * Format 1: ISO 8601 with numeric UTC offset + * "YYYY-MM-DDThh:mm:ss+HH:MM" or "…-HH:MM" + * 25 characters; timezone offset is east (+) or west (-) of UTC. + * + * ------------------------------------------------------------------ */ + nchars = 0; + n = sscanf((char *) p, + "%4d-%2d-%2dT%2d:%2d:%2d%c%2d:%2d%n", + &year, &month, &mday, + &hour, &min, &sec, + &gmtoff_sign, &gmtoff_hour, &gmtoff_min, + &nchars); + + if (n == 9 + && nchars > 0 + && (gmtoff_sign == '+' || gmtoff_sign == '-') + && gmtoff_hour >= 0 && gmtoff_hour <= 23 + && gmtoff_min >= 0 && gmtoff_min <= 59) + { + time_t gmtoff; + + timestamp = ngx_http_secure_link_gauss(year, month, mday, + hour, min, sec); + if (timestamp == (time_t) -1) { + return (time_t) -1; + } + + gmtoff = (time_t) gmtoff_hour * 3600 + (time_t) gmtoff_min * 60; + + /* East of UTC: subtract offset; west of UTC: add offset. */ + if (gmtoff_sign == '+') { + timestamp -= gmtoff; + } else { + timestamp += gmtoff; + } + + ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, + "secure link ISO 8601 +offset timestamp: %T", timestamp); + return timestamp; + } + + /* ------------------------------------------------------------------ + * Format 2: ISO 8601 UTC with "Z" suffix + * "YYYY-MM-DDThh:mm:ssZ" (20 characters) + * + * ------------------------------------------------------------------ */ + nchars = 0; + n = sscanf((char *) p, + "%4d-%2d-%2dT%2d:%2d:%2dZ%n", + &year, &month, &mday, + &hour, &min, &sec, + &nchars); + + if (n == 6 && nchars > 0) { + timestamp = ngx_http_secure_link_gauss(year, month, mday, + hour, min, sec); + if (timestamp == (time_t) -1) { + return (time_t) -1; + } + + ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, + "secure link ISO 8601 UTC (Z) timestamp: %T", timestamp); + return timestamp; + } + + /* ------------------------------------------------------------------ + * Format 3: RFC 7231 / IMF-fixdate (HTTP date) + * "Day, DD Mon YYYY hh:mm:ss GMT" + * e.g. "Sun, 06 Nov 1994 08:49:37 GMT" (29 characters) + * + * All RFC 7231 dates are implicitly UTC; no offset is applied. + * RFC 7231 §7.1.1.1 — this is the preferred HTTP date format. + * + * The weekday abbreviation (wday_buf) is read for syntactic + * validity but not used in the timestamp calculation. + * ------------------------------------------------------------------ */ + nchars = 0; + n = sscanf((char *) p, + "%3s, %2d %3s %4d %2d:%2d:%2d GMT%n", + wday_buf, &mday, mon_buf, &year, + &hour, &min, &sec, + &nchars); + + if (n == 7 && nchars > 0) { + + /* Map the 3-letter month abbreviation to an integer 1–12 */ + month = 0; + for (i = 0; i < 12; i++) { + if (ngx_strncasecmp((u_char *) mon_buf, + (u_char *) ngx_http_secure_link_months[i], + 3) == 0) + { + month = i + 1; + break; + } + } + + if (month == 0) { + ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, + "secure link: unrecognised RFC 7231 month \"%.3s\"", + mon_buf); + return (time_t) -1; + } + + timestamp = ngx_http_secure_link_gauss(year, month, mday, + hour, min, sec); + if (timestamp == (time_t) -1) { + return (time_t) -1; + } + + ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, + "secure link RFC 7231 timestamp: %T", timestamp); + return timestamp; + } + + /* ------------------------------------------------------------------ + * Format 4: Unix timestamp (decimal integer string) + * + * Every byte in [p, ts_last) must be a decimal digit — strings that + * begin with digits but contain other characters are rejected before + * ngx_atotm() is called. + * ------------------------------------------------------------------ */ + for (q = p; q < ts_last; q++) { + if (*q < '0' || *q > '9') { + return (time_t) -1; + } + } + + /* ngx_atotm returns -1 on overflow/invalid input */ + timestamp = ngx_atotm(p, ts_len); + if (timestamp < 0) { + return (time_t) -1; + } + + ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, + "secure link Unix timestamp: %T", timestamp); + return timestamp; +} + + +/* ========================================================================= + * VARIABLE HANDLER: $secure_link_hmac + * + * Validates the incoming HMAC token. Sets the variable to: + * "1" — token is valid and the link has not expired + * "0" — token is valid but the link has expired + * "" — (not_found) token is missing, malformed, or HMAC mismatch + * ========================================================================= */ + static ngx_int_t ngx_http_secure_link_variable(ngx_http_request_t *r, ngx_http_variable_value_t *v, uintptr_t data) @@ -119,18 +564,21 @@ ngx_http_secure_link_variable(ngx_http_request_t *r, ngx_http_secure_link_ctx_t *ctx; ngx_http_secure_link_conf_t *conf; const EVP_MD *evp_md; - u_char *p, *last; + u_char *last, *token_end, *ts_start, *ts_end, + *exp_start; ngx_str_t value, hash, key; - u_char hash_buf[EVP_MAX_MD_SIZE], hmac_buf[EVP_MAX_MD_SIZE]; - u_int hmac_len; - time_t timestamp, expires, gmtoff; - unsigned long long conv_timestamp; - int year, month, mday, hour, min, sec, gmtoff_hour, gmtoff_min; - char gmtoff_sign; + u_char hash_buf[EVP_MAX_MD_SIZE]; + u_char hmac_buf[EVP_MAX_MD_SIZE]; + unsigned int hmac_len; + int md_size; + time_t timestamp, expires; conf = ngx_http_get_module_loc_conf(r, ngx_http_hmac_secure_link_module); - if (conf->hmac_variable == NULL || conf->hmac_message == NULL || conf->hmac_secret == NULL) { + if (conf->hmac_variable == NULL + || conf->hmac_message == NULL + || conf->hmac_secret == NULL) + { goto not_found; } @@ -141,91 +589,73 @@ ngx_http_secure_link_variable(ngx_http_request_t *r, ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, "secure link variable: \"%V\"", &value); - last = value.data + value.len; - - p = ngx_strlchr(value.data, last, ','); + last = value.data + value.len; timestamp = 0; - expires = 0; + expires = 0; + ctx = NULL; + + /* ------------------------------------------------------------------ + * Split the directive value: ",[,]" + * + * token_end points at the first comma (separator between token and + * timestamp); ts_start is the byte after it. + * ------------------------------------------------------------------ */ + token_end = ngx_strlchr(value.data, last, ','); - if (p) { - value.len = p++ - value.data; + if (token_end) { + + /* Trim value to the base64url token only */ + value.len = (size_t)(token_end - value.data); ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, "secure link token: \"%V\"", &value); - ngx_log_debug2(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, - "secure link timestamp: \"%*s\"", - sizeof("1970-09-28T12:00:00+06:00")-1, p); - - /* Parse timestamp in ISO8601 format */ - if (sscanf((char *)p, "%4d-%02d-%02dT%02d:%02d:%02d%c%02i:%02i", - (ngx_tm_year_t *) &year, (ngx_tm_mon_t *) &month, - (ngx_tm_mday_t *) &mday, (ngx_tm_hour_t *) &hour, - (ngx_tm_min_t *) &min, (ngx_tm_sec_t *) &sec, - &gmtoff_sign, &gmtoff_hour, &gmtoff_min) == 9) { - - /* Put February last because it has leap day */ - month -= 2; - if (month <= 0) { - month += 12; - year -= 1; + ts_start = token_end + 1; + + /* + * Locate the end of the timestamp field. RFC 7231 dates contain + * an embedded comma after the three-character weekday abbreviation + * (e.g. "Sun, 06 Nov 1994 08:49:37 GMT"). When the timestamp field + * starts with three alpha characters followed by a comma we skip + * past that internal comma before searching for the expires-field + * separator, so the embedded comma is not mistaken for a field + * boundary. + */ + { + u_char *sep_search = ts_start; + if ((last - ts_start) >= 4 + && ngx_isalpha(ts_start[0]) + && ngx_isalpha(ts_start[1]) + && ngx_isalpha(ts_start[2]) + && ts_start[3] == ',') + { + sep_search = ts_start + 4; } - - /* Gauss' formula for Gregorian days since March 1, 1 BC */ - /* Taken from ngx_http_parse_time.c */ - timestamp = (time_t) ( - /* days in years including leap years since March 1, 1 BC */ - 365 * year + year / 4 - year / 100 + year / 400 - /* days before the month */ - + 367 * month / 12 - 30 - /* days before the day */ - + mday - 1 - /* - * 719527 days were between March 1, 1 BC and March 1, 1970, - * 31 and 28 days were in January and February 1970 - */ - - 719527 + 31 + 28) * 86400 + hour * 3600 + min * 60 + sec; - - /* Determine the time offset with respect to GMT */ - gmtoff = 3600 * gmtoff_hour + 60 * gmtoff_min; - - if (gmtoff_sign == '+') { - timestamp -= gmtoff; - } - - if (gmtoff_sign == '-') { - timestamp += gmtoff; + ts_end = ngx_strlchr(sep_search, last, ','); + if (ts_end == NULL) { + ts_end = last; } - - ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, - "secure link timestamp: \"%T\"", timestamp); - - } else if (sscanf((char *)p, "%llu", &conv_timestamp) == 1) { - /* Try if p is UNIX timestamp */ - - timestamp = (time_t)conv_timestamp; - - ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, - "secure link timestamp: \"%T\"", timestamp); - - } else { - goto not_found; } - if (timestamp <= 0) { + ngx_log_debug2(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, + "secure link timestamp string: \"%*s\"", + (int)(ts_end - ts_start), ts_start); + + /* Parse using ISO 8601 (with offset or Z), RFC 7231, or Unix */ + timestamp = ngx_http_secure_link_parse_ts(r, ts_start, ts_end); + if (timestamp == (time_t) -1 || timestamp <= 0) { goto not_found; } - /* Parse expiration period in seconds */ - p = ngx_strlchr(p, last, ','); - - if (p) { - p++; - - expires = ngx_atotm(p, last - p); + /* ------------------------------------------------------------------ + * Optional expiration period (seconds after the timestamp comma). + * ------------------------------------------------------------------ */ + if (ts_end < last) { + exp_start = ts_end + 1; + expires = ngx_atotm(exp_start, (size_t)(last - exp_start)); ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, - "secure link expires: \"%T\"", expires); + "secure link expires: %T", expires); if (expires < 0) { goto not_found; @@ -238,23 +668,38 @@ ngx_http_secure_link_variable(ngx_http_request_t *r, ngx_http_set_ctx(r, ctx, ngx_http_hmac_secure_link_module); - ctx->expires.len = value.len; - ctx->expires.data = value.data; + ctx->expires.data = exp_start; + ctx->expires.len = (size_t)(last - exp_start); } } - evp_md = EVP_get_digestbyname((const char*) conf->hmac_algorithm.data); + /* ------------------------------------------------------------------ + * Resolve and validate the digest algorithm. + * ------------------------------------------------------------------ */ + evp_md = EVP_get_digestbyname((const char *) conf->hmac_algorithm.data); if (evp_md == NULL) { - ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, - "Unknown cryptographic hash function \"%s\"", conf->hmac_algorithm.data); + ngx_log_error(NGX_LOG_ERR, r->connection->log, 0, + "secure link: unknown digest algorithm \"%V\"", + &conf->hmac_algorithm); + return NGX_ERROR; + } + /* EVP_MD_get_size() returns -1 on error in OpenSSL 3.0; check before use. */ + md_size = NGX_HMAC_MD_SIZE(evp_md); + if (md_size <= 0) { + ngx_log_error(NGX_LOG_ERR, r->connection->log, 0, + "secure link: digest size query failed (returned %d)", + md_size); return NGX_ERROR; } - hash.len = (u_int) EVP_MD_size(evp_md); + /* ------------------------------------------------------------------ + * Decode and validate the base64url-encoded HMAC token. + * ------------------------------------------------------------------ */ + hash.len = (size_t) md_size; hash.data = hash_buf; - if (value.len > ngx_base64_encoded_length(hash.len)+2) { + if (value.len > (size_t)(ngx_base64_encoded_length((size_t) md_size) + 2)) { goto not_found; } @@ -262,10 +707,13 @@ ngx_http_secure_link_variable(ngx_http_request_t *r, goto not_found; } - if (hash.len != (u_int) EVP_MD_size(evp_md)) { + if (hash.len != (size_t) md_size) { goto not_found; } + /* ------------------------------------------------------------------ + * Retrieve message and secret key, then compute the expected HMAC. + * ------------------------------------------------------------------ */ if (ngx_http_complex_value(r, conf->hmac_message, &value) != NGX_OK) { return NGX_ERROR; } @@ -277,27 +725,50 @@ ngx_http_secure_link_variable(ngx_http_request_t *r, return NGX_ERROR; } - HMAC(evp_md, key.data, key.len, value.data, value.len, hmac_buf, &hmac_len); + if (ngx_http_secure_link_hmac_compute(r, evp_md, + key.data, key.len, + value.data, value.len, + hmac_buf, &hmac_len) != NGX_OK) + { + return NGX_ERROR; + } - if (CRYPTO_memcmp(hash_buf, hmac_buf, EVP_MD_size(evp_md)) != 0) { + /* + * Constant-time comparison to prevent timing-based side-channel attacks. + * CRYPTO_memcmp is guaranteed not to be optimised out. + */ + if (CRYPTO_memcmp(hash_buf, hmac_buf, (size_t) md_size) != 0) { goto not_found; } - v->data = (u_char *) ((expires && timestamp + expires < ngx_time()) ? "0" : "1"); - v->len = 1; - v->valid = 1; + /* ------------------------------------------------------------------ + * Token is authentic. Check expiry. + * expires == 0 means no expiry (unlimited lifetime). + * ------------------------------------------------------------------ */ + v->data = (u_char *) ((expires > 0 && timestamp + expires < ngx_time()) + ? "0" : "1"); + v->len = 1; + v->valid = 1; v->no_cacheable = 0; - v->not_found = 0; + v->not_found = 0; return NGX_OK; not_found: v->not_found = 1; - return NGX_OK; } + +/* ========================================================================= + * VARIABLE HANDLER: $secure_link_hmac_token + * + * Computes and returns a fresh base64url-encoded HMAC token using the + * configured message and secret key. Useful when NGINX acts as a proxy + * that must forward authenticated requests to a backend. + * ========================================================================= */ + static ngx_int_t ngx_http_secure_link_token_variable(ngx_http_request_t *r, ngx_http_variable_value_t *v, uintptr_t data) @@ -307,6 +778,7 @@ ngx_http_secure_link_token_variable(ngx_http_request_t *r, ngx_str_t value, key, hmac, token; const EVP_MD *evp_md; u_char hmac_buf[EVP_MAX_MD_SIZE]; + unsigned int hmac_len; conf = ngx_http_get_module_loc_conf(r, ngx_http_hmac_secure_link_module); @@ -314,11 +786,6 @@ ngx_http_secure_link_token_variable(ngx_http_request_t *r, goto not_found; } - p = ngx_pnalloc(r->pool, ngx_base64_encoded_length(EVP_MAX_MD_SIZE)); - if (p == NULL) { - return NGX_ERROR; - } - if (ngx_http_complex_value(r, conf->hmac_message, &value) != NGX_OK) { return NGX_ERROR; } @@ -330,36 +797,61 @@ ngx_http_secure_link_token_variable(ngx_http_request_t *r, return NGX_ERROR; } - evp_md = EVP_get_digestbyname((const char*) conf->hmac_algorithm.data); + evp_md = EVP_get_digestbyname((const char *) conf->hmac_algorithm.data); if (evp_md == NULL) { - ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, - "Unknown cryptographic hash function \"%s\"", conf->hmac_algorithm.data); + ngx_log_error(NGX_LOG_ERR, r->connection->log, 0, + "secure link: unknown digest algorithm \"%V\"", + &conf->hmac_algorithm); + return NGX_ERROR; + } + /* Allocate enough for the base64url-encoded HMAC output */ + p = ngx_pnalloc(r->pool, + (size_t) ngx_base64_encoded_length(EVP_MAX_MD_SIZE) + 1); + if (p == NULL) { return NGX_ERROR; } - hmac.data = hmac_buf; + hmac.data = hmac_buf; token.data = p; - HMAC(evp_md, key.data, key.len, value.data, value.len, hmac.data, (u_int *) &hmac.len); + if (ngx_http_secure_link_hmac_compute(r, evp_md, + key.data, key.len, + value.data, value.len, + hmac.data, &hmac_len) != NGX_OK) + { + return NGX_ERROR; + } + + hmac.len = (size_t) hmac_len; ngx_encode_base64url(&token, &hmac); - v->data = token.data; - v->len = token.len; - v->valid = 1; + v->data = token.data; + v->len = token.len; + v->valid = 1; v->no_cacheable = 0; - v->not_found = 0; + v->not_found = 0; return NGX_OK; not_found: v->not_found = 1; - return NGX_OK; } + +/* ========================================================================= + * VARIABLE HANDLER: $secure_link_hmac_expires + * + * Returns the raw expiration-period string (in seconds) as it appeared in + * the request, or sets not_found if no expiration was present. + * + * The context is populated by ngx_http_secure_link_variable(); this handler + * must therefore be called after $secure_link_hmac has been evaluated. + * ========================================================================= */ + static ngx_int_t ngx_http_secure_link_expires_variable(ngx_http_request_t *r, ngx_http_variable_value_t *v, uintptr_t data) @@ -369,12 +861,11 @@ ngx_http_secure_link_expires_variable(ngx_http_request_t *r, ctx = ngx_http_get_module_ctx(r, ngx_http_hmac_secure_link_module); if (ctx) { - v->len = ctx->expires.len; - v->valid = 1; + v->data = ctx->expires.data; + v->len = ctx->expires.len; + v->valid = 1; v->no_cacheable = 0; - v->not_found = 0; - v->data = ctx->expires.data; - + v->not_found = 0; } else { v->not_found = 1; } @@ -382,6 +873,11 @@ ngx_http_secure_link_expires_variable(ngx_http_request_t *r, return NGX_OK; } + +/* ========================================================================= + * Configuration lifecycle + * ========================================================================= */ + static void * ngx_http_secure_link_create_conf(ngx_conf_t *cf) { @@ -393,12 +889,11 @@ ngx_http_secure_link_create_conf(ngx_conf_t *cf) } /* - * set by ngx_pcalloc(): - * - * conf->hmac_variable = NULL; - * conf->hmac_message = NULL; - * conf->hmac_secret = NULL; - * conf->hmac_algorithm = {0,NULL}; + * ngx_pcalloc() zero-initialises the block, so: + * conf->hmac_variable = NULL + * conf->hmac_message = NULL + * conf->hmac_secret = NULL + * conf->hmac_algorithm = { 0, NULL } */ return conf; @@ -411,7 +906,8 @@ ngx_http_secure_link_merge_conf(ngx_conf_t *cf, void *parent, void *child) ngx_http_secure_link_conf_t *prev = parent; ngx_http_secure_link_conf_t *conf = child; - ngx_conf_merge_str_value(conf->hmac_algorithm, prev->hmac_algorithm, NGX_DEFAULT_HASH_FUNCTION); + ngx_conf_merge_str_value(conf->hmac_algorithm, prev->hmac_algorithm, + NGX_DEFAULT_HASH_FUNCTION); if (conf->hmac_variable == NULL) { conf->hmac_variable = prev->hmac_variable; @@ -428,19 +924,21 @@ ngx_http_secure_link_merge_conf(ngx_conf_t *cf, void *parent, void *child) return NGX_CONF_OK; } + static ngx_int_t ngx_http_secure_link_add_variables(ngx_conf_t *cf) { - ngx_http_variable_t *var, *v; + ngx_http_variable_t *v; for (v = ngx_http_secure_link_vars; v->name.len; v++) { + ngx_http_variable_t *var; var = ngx_http_add_variable(cf, &v->name, v->flags); if (var == NULL) { return NGX_ERROR; } var->get_handler = v->get_handler; - var->data = v->data; + var->data = v->data; } return NGX_OK; diff --git a/t/01_basic.t b/t/01_basic.t new file mode 100644 index 0000000..fd36854 --- /dev/null +++ b/t/01_basic.t @@ -0,0 +1,166 @@ +#!/usr/bin/perl +# 01_basic.t +# +# Basic HMAC verification — permanent links, malformed and oversized tokens +# +# Prerequisites: +# cpanm Test::Nginx Digest::SHA Digest::HMAC_MD5 URI::Escape +# +# Run: +# prove -I t/lib -v t/01_basic.t + +use strict; +use warnings; + +use Test::Nginx::Socket; +use lib 't/lib'; +use HmacSecureLink qw(:all); +use POSIX qw(strftime); + +# --------------------------------------------------------------------------- +# Make all HmacSecureLink helpers available inside --- request eval and +# --- response_body eval blocks. Test::Base evaluates those blocks in the +# Test::Base::Filter package, so functions imported into main:: are invisible +# there. Installing aliases into Test::Base::Filter:: solves this for both +# plain subs and constant subs (which are implemented as subs with no args). +# --------------------------------------------------------------------------- +BEGIN { + no strict 'refs'; + for my $fn (qw( + tok256 tok512 tok1 tokmd5 b64url + unix_now unix_past unix_far + iso_offset iso_z iso_past + rfc7231_now rfc7231_past + uri_escape + )) { + *{"Test::Base::Filter::$fn"} = \&{"HmacSecureLink::$fn"}; + } + for my $c (qw( + SECRET SECRET2 + TS_FIXED TS_FIXED_ISO TS_FIXED_Z TS_FIXED_RFC7231 + )) { + *{"Test::Base::Filter::$c"} = \&{"HmacSecureLink::$c"}; + } +} + +# 7 tests, 14 assertions total +our $repeat = repeat_each(); +plan tests => 14 * $repeat; + +no_shuffle(); +run_tests(); + +__DATA__ +# =========================================================================== +# CATEGORY 1 — Basic HMAC verification (no timestamp, no expiry) +# =========================================================================== + +=== TEST 1.1: valid token — no timestamp field (permanent link) +--- config + location /t { + secure_link_hmac "$arg_st"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +"GET /t?st=" . tok256("/t") +--- response_body: 1 +--- error_code: 200 + +=== TEST 1.2: wrong token — no timestamp field +--- config + location /t { + secure_link_hmac "$arg_st"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request +GET /t?st=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +--- response_body eval +"" +--- error_code: 200 + +=== TEST 1.3: empty token +--- config + location /t { + secure_link_hmac "$arg_st"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request +GET /t?st= +--- response_body eval +"" +--- error_code: 200 + +=== TEST 1.4: token with standard base64 padding character '=' +--- config + location /t { + secure_link_hmac "$arg_st"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $tok = tok256("/t"); +CORE::chop($tok); +$tok .= "="; +"GET /t?st=$tok" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 1.5: token with standard base64 '+' character +--- config + location /t { + secure_link_hmac "$arg_st"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $tok = tok256("/t"); +substr($tok, 5, 1) = '+'; +"GET /t?st=$tok" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 1.6: oversized token (longer than any HMAC digest output) +--- config + location /t { + secure_link_hmac "$arg_st"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request +GET /t?st=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +--- response_body eval +"" +--- error_code: 200 + +=== TEST 1.7: token signed with a different secret key +--- config + location /t { + secure_link_hmac "$arg_st"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +"GET /t?st=" . tok256("/t", SECRET2) +--- response_body eval +"" +--- error_code: 200 + diff --git a/t/02_timestamps.t b/t/02_timestamps.t new file mode 100644 index 0000000..e069c64 --- /dev/null +++ b/t/02_timestamps.t @@ -0,0 +1,624 @@ +#!/usr/bin/perl +# 02_timestamps.t +# +# Timestamp parsing — Unix epoch, ISO 8601 (offset and Z), RFC 7231 / IMF-fixdate +# +# Prerequisites: +# cpanm Test::Nginx Digest::SHA Digest::HMAC_MD5 URI::Escape +# +# Run: +# prove -I t/lib -v t/02_timestamps.t + +use strict; +use warnings; + +use Test::Nginx::Socket; +use lib 't/lib'; +use HmacSecureLink qw(:all); +use POSIX qw(strftime); + +# --------------------------------------------------------------------------- +# Make all HmacSecureLink helpers available inside --- request eval and +# --- response_body eval blocks. Test::Base evaluates those blocks in the +# Test::Base::Filter package, so functions imported into main:: are invisible +# there. Installing aliases into Test::Base::Filter:: solves this for both +# plain subs and constant subs (which are implemented as subs with no args). +# --------------------------------------------------------------------------- +BEGIN { + no strict 'refs'; + for my $fn (qw( + tok256 tok512 tok1 tokmd5 b64url + unix_now unix_past unix_far + iso_offset iso_z iso_past + rfc7231_now rfc7231_past + uri_escape + )) { + *{"Test::Base::Filter::$fn"} = \&{"HmacSecureLink::$fn"}; + } + for my $c (qw( + SECRET SECRET2 + TS_FIXED TS_FIXED_ISO TS_FIXED_Z TS_FIXED_RFC7231 + )) { + *{"Test::Base::Filter::$c"} = \&{"HmacSecureLink::$c"}; + } +} + +# 32 tests, 64 assertions total +our $repeat = repeat_each(); +plan tests => 64 * $repeat; + +no_shuffle(); +run_tests(); + +__DATA__ +# =========================================================================== +# CATEGORY 2 — Unix timestamps +# =========================================================================== + +=== TEST 2.1: Unix timestamp — valid, no expiry field +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body: 1 +--- error_code: 200 + +=== TEST 2.2: Unix timestamp — valid, future expiry (not yet expired) +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $e = 3600; +my $tok = tok256("/t|$ts|$e"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body: 1 +--- error_code: 200 + +=== TEST 2.3: Unix timestamp — valid HMAC but link is expired +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_past(); +my $e = 60; +my $tok = tok256("/t|$ts|$e"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body: 0 +--- error_code: 200 + +=== TEST 2.4: Unix timestamp — expiry = 0 means unlimited lifetime +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_past(); +my $e = 0; +my $tok = tok256("/t|$ts|$e"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body: 1 +--- error_code: 200 + +=== TEST 2.5: Unix timestamp = 0 — rejected by timestamp <= 0 guard +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $tok = tok256("/t|0"); +"GET /t?st=$tok&ts=0" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 2.6: timestamp with non-digit characters — strict digit check rejects it +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = "1234abc"; +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 2.7: timestamp with leading whitespace — rejected +# A leading space cannot be sent raw in an HTTP request line (causes 400). +# Uses nginx set $ts to inject the space-prefixed value directly so the +# module receives " 1234567890"; the digit-only scan rejects it because +# space (0x20) is not a decimal digit. +--- config + location /t { + set $ts " 1234567890"; + secure_link_hmac "$arg_st,$ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $tok = tok256("/t| 1234567890"); +"GET /t?st=$tok" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 2.8: negative expiry string — rejected +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $e = "-1"; +my $tok = tok256("/t|$ts|$e"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 2.9: no timestamp comma in directive value — treated as token-only +--- config + location /t { + secure_link_hmac "$arg_st"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +"GET /t?st=" . tok256("/t") +--- response_body: 1 +--- error_code: 200 + +=== TEST 2.10: empty timestamp field (double comma) — rejected +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $e = 3600; +my $tok = tok256("/t||$e"); +"GET /t?st=$tok&ts=&e=$e" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 2.11: Unix timestamp far in the future — valid (no expiry set) +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = TS_FIXED; +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body: 1 +--- error_code: 200 + +=== TEST 3.1: ISO 8601 +00:00 offset — valid, no expiry +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = iso_offset(); +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body: 1 +--- error_code: 200 + +=== TEST 3.2: ISO 8601 positive UTC offset +05:30 +# The module subtracts 5h30m from the supplied local time to obtain UTC. +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $utc_sec = time(); +my $offset_min = 330; +my $local_sec = $utc_sec + $offset_min * 60; +my $ts = POSIX::strftime('%Y-%m-%dT%H:%M:%S+05:30', gmtime($local_sec)); +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body: 1 +--- error_code: 200 + +=== TEST 3.3: ISO 8601 negative UTC offset -08:00 +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $utc_sec = time(); +my $offset_min = -480; +my $local_sec = $utc_sec + $offset_min * 60; +my $ts = POSIX::strftime('%Y-%m-%dT%H:%M:%S-08:00', gmtime($local_sec)); +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body: 1 +--- error_code: 200 + +=== TEST 3.4: ISO 8601 with expiry — not yet expired +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = iso_offset(); +my $e = 3600; +my $tok = tok256("/t|$ts|$e"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body: 1 +--- error_code: 200 + +=== TEST 3.5: ISO 8601 — valid HMAC, link expired +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = iso_past(); +my $e = 60; +my $tok = tok256("/t|$ts|$e"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body: 0 +--- error_code: 200 + +=== TEST 3.6: ISO 8601 'Z' suffix — valid, no expiry +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = iso_z(); +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body: 1 +--- error_code: 200 + +=== TEST 3.7: ISO 8601 'Z' suffix — with expiry, not yet expired +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = iso_z(); +my $e = 3600; +my $tok = tok256("/t|$ts|$e"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body: 1 +--- error_code: 200 + +=== TEST 3.8: ISO 8601 — date only, no time component — rejected +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = "2025-06-01"; +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 3.9: ISO 8601 — month 13 (out of range) — rejected +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = "2025-13-01T00:00:00+00:00"; +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 3.10: ISO 8601 — day 32 (out of range) — rejected +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = "2025-06-32T00:00:00+00:00"; +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 3.11: ISO 8601 — hour 25 (out of range) — rejected +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = "2025-06-01T25:00:00+00:00"; +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 3.12: ISO 8601 — year before Unix epoch (1969) — rejected +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = "1969-12-31T23:59:59+00:00"; +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 3.13: ISO 8601 — UTC offset hours out of range (+24:00) — rejected +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = "2025-06-01T00:00:00+24:00"; +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 4.1: RFC 7231 — valid, no expiry +# Uses nginx set $ts to inject the RFC 7231 timestamp directly, bypassing +# query-parameter encoding issues (spaces in RFC 7231 cannot be sent raw +# in an HTTP request line, and nginx $arg_* returns raw percent-encoded bytes). +--- config + location /t { + set $ts "Fri, 20 Nov 2286 17:46:39 GMT"; + secure_link_hmac "$arg_st,$ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $tok = tok256("/t|" . TS_FIXED_RFC7231); +"GET /t?st=$tok" +--- response_body: 1 +--- error_code: 200 + +=== TEST 4.2: RFC 7231 — with expiry, internal comma handled correctly +# Uses nginx set $ts to inject the RFC 7231 timestamp, and set $e for the expiry. +# The embedded comma in "Fri, 20 Nov ..." must not be confused with the +# comma field separator in secure_link_hmac. +--- config + location /t { + set $ts "Fri, 20 Nov 2286 17:46:39 GMT"; + set $e "3600"; + secure_link_hmac "$arg_st,$ts,$e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$ts|$e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $tok = tok256("/t|" . TS_FIXED_RFC7231 . "|3600"); +"GET /t?st=$tok" +--- response_body: 1 +--- error_code: 200 + +=== TEST 4.3: RFC 7231 — valid HMAC, link expired +# Uses a fixed past RFC 7231 date so the test is always expired regardless of +# when it runs. "Wed, 01 Jan 2020 00:00:00 GMT" is always in the past. +--- config + location /t { + set $ts "Wed, 01 Jan 2020 00:00:00 GMT"; + set $e "60"; + secure_link_hmac "$arg_st,$ts,$e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$ts|$e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = "Wed, 01 Jan 2020 00:00:00 GMT"; +my $e = 60; +my $tok = tok256("/t|$ts|$e"); +"GET /t?st=$tok" +--- response_body: 0 +--- error_code: 200 + +=== TEST 4.4: RFC 7231 — unrecognised month abbreviation — rejected +# Spaces and commas in RFC 7231 dates cannot be sent raw in an HTTP request +# line. Uses nginx set $ts so the module receives the literal date string; +# the month name "Foo" is not in the lookup table so parsing fails. +--- config + location /t { + set $ts "Mon, 01 Foo 2025 00:00:00 GMT"; + secure_link_hmac "$arg_st,$ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $tok = tok256("/t|Mon, 01 Foo 2025 00:00:00 GMT"); +"GET /t?st=$tok" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 4.5: RFC 7231 — 'UTC' suffix instead of 'GMT' — rejected +# Uses nginx set $ts; the sscanf pattern requires a literal "GMT" suffix, +# so "UTC" does not match and parsing fails. +--- config + location /t { + set $ts "Mon, 01 Jan 2025 00:00:00 UTC"; + secure_link_hmac "$arg_st,$ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $tok = tok256("/t|Mon, 01 Jan 2025 00:00:00 UTC"); +"GET /t?st=$tok" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 4.6: RFC 7231 — month name is case-insensitive +# Uses nginx set $ts with TS_FIXED_RFC7231 uppercased. The weekday and month +# abbreviations are both uppercased (FRI -> FRI, NOV -> NOV); the module's +# ngx_strncasecmp lookup must accept "NOV" as November. +--- config + location /t { + set $ts "FRI, 20 NOV 2286 17:46:39 GMT"; + secure_link_hmac "$arg_st,$ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +(my $ts = TS_FIXED_RFC7231) =~ s/\b([A-Z][a-z]{2})\b/uc($1)/ge; +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok" +--- response_body: 1 +--- error_code: 200 + +=== TEST 4.7: RFC 7231 — pre-computed fixed constant (TS_FIXED_RFC7231) +# Uses nginx set to inject TS_FIXED_RFC7231 directly, eliminating query-parameter +# encoding ambiguity. +--- config + location /t { + set $ts "Fri, 20 Nov 2286 17:46:39 GMT"; + secure_link_hmac "$arg_st,$ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $tok = tok256("/t|" . TS_FIXED_RFC7231); +"GET /t?st=$tok" +--- response_body: 1 +--- error_code: 200 + +=== TEST 4.8: Common Log Format timestamp — rejected +# CLF format "01/Jan/2025:00:00:00 +0000" contains a space before +0000 which +# cannot be sent raw in an HTTP request line. Uses nginx set $ts to deliver +# the value directly; the '/' makes it fail all four timestamp parsers. +--- config + location /t { + set $ts "01/Jan/2025:00:00:00 +0000"; + secure_link_hmac "$arg_st,$ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $tok = tok256("/t|01/Jan/2025:00:00:00 +0000"); +"GET /t?st=$tok" +--- response_body eval +"" +--- error_code: 200 + diff --git a/t/03_algorithms.t b/t/03_algorithms.t new file mode 100644 index 0000000..2952707 --- /dev/null +++ b/t/03_algorithms.t @@ -0,0 +1,139 @@ +#!/usr/bin/perl +# 03_algorithms.t +# +# HMAC algorithm variants — SHA-1, SHA-256, SHA-512, MD5, unknown algorithm +# +# Prerequisites: +# cpanm Test::Nginx Digest::SHA Digest::HMAC_MD5 URI::Escape +# +# Run: +# prove -I t/lib -v t/03_algorithms.t + +use strict; +use warnings; + +use Test::Nginx::Socket; +use lib 't/lib'; +use HmacSecureLink qw(:all); +use POSIX qw(strftime); + +# --------------------------------------------------------------------------- +# Make all HmacSecureLink helpers available inside --- request eval and +# --- response_body eval blocks. Test::Base evaluates those blocks in the +# Test::Base::Filter package, so functions imported into main:: are invisible +# there. Installing aliases into Test::Base::Filter:: solves this for both +# plain subs and constant subs (which are implemented as subs with no args). +# --------------------------------------------------------------------------- +BEGIN { + no strict 'refs'; + for my $fn (qw( + tok256 tok512 tok1 tokmd5 b64url + unix_now unix_past unix_far + iso_offset iso_z iso_past + rfc7231_now rfc7231_past + uri_escape + )) { + *{"Test::Base::Filter::$fn"} = \&{"HmacSecureLink::$fn"}; + } + for my $c (qw( + SECRET SECRET2 + TS_FIXED TS_FIXED_ISO TS_FIXED_Z TS_FIXED_RFC7231 + )) { + *{"Test::Base::Filter::$c"} = \&{"HmacSecureLink::$c"}; + } +} + +# 5 tests, 10 assertions total +our $repeat = repeat_each(); +plan tests => 10 * $repeat; + +no_shuffle(); +run_tests(); + +__DATA__ +# =========================================================================== +# CATEGORY 5 — Algorithm variants +# =========================================================================== + +=== TEST 5.1: SHA-1 algorithm +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha1; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $tok = tok1("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body: 1 +--- error_code: 200 + +=== TEST 5.2: SHA-512 algorithm +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha512; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $tok = tok512("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body: 1 +--- error_code: 200 + +=== TEST 5.3: MD5 algorithm +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm md5; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $tok = tokmd5("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body: 1 +--- error_code: 200 + +=== TEST 5.4: SHA-256 token presented to a SHA-512 location — rejected +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha512; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 5.5: unknown algorithm — variable empty, link invalid +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha999_does_not_exist; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body eval +"" +--- error_code: 200 + diff --git a/t/04_variables.t b/t/04_variables.t new file mode 100644 index 0000000..59251e9 --- /dev/null +++ b/t/04_variables.t @@ -0,0 +1,228 @@ +#!/usr/bin/perl +# 04_variables.t +# +# Embedded variables — $secure_link_hmac_expires and $secure_link_hmac_token +# +# Prerequisites: +# cpanm Test::Nginx Digest::SHA Digest::HMAC_MD5 URI::Escape +# +# Run: +# prove -I t/lib -v t/04_variables.t + +use strict; +use warnings; + +use Test::Nginx::Socket; +use lib 't/lib'; +use HmacSecureLink qw(:all); +use POSIX qw(strftime); + +# --------------------------------------------------------------------------- +# Make all HmacSecureLink helpers available inside --- request eval and +# --- response_body eval blocks. Test::Base evaluates those blocks in the +# Test::Base::Filter package, so functions imported into main:: are invisible +# there. Installing aliases into Test::Base::Filter:: solves this for both +# plain subs and constant subs (which are implemented as subs with no args). +# --------------------------------------------------------------------------- +BEGIN { + no strict 'refs'; + for my $fn (qw( + tok256 tok512 tok1 tokmd5 b64url + unix_now unix_past unix_far + iso_offset iso_z iso_past + rfc7231_now rfc7231_past + uri_escape + )) { + *{"Test::Base::Filter::$fn"} = \&{"HmacSecureLink::$fn"}; + } + for my $c (qw( + SECRET SECRET2 + TS_FIXED TS_FIXED_ISO TS_FIXED_Z TS_FIXED_RFC7231 + )) { + *{"Test::Base::Filter::$c"} = \&{"HmacSecureLink::$c"}; + } +} + +# 10 tests, 20 assertions total +our $repeat = repeat_each(); +plan tests => 20 * $repeat; + +no_shuffle(); +run_tests(); + +__DATA__ +# =========================================================================== +# CATEGORY 6 — $secure_link_hmac_expires variable +# =========================================================================== + +=== TEST 6.1: expires variable reflects the value supplied in the request +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + if ($secure_link_hmac != "1") { return 403; } + return 200 "$secure_link_hmac_expires"; + } +--- request eval +my $ts = unix_now(); +my $e = 7200; +my $tok = tok256("/t|$ts|$e"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body: 7200 +--- error_code: 200 + +=== TEST 6.2: expires variable is unset when no expiry field present +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "expires=[$secure_link_hmac_expires]"; + } +--- request eval +my $ts = unix_now(); +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body: expires=[] +--- error_code: 200 + +=== TEST 6.3: expires variable correct for a very large expiry value +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + if ($secure_link_hmac != "1") { return 403; } + return 200 "$secure_link_hmac_expires"; + } +--- request eval +my $ts = unix_now(); +my $e = 315360000; +my $tok = tok256("/t|$ts|$e"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body: 315360000 +--- error_code: 200 + +=== TEST 6.4: expires variable — ISO 8601 timestamp +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + if ($secure_link_hmac != "1") { return 403; } + return 200 "$secure_link_hmac_expires"; + } +--- request eval +my $ts = iso_offset(); +my $e = 900; +my $tok = tok256("/t|$ts|$e"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body: 900 +--- error_code: 200 + +=== TEST 6.5: expires variable — RFC 7231 with expiry — internal comma safe +# Uses nginx set $ts with TS_FIXED_RFC7231 to avoid RFC 7231's embedded +# spaces and comma from breaking the HTTP request line when sent as $arg_ts. +# Verifies that the embedded comma in "Fri, 20 Nov ..." does not corrupt +# the expires substring stored in the module's request context. +--- config + location /t { + set $ts "Fri, 20 Nov 2286 17:46:39 GMT"; + set $e "1800"; + secure_link_hmac "$arg_st,$ts,$e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$ts|$e"; + secure_link_hmac_algorithm sha256; + if ($secure_link_hmac != "1") { return 403; } + return 200 "$secure_link_hmac_expires"; + } +--- request eval +my $tok = tok256("/t|" . TS_FIXED_RFC7231 . "|1800"); +"GET /t?st=$tok" +--- response_body: 1800 +--- error_code: 200 + +=== TEST 7.1: generated SHA-256 token matches independently computed HMAC +# Uses TS_FIXED so that both the request URL and the expected response body +# reference the same timestamp constant, eliminating any clock-second race. +--- config + location /t { + set $ts $arg_ts; + set $e $arg_e; + secure_link_hmac_message "$uri|$ts|$e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac_token"; + } +--- request eval +"GET /t?ts=" . TS_FIXED . "&e=3600" +--- response_body eval +tok256("/t|" . TS_FIXED . "|3600") +--- error_code: 200 + +=== TEST 7.2: generated SHA-512 token matches independently computed HMAC +--- config + location /t { + set $ts $arg_ts; + secure_link_hmac_message "$uri|$ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_algorithm sha512; + return 200 "$secure_link_hmac_token"; + } +--- request eval +"GET /t?ts=" . TS_FIXED +--- response_body eval +tok512("/t|" . TS_FIXED) +--- error_code: 200 + +=== TEST 7.3: generated MD5 token matches independently computed HMAC +--- config + location /t { + set $ts $arg_ts; + secure_link_hmac_message "$uri|$ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_algorithm md5; + return 200 "$secure_link_hmac_token"; + } +--- request eval +"GET /t?ts=" . TS_FIXED +--- response_body eval +tokmd5("/t|" . TS_FIXED) +--- error_code: 200 + +=== TEST 7.4: $secure_link_hmac_token output is valid base64url without padding +# SHA-256 produces 32 bytes = 43 base64url characters (no trailing padding). +--- config + location /t { + set $ts $arg_ts; + set $e $arg_e; + secure_link_hmac_message "$uri|$ts|$e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac_token"; + } +--- request eval +"GET /t?ts=" . unix_now() . "&e=3600" +--- response_body_like: ^[A-Za-z0-9_-]{43}$ +--- error_code: 200 + +=== TEST 7.5: SHA-512 token output is 86 base64url characters +# SHA-512 produces 64 bytes = 86 base64url characters (no trailing padding). +--- config + location /t { + set $ts $arg_ts; + secure_link_hmac_message "$uri|$ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_algorithm sha512; + return 200 "$secure_link_hmac_token"; + } +--- request eval +"GET /t?ts=" . unix_now() +--- response_body_like: ^[A-Za-z0-9_-]{86}$ +--- error_code: 200 + diff --git a/t/05_integration.t b/t/05_integration.t new file mode 100644 index 0000000..1686725 --- /dev/null +++ b/t/05_integration.t @@ -0,0 +1,286 @@ +#!/usr/bin/perl +# 05_integration.t +# +# Separators, configuration edge cases, and real-world access-control patterns +# +# Prerequisites: +# cpanm Test::Nginx Digest::SHA Digest::HMAC_MD5 URI::Escape +# +# Run: +# prove -I t/lib -v t/05_integration.t + +use strict; +use warnings; + +use Test::Nginx::Socket; +use lib 't/lib'; +use HmacSecureLink qw(:all); +use POSIX qw(strftime); + +# --------------------------------------------------------------------------- +# Make all HmacSecureLink helpers available inside --- request eval and +# --- response_body eval blocks. Test::Base evaluates those blocks in the +# Test::Base::Filter package, so functions imported into main:: are invisible +# there. Installing aliases into Test::Base::Filter:: solves this for both +# plain subs and constant subs (which are implemented as subs with no args). +# --------------------------------------------------------------------------- +BEGIN { + no strict 'refs'; + for my $fn (qw( + tok256 tok512 tok1 tokmd5 b64url + unix_now unix_past unix_far + iso_offset iso_z iso_past + rfc7231_now rfc7231_past + uri_escape + )) { + *{"Test::Base::Filter::$fn"} = \&{"HmacSecureLink::$fn"}; + } + for my $c (qw( + SECRET SECRET2 + TS_FIXED TS_FIXED_ISO TS_FIXED_Z TS_FIXED_RFC7231 + )) { + *{"Test::Base::Filter::$c"} = \&{"HmacSecureLink::$c"}; + } +} + +# 14 tests, 25 assertions total +our $repeat = repeat_each(); +plan tests => 25 * $repeat; + +no_shuffle(); +run_tests(); + +__DATA__ +# =========================================================================== +# CATEGORY 8 — Separator choices in secure_link_hmac_message +# =========================================================================== + +=== TEST 8.1: pipe (|) separator — recommended convention +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $e = 3600; +my $tok = tok256("/t|$ts|$e"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body: 1 +--- error_code: 200 + +=== TEST 8.2: colon (:) separator +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri:$arg_ts:$arg_e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $e = 3600; +my $tok = tok256("/t:$ts:$e"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body: 1 +--- error_code: 200 + +=== TEST 8.3: slash (/) separator +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri/$arg_ts/$arg_e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $e = 3600; +my $tok = tok256("/t/$ts/$e"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body: 1 +--- error_code: 200 + +=== TEST 8.4: no separator — fields concatenated directly +# Valid but potentially ambiguous; documents the behaviour explicitly. +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri$arg_ts$arg_e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $e = 3600; +my $tok = tok256("/t${ts}${e}"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body: 1 +--- error_code: 200 + +=== TEST 8.5: pipe on server, colon used by client — rejected +# Demonstrates that any separator mismatch invalidates the HMAC. +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $e = 3600; +my $tok = tok256("/t:$ts:$e"); +"GET /t?st=$tok&ts=$ts&e=$e" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 9.1: no directives configured — not_found +--- config + location /t { + return 200 "$secure_link_hmac"; + } +--- request +GET /t?st=anything&ts=1234567890&e=3600 +--- response_body eval +"" +--- error_code: 200 + +=== TEST 9.2: secure_link_hmac_message missing — not_found +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 9.3: secure_link_hmac_secret missing — not_found +--- config + location /t { + secure_link_hmac "$arg_st,$arg_ts"; + secure_link_hmac_message "$uri|$arg_ts"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +my $ts = unix_now(); +my $tok = tok256("/t|$ts"); +"GET /t?st=$tok&ts=$ts" +--- response_body eval +"" +--- error_code: 200 + +=== TEST 9.4: literal string message (no variables) — valid +--- config + location /t { + secure_link_hmac "$arg_st"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "static-message"; + secure_link_hmac_algorithm sha256; + return 200 "$secure_link_hmac"; + } +--- request eval +"GET /t?st=" . tok256("static-message") +--- response_body: 1 +--- error_code: 200 + +=== TEST 10.1: valid token — returns 200 with if/return guard +--- config + location /protected/ { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + if ($secure_link_hmac != "1") { return 403; } + return 200 "OK"; + } +--- request eval +my $ts = unix_now(); +my $e = 3600; +my $tok = tok256("/protected/file.txt|$ts|$e"); +"GET /protected/file.txt?st=$tok&ts=$ts&e=$e" +--- response_body: OK +--- error_code: 200 + +=== TEST 10.2: wrong token — returns 403 +--- config + location /protected/ { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + if ($secure_link_hmac != "1") { return 403; } + return 200 "OK"; + } +--- request eval +my $ts = unix_now(); +my $e = 3600; +my $tok = tok256("/protected/file.txt|$ts|$e", SECRET2); +"GET /protected/file.txt?st=$tok&ts=$ts&e=$e" +--- error_code: 403 + +=== TEST 10.3: expired token — returns 403 (value "0" != "1") +--- config + location /protected/ { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + if ($secure_link_hmac != "1") { return 403; } + return 200 "OK"; + } +--- request eval +my $ts = unix_past(); +my $e = 60; +my $tok = tok256("/protected/file.txt|$ts|$e"); +"GET /protected/file.txt?st=$tok&ts=$ts&e=$e" +--- error_code: 403 + +=== TEST 10.4: no parameters — returns 403 +--- config + location /protected/ { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + if ($secure_link_hmac != "1") { return 403; } + return 200 "OK"; + } +--- request +GET /protected/file.txt +--- error_code: 403 + +=== TEST 10.5: distinguish expired (410) from invalid (403) separately +--- config + location /protected/ { + secure_link_hmac "$arg_st,$arg_ts,$arg_e"; + secure_link_hmac_secret "testsecret"; + secure_link_hmac_message "$uri|$arg_ts|$arg_e"; + secure_link_hmac_algorithm sha256; + if ($secure_link_hmac = "0") { return 410 "Gone"; } + if ($secure_link_hmac != "1") { return 403 "Forbidden"; } + return 200 "OK"; + } +--- request eval +my $ts = unix_past(); +my $e = 60; +my $tok = tok256("/protected/file.txt|$ts|$e"); +"GET /protected/file.txt?st=$tok&ts=$ts&e=$e" +--- response_body: Gone +--- error_code: 410 diff --git a/t/lib/HmacSecureLink.pm b/t/lib/HmacSecureLink.pm new file mode 100644 index 0000000..33bc7d5 --- /dev/null +++ b/t/lib/HmacSecureLink.pm @@ -0,0 +1,174 @@ +package HmacSecureLink; +# t/lib/HmacSecureLink.pm +# +# Shared helpers used by all test files in t/. +# +# Import everything at once: +# use lib 't/lib'; +# use HmacSecureLink qw(:all); +# +# Or import selectively: +# use HmacSecureLink qw(tok256 b64url unix_now iso_offset rfc7231_now); + +use strict; +use warnings; +use Exporter 'import'; + +use Digest::SHA qw(hmac_sha256 hmac_sha512 hmac_sha1); +use Digest::HMAC_MD5 qw(hmac_md5); +use MIME::Base64 qw(encode_base64); +use URI::Escape qw(uri_escape); +use POSIX qw(strftime); + +our @EXPORT_OK = qw( + SECRET SECRET2 + TS_FIXED TS_FIXED_ISO TS_FIXED_Z TS_FIXED_RFC7231 + b64url + tok256 tok512 tok1 tokmd5 + unix_now unix_past unix_far + iso_offset iso_z iso_past + rfc7231_now rfc7231_past + uri_escape +); + +our %EXPORT_TAGS = (all => \@EXPORT_OK); + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +# Shared HMAC secrets. +use constant SECRET => 'testsecret'; +use constant SECRET2 => 'anothersecret'; + +# A fixed Unix timestamp far in the future (year 2286) used in tests that +# need the request URL and the expected-response computation to agree on the +# same timestamp. Because it is a compile-time constant, both the +# "--- request eval" and "--- response_body eval" blocks see the same value +# regardless of when they execute, eliminating the race condition that would +# arise from two independent time() calls straddling a second boundary. +use constant TS_FIXED => 9_999_999_999; + +# The same point in time expressed in each timestamp format accepted by the +# module. Pre-computed so test blocks can reference them without repeating +# the strftime calls. +use constant TS_FIXED_ISO => '2286-11-20T17:46:39+00:00'; +use constant TS_FIXED_Z => '2286-11-20T17:46:39Z'; +use constant TS_FIXED_RFC7231 => 'Fri, 20 Nov 2286 17:46:39 GMT'; + +# --------------------------------------------------------------------------- +# Base64url helpers +# --------------------------------------------------------------------------- + +# b64url($raw_bytes) — encode raw bytes as base64url without padding. +sub b64url { + my $raw = shift; + my $b64 = encode_base64($raw, ''); + $b64 =~ tr|+/|-_|; + $b64 =~ s/=+$//; + return $b64; +} + +# --------------------------------------------------------------------------- +# Token generators +# tok256($message [, $secret]) — HMAC-SHA-256, base64url, no padding. +# --------------------------------------------------------------------------- + +sub tok256 { b64url(hmac_sha256($_[0], $_[1] // SECRET)) } +sub tok512 { b64url(hmac_sha512($_[0], $_[1] // SECRET)) } +sub tok1 { b64url(hmac_sha1 ($_[0], $_[1] // SECRET)) } +sub tokmd5 { b64url(hmac_md5 ($_[0], $_[1] // SECRET)) } + +# --------------------------------------------------------------------------- +# Live timestamp generators (return the current wall-clock time). +# +# Use these for tests whose only job is to check the module's response +# variable values ("1" / "0" / not-found) and where both sides of the check +# use the URL parameter value — i.e. the Perl test code does NOT need to +# independently re-derive the timestamp to compare with. +# +# Do NOT use live timestamps in response_body eval blocks that must +# reproduce the same token the request carried; use TS_FIXED instead. +# --------------------------------------------------------------------------- + +sub unix_now { time() } +sub unix_past { time() - 7200 } # 2 h ago — for expired-link tests +sub unix_far { time() + 86400 * 3650 } # ~10 years ahead — quasi-permanent + +# ISO 8601 with numeric UTC offset (the format emitted by $time_iso8601) +sub iso_offset { strftime('%Y-%m-%dT%H:%M:%S+00:00', gmtime(time())) } + +# ISO 8601 UTC Z suffix +sub iso_z { strftime('%Y-%m-%dT%H:%M:%SZ', gmtime(time())) } + +# ISO 8601, 2 hours ago +sub iso_past { strftime('%Y-%m-%dT%H:%M:%S+00:00', gmtime(time() - 7200)) } + +# RFC 7231 / IMF-fixdate (always UTC, always "GMT" suffix) +sub rfc7231_now { strftime('%a, %d %b %Y %H:%M:%S GMT', gmtime(time())) } +sub rfc7231_past { strftime('%a, %d %b %Y %H:%M:%S GMT', gmtime(time() - 7200)) } + +1; +__END__ + +=head1 NAME + +HmacSecureLink — shared test helpers for ngx_http_hmac_secure_link_module + +=head1 SYNOPSIS + + use lib 't/lib'; + use HmacSecureLink qw(:all); + + my $tok = tok256("/protected/file.txt|" . TS_FIXED . "|3600"); + +=head1 CONSTANTS + +=over 4 + +=item C, C + +Default and alternate HMAC secrets used throughout the test suite. + +=item C + +Unix timestamp 9,999,999,999 (2286-11-20). Use this — not C — +whenever a "--- request eval" and a "--- response_body eval" block both need +the same numeric timestamp. Two calls to C in separate eval blocks +can straddle a second boundary and produce different values, causing a +spurious test failure. + +=item C, C, C + +The same point in time as C pre-formatted in each timestamp +dialect accepted by the module. + +=back + +=head1 FUNCTIONS + +=over 4 + +=item C + +Encode raw bytes as base64url without padding. + +=item C, C, C, C + +Compute HMAC using the named algorithm and return a base64url-encoded token. +Defaults to C when no secret is supplied. + +=item C, C, C + +Current time, 2 h ago, and ~10 years from now, as Unix timestamps. + +=item C, C, C + +Current time formatted as ISO 8601 with C<+00:00> offset, with C suffix, +and 2 h ago with C<+00:00> offset respectively. + +=item C, C + +Current time and 2 h ago formatted as RFC 7231 IMF-fixdate. + +=back