Repository navigation
Releases: myguard-labs/nginx-zstd-module
Release list
Security Release
This release fixes three security issues in the dcz (compression dictionary
transport) and dictionary-loading paths. Operators using zstd_dcz_dict_file
or zstd_dict_strict_path should upgrade.
Security
zstd_dict_strict_path trusted intermediate path components and ignored file
ownership. The open used O_NOFOLLOW on the whole path, which the kernel
applies only to the leaf, so every intermediate component was resolved
normally — /srv/current/dict.bin with current a symlink was followed
silently. A local writer able to repoint an intermediate component chose the
bytes a privileged reload snapshotted into every worker, despite strict mode
being on. Separately, the trust check tested only S_IWGRP|S_IWOTH, so a
dictionary owned by an unprivileged account at mode 0644 passed while root read
it. Strict mode now walks the path one component at a time with
openat(O_NOFOLLOW|O_DIRECTORY), requires an absolute path with no . or ..
component, and requires the file to be owned by the config-parsing master's
effective uid or by root. Confined to zstd_dict_strict_path on; the off
default is byte-for-byte unchanged.
A supplied dictionary SHA-256 was trusted verbatim and never verified. The
load-time hashing pass was skipped entirely when the optional hash argument was
present, so zstd_dcz_dict_file new.dict <sha256-of-old.dict> compressed
against new.dict while advertising the old dictionary's hash. Every client
holding the old dictionary matched the advertisement, received a frame built
from a dictionary it did not have, and could not decode the body. The
dictionary is now always hashed from the bytes actually read, and a supplied
literal is compared against it — a mismatch aborts config load.
dcz was offered outside a secure context, and its cross-origin gate was
bypassable. Dictionary compression is now refused unless RFC 9842 section 8
secure-context requirements are met, and duplicate Sec-Fetch-Site or
Available-Dictionary headers fail closed rather than being chained (nginx
chains duplicates for headers absent from its known-header table).
Bugfixes
$zstd_ratiois now exact across the full 64-bit range. The previous form
scaled the input count before dividing, so a large enough response overflowed
the intermediate — forbytes_innearUINT64_MAXit logged0.000where
the true ratio was0.999. Log output only; no response bytes change.- On 32-bit (ILP32) builds with large-file support, a pledged response length
above 4 GiB was narrowed tosize_tbeforeZSTD_compressBound(), so a body
pledged as 4 GiB + 389 bytes was sized for 389 bytes. Length is now checked
for representability at fulloff_twidth before any narrowing. LP64 builds
were never affected. $zstd_bytes_inand$zstd_bytes_outwrapped on ILP32 for responses over
4 GiB. Both counters are nowuint64_t.
Features
zstd_static_dict_bypass onmakes the precompressed static handler stand
aside when a main request carriesAvailable-Dictionaryand explicitly
accepts dcz, letting the filter module negotiate the dictionary response
first. Default off.zstd_dcz_dict_trust_hashes onopts out of verifying a supplied dictionary
hash against the file's bytes, trading that check for config-load time. At
737 dictionary lines,nginx -tdrops from 5.1s to 0.9s. The verify-always
default is unchanged; the stated risk of advertising a stale or mistyped
literal is the operator's to own.
Changes
- A location configuring dcz dictionaries at
zstd_comp_level9 or above, or
withzstd_long on, now emits a config-load warning naming the profile and
the largest configured dictionary. dcz re-references its dictionary with
ZSTD_CCtx_refPrefix()on every request, at a cost set by dictionary size and
level and independent of body size (measured, 1 KB body: an 8 MB dictionary
costs 0.75 ms at level 3 against 35 ms at level 9). Advisory only — no
request-path, wire-format or configuration-acceptance change.
See CHANGES
for the complete list.
Bugfix Release
Audit-hardening release. Static-module security/correctness fixes surfaced by a full /audit-code pass, each shipped as its own reviewed + CI-green PR.
Fixes
- BLOCKER — enum sentinel (#74):
ngx_http_zstd_static[](off/on/always) was missing the terminating{ ngx_null_string, 0 }sentinel, so an invalidzstd_static <value>;walked off the array end at config load — an out-of-bounds read with a wild-pointerngx_strcasecmp(possible crash). Invalid values are now a cleaninvalid valueconfig error. - MAJOR — directio (#75): under
directio, the.zstis openedO_DIRECT; the unaligned 4-byte magic-number probe then failedEINVALon every request, wrongly declining every.zstabove the threshold and spammingcritlogs. The probe is now skipped forO_DIRECTfiles. - MINOR — gzip fallback (#76):
zstd_static onlatchedr->gzip_ok = 0before checking whether the.zstexisted, suppressing the gzip fallback when the.zstwas absent. The acceptance check is now side-effect-free (ngx_http_zstd_accepts()); the latching path is retained only for the on-the-fly filter.
CI / infra
- Add F5 signer key
arutto the source-tarball PGP verification list (#77).
Tests
Regression tests added for each fix (t/01-static.t, t/02-conf-warn.t), all fail-first verified against the unpatched code. Full suite green on nginx 1.31.2 under ASAN/UBSAN, valgrind, and fuzz gates.
Full changelog: 0.90.7...0.90.8
0.90.7
What's Changed
- feat(defaults)!: tune zstd compression defaults by @eilandert in #59
- Add application/wasm and text/wgsl mime types to zstd_types default by @logankaser in #61
New Contributors
- @logankaser made their first contribution in #61
Full Changelog: 0.90.6...0.90.7
0.90.6
What’s Changed
Correctness
- Hardened Accept-Encoding parsing: quoted strings can no longer fabricate a zstd token, and duplicate q parameters are rejected.
CI
- Made clang-tidy use headers generated from a real nginx source and excluded the known nginx Annex-K memset false positive.
- Standardized CI on the latest nginx mainline and added a monthly, manually dispatchable Valgrind Memcheck soak.
Validation
- The parser-hardening revision passed Build & Test, Security Scanners, CodeQL, and scheduled fuzzing.
- Current workflow configuration passed local actionlint, YAML parsing, and shellcheck.
Full Changelog: 0.90.5...0.90.6
0.90.5-test
What's Changed
Correctness / stability / perf
CQ2 enforce libzstd 1.4.0 floor in the feature probe; drop the fictional ZSTD_initCStream_usingCDict fallback note.
ST1 recycle consumed input chain links (ngx_free_chain) — bounds long-lived chunked/SSE worker memory.
ST2 free the CDict if ngx_pool_cleanup_add() fails (reload leak).
P1 capture body length before clear_content_length so ZSTD_CCtx_setPledgedSrcSize() actually runs.
P2 clear flush/sync/last_buf/last_in_chain on recycled output buffers.
C1 gate zstd_target_cblock_size on ZSTD_VERSION_NUMBER >= 10506 (the #ifdef on an enum was always false → permanent no-op).
C3 validate window_log/target_cblock via ZSTD_cParam_getBounds at config load.
C2 drop the premature dict merge; build a per-(level,window_log) CDict so a child level isn't silently ignored. Document CDict parameter precedence.
C5 only warn about gzip_vary for zstd_static on (not always).
RFC4 skip 206 Partial Content (preserves Content-Range).
Security / standards
S1 new zstd_bypass_vary directive so header-driven bypass can't poison shared caches.
RFC1 refuse to start with zstd_dict_file unless zstd_dict_file_unsafe on; (non-RFC-9842 Content-Encoding: zstd dict mode).
RFC2 rewrite the Accept-Encoding parser: RFC 9110 * wildcard, integer-milli qvalues, strict grammar (reject q=, 4th decimal, q=1x). Function names preserved for the fuzzer extractor.
RFC3 document server-preference selection (not client qvalue ranking).
C4 document zstd vs brotli precedence for dynamic builds (reverse load_module order).
CI (one commit)
CI1/CI2 gate shellcheck (error severity), cppcheck (+ checked-in suppressions baseline), and scan-build (both modules, exit no longer swallowed); require + verify generated nginx headers.
CI3/CI8 regressions for target_cblock, 206/Content-Range, zstd_bypass_vary.
CI4 replace the fake chunked min-length test with a raw chunked HTTP/1.1 backend.
CI5 independent reference parser as a libFuzzer semantic oracle.
CI6 resolve + build nginx stable; new tests-compat job runs the Perl suites + smoke against nginx stable and Angie.
CI7 run the Perl suites under ASAN+UBSAN.
Local validation
Module compiles clean (-Werror) against libzstd 1.5.7.
t/00-filter.t 786 subtests pass (incl. new wildcard/malformed-q/chunked/206/bypass_vary/target_cblock tests).
t/01-static.t 294 subtests pass.
Parser unit table (22 cases) + libFuzzer oracle 10M+ runs, no traps/false positives.
cppcheck / shellcheck / scan-build gates pass locally.
- Test/expand coverage by @eilandert in #50
- ci: fix flaky $zstd_ratio log race in ASAN smoke test by @eilandert in #51
- fix(ci): download nginx over HTTPS and verify PGP signature (S2) by @eilandert in #52
- refactor(config): dedup libzstd discovery into auto/zstd (CQ1) by @eilandert in #53
- Resolve all open audit findings (CQ/C/P/ST/S/RFC/CI) by @eilandert in #54
Full Changelog: 0.90.3...0.90.5
0.90.4
Another preview release, nothing functional changed. feedback needed.
What's Changed
- test: expand 00-filter.t and 01-static.t coverage (+18 tests) by @eilandert in #48
- Test/expand coverage by @eilandert in #49
Full Changelog: 0.90.2...0.90.4
ZSTD NGINX Compression Sunday Release.
Sunday Evening release.
Tests added.
http-zstd
What's Changed
- refactor: rewrite Accept-Encoding parser with length-bounded scanning by @eilandert in #39
- ci: add build-old-libzstd job for fallback-path coverage by @eilandert in #40
- docs: expand README, clarify BREACH decision, update test counts by @eilandert in #41
- fix: hardening improvements and test enhancements by @eilandert in #42
- docs: add RFC 7231 compliance audit comparing zstd vs gzip vs brotli by @eilandert in #43
- fix: body filter checks last_buf before flush (gzip/brotli order) by @eilandert in #44
- fix: gate zstd_static gzip_vary warning per-location by @eilandert in #45
- chore(config): declare ngx_http_zstd_common.h as a build dependency by @eilandert in #46
- fix: warn when zstd_target_cblock_size set on libzstd < 1.5.6 by @eilandert in #47
Full Changelog: 0.90...0.90.2
Test Release 2
What's Changed
- refactor: rewrite Accept-Encoding parser with length-bounded scanning by @eilandert in #39
- ci: add build-old-libzstd job for fallback-path coverage by @eilandert in #40
- docs: expand README, clarify BREACH decision, update test counts by @eilandert in #41
- fix: hardening improvements and test enhancements by @eilandert in #42
Full Changelog: 0.90...0.90.1
http-zstd
What's Changed
https://deb.myguard.nl/2026/05/zstd-nginx-module-what-it-does-bugs-fixed/
This release is a test release, although we think code is now stable and production worthy it has to mature too. Please leave your feedback.
New Directives & Features
- zstd_long: Enable long-distance matching to achieve ~2:1 compression improvement on large repetitive bodies (concatenated JSON, HTML boilerplate, logs) with configurable memory ceiling via zstd_window_log.
- zstd_bypass: Per-request compression opt-out via nginx predicates for BREACH mitigation (exclude endpoints mixing secrets with user-controlled input; requires application-level defenses).
- zstd_static startup warning: Warn operators at nginx startup if zstd_static enabled without gzip_vary to prevent CDN cache collision issues.
- Comprehensive debug logging: Added ngx_log_debug diagnostic logging for all 8 header-filter rejection reasons, buffer decisions, compression state, and emit decisions (zero cost in release builds, visible with error_log debug).
- zstd_max_length on chunked: Extended zstd_max_length enforcement to track running input total on chunked responses (no Content-Length) to prevent DoS from unbounded upstreams.
Added optimisations:
- Per-request context reuse done right. Each request gets its own zstd context, but the underlying allocation is reused across requests in a worker via reset — correctness and speed.
- Modern single-call streaming API. Replaced three legacy streaming calls and a deprecated init API with one ZSTD_compressStream2(), and dropped a custom allocator that added nothing.
- Pledged source size. When Content-Length is known, the exact size is pledged to zstd up front, producing a more compact frame header and slightly better speed and ratio at zero cost.
- Output buffers default to ZSTD_CStreamOutSize(). The encoder’s own recommended granularity (~128 KB), so zstd never has to fragment a block across calls. Two buffers, so one fills while the other is in flight.
- Hot-path loc-conf hoist. The location config is resolved once per request instead of once per inner-loop iteration — measurable on large responses.
- Single-division ratio math for $zstd_ratio instead of dividing twice.
- Skip a redundant loop iteration for an empty terminal buffer in the add-data path.
- New operator capabilities: zstd_max_length (cap huge responses), zstd_window_log (bound per-request memory — predictable RSS under load), zstd_long (long-distance matching for big repetitive bodies), zstd_bypass (the BREACH lever), and $zstd_bytes_in / $zstd_bytes_out log variables for real observability.
Fixed bugs:
- Silent truncation at 131072 bytes. Any response larger than zstd’s internal stream buffer (ZSTD_CStreamInSize, exactly 128 KB) was chopped off at that boundary. Big CSS bundles and JavaScript files simply ended mid-stream. This is the bug that makes a site look fine on the homepage and shatter the moment a real asset loads.
- Terminal frame never emitted on empty end. When the final compression call produced zero output bytes (it happens — the encoder had already flushed everything), the module forgot to write zstd’s end-of-frame marker. Decoders saw a stream that just stopped and rejected it.
- Truncation/abort with proxy_buffering off (the “bug B” saga). This was the production incident that took down deb.myguard.nl‘s wp-admin. With unbuffered proxying (the FastCGI/PHP path), the upstream forces a flush around a chunk that zstd buffers internally. The directive logic never told libzstd to actually flush, so it sat on the bytes forever — manifesting as a truncated response and a worker spinning at 100% CPU in a livelock. The fix has two halves: map a pending flush to ZSTD_e_flush, and clear the flush state when the encoder reports it’s fully drained so the loop can terminate.
- NULL-deref / worker SIGSEGV on multi-buffer chunked responses. On any response big enough to need more than one output buffer (chunked or no-Content-Length is the common trigger), a recycled buffer pointer was dereferenced after being invalidated — a clean worker segfault that only appeared past the first ~128 KB.
- 100% CPU infinite loop. A separate flush state-machine defect (PR #23 + #49 class) where the inner compression loop never reached a termination condition, pinning a core until the request timed out.
- ZSTD_CDict leaked on every config reload. If you used a compression dictionary, every nginx -s reload leaked the entire dictionary. On a busy box that reloads config often, that’s a steady climb to OOM.
- Cleanup handler registered with the wrong size. The dictionary cleanup used a non-zero size in ngx_pool_cleanup_add(), which subtly mismanaged the cleanup bookkeeping. Fixed to pass size=0 as the API intends.
- Shared compression context across requests. The original reused one zstd context worker-wide. Overlapping requests in a single worker could stomp each other’s compression state — intermittent corruption that’s almost impossible to reproduce on demand. Now every request gets its own context, attached to the request’s cleanup chain.
- Version-specific dictionary init error handling. On older libzstd, the dictionary init path used a different API whose error return wasn’t checked correctly, so a failed init could proceed as if it had succeeded.
- Buffer overflow appending the .zst extension. The static module built the .zst path without reserving enough room, so a long enough request URI could write past the buffer. Classic overflow, now bounded and regression-tested with a 2000-character URI.
- Integer overflow in the compression-ratio calculation. The $zstd_ratio math could overflow on large responses, producing garbage stats. Reworked with 64-bit scaling.
- Reject oversized numeric directives (INT_MAX hardening). zstd_window_log and zstd_target_cblock_size are narrowed to int before being handed to libzstd. A configured value above INT_MAX would silently truncate (possibly to a negative). Now rejected at config load with a clear error.
- Dictionary-file size cap. A 10 MB ceiling on zstd_dict_file prevents a memory-exhaustion denial of service via a giant dictionary.
- BREACH containment lever (zstd_bypass). A new per-request opt-out so operators can serve identity (uncompressed) on endpoints that mix a secret with attacker-influenced reflected input — the precondition for a BREACH-style attack — without splitting the location config.
- Unbounded input cap for chunked responses. A streaming response has no Content-Length, so the size check was skipped and a runaway upstream could feed the compressor forever. zstd_max_length is now enforced against the running input total too.
- The -fPIC linking failure. The config preferred a static libzstd.a that lacks position-independent code and cannot link into a shared dynamic module. Now prefers dynamic linking with a pkg-config fallback.
- Global CFLAGS mutation. The static module’s config leaked compiler flags into the global build, polluting other modules. Removed.
Cross-platform portability. Build logic hardened for Linux, the BSDs, RHEL/Rocky, and Gentoo — different pkg-config tools, different library locations. - Cross-compilation support. Set ngx_feature_run=no so the feature probe doesn’t try to execute a test binary (impossible when cross-compiling).
- Doubled source path. A bug in the addon config concatenated the source directory twice; fixed by scoping ngx_addon_dir per sub-config.
- Typo: SAVED_CC_TAST_FLAGS. A real, shipped typo that silently failed to restore test flags. One character; genuinely broke the build path.
- Filter ordering vs. brotli. If both zstd and brotli are enabled, zstd must run first or the ordering produces wrong results. Now enforced.
- RPATH embedding documented for custom ZSTD_LIB paths, plus advisories when building against libzstd older than 1.4.0 or 1.5.0 (missing APIs).
- Non-redistributable test fixture replaced with a BSD-2-Clause original, so the whole repo is cleanly licensed.
- Accept-Encoding parsing was wrong. The header matcher could mis-detect zstd as a substring of another token. Rewritten to tokenize properly.
- Quality values (q=) ignored. Accept-Encoding: zstd;q=0 means “do NOT send me zstd.” The module compressed anyway. Now it honors RFC 7231 qvalues, including the strict 0–1 range.
- Content-Type detection used the wrong filename in the static module, so .zst files could get the wrong MIME type. Fixed to use the original (pre-.zst) name.
- Only 200/403/404 were compressed. Other perfectly compressible 2xx responses (201, 206-not-applicable aside) were skipped. Broadened correctly.
- 204 and 205 were compressed. Bodyless responses must not carry a Content-Encoding. Now excluded.
- Accept-Ranges not cleared on compressed static files. Byte ranges into a .zst file are meaningless (offsets don’t map to the original). Now cleared per RFC 9110.
- Default compression level changed 1 → 3. Level 1 left obvious ratio on the table for negligible CPU savings; 3 is the sane default.
- Missing gzip_vary warnings. If zstd is enabled but gzip_vary is off, proxies and CDNs can cache a compressed response and serve it to a client that can’t read it. Both the filter and static modules now warn loudly at config load. (We added the static-module half this week.)
- Two no-op / unreachable code blocks removed during audit, plus a cluster of eight smaller correctness fixes batched from the first full codebase audit (dead code, deduplication, minor bugs).
- Skip-reason and diagnostic debug logging. “Why isn’t my response compressed?” was previously undiagnosable without a rebuild. Every eligibility-rejection path, the buffer-acquire decision, and the compression-context setup now emit structured debug logs (zero cost in release builds, visible under error_log debug). The permanent emit-decision probe for the truncation bug class is part of this group too.
Added CI pipeline:
- Build & Test workflow...