pax_global_header00006660000000000000000000000064151634075260014523gustar00rootroot0000000000000052 comment=8cc23dd93bf6f162403019ddcc1ebfcce7014abb nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/000077500000000000000000000000001516340752600235615ustar00rootroot00000000000000nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/.gitattributes000066400000000000000000000000601516340752600264500ustar00rootroot00000000000000*.t linguist-language=Perl *.t linguist-vendorednginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/.github/000077500000000000000000000000001516340752600251215ustar00rootroot00000000000000nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/.github/workflows/000077500000000000000000000000001516340752600271565ustar00rootroot00000000000000nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/.github/workflows/ci.yml000066400000000000000000000324471516340752600303060ustar00rootroot00000000000000name: 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 nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/CHANGELOG.md000066400000000000000000000152131516340752600253740ustar00rootroot00000000000000# 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. nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/LICENSE000066400000000000000000000022721516340752600245710ustar00rootroot00000000000000This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/Makefile000066400000000000000000000065431516340752600252310ustar00rootroot00000000000000# 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)" nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/README.md000066400000000000000000000441111516340752600250410ustar00rootroot00000000000000Nginx HMAC Secure Link Module ============================= Description ----------- 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. **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`. **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`. 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 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 '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 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][gps]. [gps]: https://apt-nginx-extras.getpagespeed.com/apt-setup/ Installation from Source ------------------------ 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 module** (`.so` loaded with `load_module`): ./configure --with-compat \ --add-dynamic-module=/absolute/path/to/ngx_http_hmac_secure_link_module Then build: make make install 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)). ### `secure_link_hmac_message` **Context:** `http`, `server`, `location` 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. **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/ { # The three comma-separated fields: token, timestamp, expires (seconds) secure_link_hmac "$arg_st,$arg_ts,$arg_e"; # HMAC secret key secure_link_hmac_secret "my_secret_key"; # The message that was signed: URI + timestamp + expiry secure_link_hmac_message "$uri|$arg_ts|$arg_e"; # Hash algorithm secure_link_hmac_algorithm sha256; # 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 403; } rewrite ^/files/(.*)$ /files/$1 break; } ``` Client-Side Examples -------------------- ### Perl — ISO 8601 timestamp ```perl perl_set $secure_token ' sub { use Digest::SHA qw(hmac_sha256_base64); use POSIX qw(strftime); my $r = shift; my $key = "my_very_secret_key"; my $expire = 60; my $now = time(); # 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 $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"; } '; ``` ### PHP — Unix timestamp ```php format(DateTimeInterface::RFC3339); // "2025-06-01T14:30:00+00:00" $uri = '/files/top_secret.pdf'; $message = "{$uri}|{$timestamp}|{$expire}"; $token = base64_encode(hash_hmac($algo, $message, $secret, true)); $token = strtr($token, '+/', '-_'); $token = rtrim($token, '='); $url = "https://example.com{$uri}?st={$token}&ts=" . urlencode($timestamp) . "&e={$expire}"; ``` ### PHP — RFC 7231 / IMF-fixdate 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. nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/config000066400000000000000000000007121516340752600247510ustar00rootroot00000000000000ngx_addon_name=ngx_http_hmac_secure_link_module if test -n "$ngx_module_link"; then ngx_module_type=HTTP ngx_module_name=ngx_http_hmac_secure_link_module ngx_module_srcs="$ngx_addon_dir/ngx_http_hmac_secure_link_module.c" ngx_module_libs="-lcrypto" . auto/module else HTTP_MODULES="$HTTP_MODULES ngx_http_hmac_secure_link_module" NGX_ADDON_SRCS="$NGX_ADDON_SRCS $ngx_addon_dir/ngx_http_hmac_secure_link_module.c" CORE_LIBS="$CORE_LIBS -lcrypto" fi nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/ngx_http_hmac_secure_link_module.c000066400000000000000000001002461516340752600325030ustar00rootroot00000000000000/* * 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 /* ------------------------------------------------------------------------- * 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; ngx_http_complex_value_t *hmac_secret; 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, ngx_http_variable_value_t *v, uintptr_t data); static ngx_int_t ngx_http_secure_link_token_variable(ngx_http_request_t *r, ngx_http_variable_value_t *v, uintptr_t data); static void *ngx_http_secure_link_create_conf(ngx_conf_t *cf); static char *ngx_http_secure_link_merge_conf(ngx_conf_t *cf, void *parent, void *child); 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"), NGX_HTTP_MAIN_CONF|NGX_HTTP_SRV_CONF|NGX_HTTP_LOC_CONF|NGX_CONF_TAKE1, ngx_http_set_complex_value_slot, NGX_HTTP_LOC_CONF_OFFSET, offsetof(ngx_http_secure_link_conf_t, hmac_variable), NULL }, { ngx_string("secure_link_hmac_message"), NGX_HTTP_MAIN_CONF|NGX_HTTP_SRV_CONF|NGX_HTTP_LOC_CONF|NGX_CONF_TAKE1, ngx_http_set_complex_value_slot, NGX_HTTP_LOC_CONF_OFFSET, offsetof(ngx_http_secure_link_conf_t, hmac_message), NULL }, { ngx_string("secure_link_hmac_secret"), NGX_HTTP_MAIN_CONF|NGX_HTTP_SRV_CONF|NGX_HTTP_LOC_CONF|NGX_CONF_TAKE1, ngx_http_set_complex_value_slot, NGX_HTTP_LOC_CONF_OFFSET, offsetof(ngx_http_secure_link_conf_t, hmac_secret), NULL }, { ngx_string("secure_link_hmac_algorithm"), NGX_HTTP_MAIN_CONF|NGX_HTTP_SRV_CONF|NGX_HTTP_LOC_CONF|NGX_CONF_TAKE1, ngx_conf_set_str_slot, NGX_HTTP_LOC_CONF_OFFSET, offsetof(ngx_http_secure_link_conf_t, hmac_algorithm), NULL }, ngx_null_command }; static ngx_http_module_t ngx_http_secure_link_module_ctx = { ngx_http_secure_link_add_variables, /* preconfiguration */ NULL, /* postconfiguration */ NULL, /* create main configuration */ NULL, /* init main configuration */ NULL, /* create server configuration */ NULL, /* merge server configuration */ ngx_http_secure_link_create_conf, /* create location configuration */ ngx_http_secure_link_merge_conf /* merge location configuration */ }; ngx_module_t ngx_http_hmac_secure_link_module = { NGX_MODULE_V1, &ngx_http_secure_link_module_ctx, /* module context */ ngx_http_hmac_secure_link_commands, /* module directives */ NGX_HTTP_MODULE, /* module type */ NULL, /* init master */ NULL, /* init module */ NULL, /* init process */ NULL, /* init thread */ NULL, /* exit thread */ NULL, /* exit process */ NULL, /* exit master */ NGX_MODULE_V1_PADDING }; 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 }, { ngx_string("secure_link_hmac_expires"), NULL, ngx_http_secure_link_expires_variable, 0, NGX_HTTP_VAR_CHANGEABLE, 0 }, { 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 } }; /* ========================================================================= * 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) { ngx_http_secure_link_ctx_t *ctx; ngx_http_secure_link_conf_t *conf; const EVP_MD *evp_md; 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]; 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) { goto not_found; } if (ngx_http_complex_value(r, conf->hmac_variable, &value) != NGX_OK) { return NGX_ERROR; } ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, "secure link variable: \"%V\"", &value); last = value.data + value.len; timestamp = 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 (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); 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; } ts_end = ngx_strlchr(sep_search, last, ','); if (ts_end == NULL) { ts_end = last; } } 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; } /* ------------------------------------------------------------------ * 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); if (expires < 0) { goto not_found; } ctx = ngx_pcalloc(r->pool, sizeof(ngx_http_secure_link_ctx_t)); if (ctx == NULL) { return NGX_ERROR; } ngx_http_set_ctx(r, ctx, ngx_http_hmac_secure_link_module); ctx->expires.data = exp_start; ctx->expires.len = (size_t)(last - exp_start); } } /* ------------------------------------------------------------------ * Resolve and validate the digest algorithm. * ------------------------------------------------------------------ */ evp_md = EVP_get_digestbyname((const char *) conf->hmac_algorithm.data); if (evp_md == NULL) { 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; } /* ------------------------------------------------------------------ * Decode and validate the base64url-encoded HMAC token. * ------------------------------------------------------------------ */ hash.len = (size_t) md_size; hash.data = hash_buf; if (value.len > (size_t)(ngx_base64_encoded_length((size_t) md_size) + 2)) { goto not_found; } if (ngx_decode_base64url(&hash, &value) != NGX_OK) { goto not_found; } 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; } ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, "secure link message: \"%V\"", &value); if (ngx_http_complex_value(r, conf->hmac_secret, &key) != NGX_OK) { return NGX_ERROR; } 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; } /* * 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; } /* ------------------------------------------------------------------ * 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; 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) { ngx_http_secure_link_conf_t *conf; u_char *p; 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); if (conf->hmac_message == NULL || conf->hmac_secret == NULL) { goto not_found; } if (ngx_http_complex_value(r, conf->hmac_message, &value) != NGX_OK) { return NGX_ERROR; } ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, "secure link string to sign: \"%V\"", &value); if (ngx_http_complex_value(r, conf->hmac_secret, &key) != NGX_OK) { return NGX_ERROR; } evp_md = EVP_get_digestbyname((const char *) conf->hmac_algorithm.data); if (evp_md == NULL) { 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; token.data = p; 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->no_cacheable = 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) { ngx_http_secure_link_ctx_t *ctx; ctx = ngx_http_get_module_ctx(r, ngx_http_hmac_secure_link_module); if (ctx) { v->data = ctx->expires.data; v->len = ctx->expires.len; v->valid = 1; v->no_cacheable = 0; v->not_found = 0; } else { v->not_found = 1; } return NGX_OK; } /* ========================================================================= * Configuration lifecycle * ========================================================================= */ static void * ngx_http_secure_link_create_conf(ngx_conf_t *cf) { ngx_http_secure_link_conf_t *conf; conf = ngx_pcalloc(cf->pool, sizeof(ngx_http_secure_link_conf_t)); if (conf == NULL) { return 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; } static char * 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); if (conf->hmac_variable == NULL) { conf->hmac_variable = prev->hmac_variable; } if (conf->hmac_message == NULL) { conf->hmac_message = prev->hmac_message; } if (conf->hmac_secret == NULL) { conf->hmac_secret = prev->hmac_secret; } return NGX_CONF_OK; } static ngx_int_t ngx_http_secure_link_add_variables(ngx_conf_t *cf) { 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; } return NGX_OK; } nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/t/000077500000000000000000000000001516340752600240245ustar00rootroot00000000000000nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/t/01_basic.t000066400000000000000000000110501516340752600255670ustar00rootroot00000000000000#!/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 nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/t/02_timestamps.t000066400000000000000000000456731516340752600267170ustar00rootroot00000000000000#!/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 nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/t/03_algorithms.t000066400000000000000000000075431516340752600266750ustar00rootroot00000000000000#!/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 nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/t/04_variables.t000066400000000000000000000162431516340752600264720ustar00rootroot00000000000000#!/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 nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/t/05_integration.t000066400000000000000000000206001516340752600270360ustar00rootroot00000000000000#!/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 nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/t/lib/000077500000000000000000000000001516340752600245725ustar00rootroot00000000000000nginx-modules-ngx_http_hmac_secure_link_module-8cc23dd/t/lib/HmacSecureLink.pm000066400000000000000000000132451516340752600277720ustar00rootroot00000000000000package 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