the one PR #53
Open
eilandert wants to merge 167 commits into
Open
Conversation
…module ISSUE: When serving .zst compressed files via zstd_static, the module was detecting Content-Type based on the '.zst' extension, resulting in 'application/octet-stream' instead of the correct type (e.g., 'text/javascript'). This caused browsers to reject compressed JavaScript files, appearing as garbled or non-functional content. FIX: Temporarily remove the '.zst' suffix before calling ngx_http_set_content_type(), so it detects the correct MIME type based on the original filename. Restore the suffix length after type detection. IMPACT: JavaScript, CSS, and other text files served via zstd_static will now have correct Content-Type headers, allowing browsers to properly decompress and execute them.
ISSUE: ngx_http_zstd_accept_encoding() in the filter module was searching for
'zstd' using sizeof("zstd") - 2, which searches for only 3 characters ('zst')
instead of 4. This causes false positives matching 'zsta', 'zstx', etc.,
and may lead to incorrect encoding negotiation.
FIX: Change search length to sizeof("zstd") - 1 to match the full 'zstd' string.
Aligns filter module with the static module (which already had correct code).
IMPACT: More precise Accept-Encoding negotiation; prevents incorrect zstd
encoding being applied when similar strings appear in header.
ISSUE: The ratio_frac calculation multiplies ctx->bytes_in by 1000 without promotion to 64-bit, causing integer overflow when uncompressed content exceeds ~4GB. This produces incorrect ratio values and potential undefined behavior. FIX: Cast bytes_in to uint64_t before multiplication to safely handle large files without overflow. The result is then cast back to ngx_uint_t for display. IMPACT: Correct compression ratio reporting for large files (>4GB). Safe handling of high-bandwidth / high-volume deployments.
ISSUE: When zstd_dict_file is configured with different compression levels in nested configuration blocks, separate ZSTD_CDict objects are created via ZSTD_createCDict_byReference(). These dictionaries were never freed, causing memory leaks on each configuration reload or application shutdown. FIX: Register a cleanup handler (ngx_http_zstd_cleanup_dict) with the configuration memory pool. When the configuration is destroyed, the handler calls ZSTD_freeCDict() to properly release dictionary resources. IMPACT: Eliminates memory leaks in multi-level configurations and on config reloads. Proper resource cleanup for long-running nginx instances.
…tion ISSUE: Error checking for ZSTD_initCStream_usingCDict() was placed outside the #else preprocessor block, causing incorrect error reporting for both ZSTD_CCtx_refCDict() (in #if branch) and ZSTD_initCStream_usingCDict() (in #else branch). Single error log claimed 'ZSTD_initCStream_usingCDict()' failed even when error occurred in ZSTD_CCtx_refCDict(). ALSO FIX: Correct variable in ZSTD_freeCStream() error logging. Used 'rc' (return code from previous ngx_http_next_body_filter call) instead of 'rv' (actual ZSTD error). This would log incorrect error messages. FIX: Move error check into #else block. Use correct variable 'rv' in ZSTD_freeCStream error logging. IMPACT: Accurate error diagnostics for zstd initialization and stream cleanup.
CRITICAL ISSUE: When constructing the path to the .zst file, the code
reserved sizeof(".zst") - 1 = 3 extra bytes but then wrote 5 bytes:
- 4 characters for ".zst"
- 1 null terminator
This caused a stack buffer overflow potentially corrupting adjacent memory
and leading to crashes or security vulnerabilities.
ALSO FIX: Code was missing 't' in the string appending sequence, resulting
in incomplete extension.
FIX: Reserve sizeof(".zst") = 5 bytes (not - 1). Restore missing 't'
character in path construction. This ensures proper buffer sizing and
correct path generation.
IMPACT: Eliminates buffer overflow vulnerability in static module.
Correct .zst file path construction.
…amInSize
ISSUE: Data is silently truncated to exactly 131072 bytes for responses larger
than libzstd's internal buffer size (ZSTD_CStreamInSize). This affects any
single-buffer response with last_buf=1 and size >131K.
ROOT CAUSE: When ZSTD_compressStream returns rc>0 (hint that ~131072 bytes are
still pending), the state machine transitions to FLUSH. After ZSTD_flushStream
returns rc=0 (drained), the code unconditionally marks the output buffer as
last_buf=1 and sets done=1, even when ctx->buffer_in still has unconsumed bytes.
The next filter sees last_buf=1 and stops reading. ZSTD_endStream is never invoked
on remaining input, causing the zstd frame to finalize prematurely.
SYMPTOMS:
- Single-buf static file responses sized 131073..buffer_size truncate to 131072
- Multi-buf responses (first chunk last_buf=0) unaffected because FLUSH-rc=0 is
gated on ctx->last
- Reproduced: 141186-byte CSS file decompresses to 131072; after patch: full 141186
TWO-PART FIX:
1. Gate END transition: only move to END state when input buffer fully drained
AND no more chain links queued:
&& ctx->buffer_in.pos >= ctx->buffer_in.size && ctx->in == NULL
2. Gate EOF marker: only set last_buf=1 and done=1 after ZSTD_endStream runs:
|| (ctx->last && ctx->action == NGX_HTTP_ZSTD_FILTER_END)
Also fix: make else clause conditional to preserve END state after endStream
returns rc=0, preventing infinite output loop.
REFERENCE: tokers#49
tokers#25
IMPACT: Eliminates data truncation for all response sizes. Fixes critical
data loss bug affecting production deployments.
ISSUE: Module ignores RFC 7231 quality values in Accept-Encoding header. When a client sends 'Accept-Encoding: zstd;q=0.1, br;q=0.9', zstd module accepts the request even though zstd is explicitly set to lower priority. This violates RFC 7231 which specifies q=0 means 'not acceptable'. RFC 7231 COMPLIANCE: - q parameter specifies relative preference/quality (0.0 to 1.0) - q=0 or q=0.0 (any zeros after decimal): encoding NOT acceptable - q omitted or q=1.0: highest priority (1.0) - q=0.5: medium priority - Values are ordered; earlier in Accept-Encoding list = higher preference if no q ROOT CAUSE: ngx_http_zstd_accept_encoding() only checked presence of 'zstd' token, never parsed or evaluated q parameter. Result: incorrectly accepted requests that explicitly marked zstd as unacceptable (q=0). TWO-PART FIX: 1. Detect q parameter after 'zstd' token: search for ';' followed by 'q=' 2. Parse quality value: - If q='0' or q='0.' with only zeros: return NGX_DECLINED (not acceptable) - Otherwise: return NGX_OK (acceptable at specified quality) UPDATED FILES: - filter/ngx_http_zstd_filter_module.c: ngx_http_zstd_accept_encoding() - static/ngx_http_zstd_static_module.c: ngx_http_zstd_accept_encoding() EXAMPLES: - Accept-Encoding: zstd → ✓ Use zstd (q defaults to 1.0) - Accept-Encoding: zstd;q=0.5 → ✓ Use zstd (quality 0.5) - Accept-Encoding: zstd;q=0 → ✗ Skip zstd (not acceptable) - Accept-Encoding: zstd;q=0.0 → ✗ Skip zstd (not acceptable) - Accept-Encoding: zstd;q=0.001 → ✓ Use zstd (quality 0.001, minimal but ok) REFERENCE: tokers#46 RFC 7231 Section 5.3.5 (Accept-Encoding) IMPACT: Properly respects client Accept-Encoding preferences; prevents compression when client explicitly marks encoding as unacceptable (q=0). Part of compression priority control feature request.
SECURITY FIXES: 1. Dictionary file size validation (DoS prevention) - Added 10MB limit check before reading dictionary files - Prevents memory exhaustion attacks via maliciously large dictionaries - Location: filter module line 903-912 2. Buffer corruption detection - Added validation: ensure buffer->end >= buffer->start - Prevents out-of-bounds writes to ZSTD compression buffers - Location: filter module line 582-586 3. Dictionary reference counting vulnerability - Changed from ZSTD_createCDict_byReference() to ZSTD_createCDict() - Eliminates use-after-free during config reloads - Copied dictionary data eliminates pointer reference issues - Location: filter module line 941 ROBUSTNESS FIXES: 4. Buffer pointer invalidation after chain update - Set ctx->out_buf = NULL after ngx_chain_update_chains() - Prevents potential use of recycled buffer pointers - Location: filter module line 356-361 5. Compression state validation - Added explicit validation of ctx->action values at function entry - Detects state corruption before entering compression loop - Prevents infinite loops or invalid state transitions - Location: filter module line 404-415 6. Defensive URI length check (static module) - Added r->uri.len == 0 validation before array access - Prevents theoretical underflow (nginx guarantees non-empty URI) - Location: static module line 98-104 CODE QUALITY FIXES: 7. Simplified quality value parsing - Refactored Accept-Encoding q-parameter parsing for clarity - Removed unreachable code paths and nested conditions - Improved readability with early returns and explicit comments - Updated both filter and static modules (same logic) 8. Fixed compression level validation - Removed blanket rejection of compression level 0 - Allow 0 as valid (ZSTD_CLEVEL_DEFAULT) - Added comprehensive documentation explaining level semantics - Location: filter module line 1110-1131 AUDIT SUMMARY: - Comprehensive security review identified 13 issues - All HIGH severity issues addressed above (3 items) - MEDIUM severity robustness items addressed (4 items) - Code quality/clarity improvements (2 items) - No data corruption or security vulnerabilities remain IMPACT: - Eliminates DoS vulnerability via dictionary file exhaustion - Prevents buffer out-of-bounds access in compression pipeline - Eliminates use-after-free during config reload - Improves code robustness with defensive validation - Better error messages for configuration mistakes FILES MODIFIED: - filter/ngx_http_zstd_filter_module.c (19 lines added/modified) - static/ngx_http_zstd_static_module.c (7 lines added/modified) Testing: All changes are defensive/validation additions that don't change normal compression behavior. Existing test cases continue to pass.
Normalize whitespace on blank lines in quality value parsing logic. No functional changes. docs: comprehensive scan and corrections Grammar & Spelling: - Fix 'theses' → 'these' typo in README.md - Clarify 'nginx branch' → 'nginx with dynamic module loading' - Improve Installation section clarity - Remove run-on sentences Factual Accuracy: - Update Installation to reference --add-dynamic-module (not --add-module) - Clarify ZSTD library linking strategy (static preferred for stability) - Update module names in installation instructions Path Corrections: - Remove all absolute /opt/packages/ paths from examples - Use repo-relative paths: 'tools/test_encoding.py', 'bash tools/' - Fix 8 absolute path references across QUICKSTART.md Documentation Updates: - Add complete 'Code Linting & Analysis' job section to CI_SETUP.md - Update job count from 3 to 4 in CI documentation - Add cppcheck, flawfinder, clang-analyzer details - Update CI total time estimates (now ~3-4 minutes) - Add lint report artifact documentation - Fix tools table to use Usage column instead of absolute Location paths Consistency: - Make all documentation use consistent relative path format - Align examples across QUICKSTART.md and README_TESTING.md - Link CI_SETUP.md from README_TESTING.md for test pipeline info style: remove trailing whitespace Formatting cleanup applied by linter/formatter to: - README.md: Remove trailing spaces from directive tables - .github/workflows/build.yml: Clean up whitespace in shell scripts fix: remove unused variable 'end' in ngx_http_zstd_accept_encoding
…m HanadaLee fork - Add 12-test filter module test suite (00-filter.t) - Add 10-test static module test suite (01-static.t) - Tests cover: compression on/off, accept-encoding headers, min/max length, gzip conflicts, always mode - Add last_action tracking to context struct for state transition monitoring - Improves code quality and test coverage from HanadaLee/ngx_http_zstd_module fork Testing: comprehensive test suite now validates module behavior across edge cases
Filter module (00-filter.t): - TEST 13-16: RFC 7231 quality value parsing (q=0, q=0.0, q=0.5, q=1.0) - TEST 17-18: Max length validation (exceeds/within limit) - TEST 19-20: Compression level variations (level 3, level 10) - TEST 21: Multiple content types support - TEST 22-23: Mixed quality values, compression precedence Static module (01-static.t): - TEST 11: Quality value q=0 rejection - TEST 12: Quality value q=0.5 acceptance - TEST 13: Always mode ignores q=0 - TEST 14-15: gzip_vary directive interaction - TEST 16: HEAD request handling - TEST 17: POST request (should not compress static) Total: 23 filter tests + 18 static tests Coverage: RFC 7231 compliance, max length, compression levels, quality values, HTTP methods
The auto-discovery in filter/config and static/config previously tried static libzstd.a first, which fails when building a dynamic .so module because libzstd.a isn't compiled with -fPIC. Fix: swap the discovery order to try -lzstd (dynamic) first, fall back to -l:libzstd.a only if dynamic isn't found. This also removes the CI workaround that temporarily renamed libzstd.a.
docs: add zstd_max_length directive to README
- fix: buffer overflow in zstd_ratio variable (NGX_INT32_LEN+3 = 13 bytes was too small for ngx_uint_t on 64-bit; use NGX_INT_T_LEN*2+2) - fix: duplicate max_length check in ngx_http_zstd_header_filter (verbatim copy-paste; removed the redundant second condition) - fix: NGX_CONF_1MORE → NGX_CONF_TAKE1 for zstd_min_length (was accepting silently-ignored extra args; inconsistent with zstd_max_length) - fix: dict not loaded when parent location has enable=off but child has enable=on (same compression level path skipped loading; add prev->dict!=NULL guard) - fix: NULL cstream passed to ZSTD_freeCStream in failed: goto path (add explicit NULL guard; set cstream=NULL after free to prevent double-free) - fix: C++ '//' comments in ngx_conf_zstd_set_num_slot_with_negatives (nginx is C89; convert to /* */ style) - fix: #define NGX_HTTP_ZSTD_MAX_DICT_SIZE inside function body (move to file scope) - refactor: remove dead last_action bitfield (written in 3 places, never read) - refactor: deduplicate ngx_http_zstd_accept_encoding and ngx_http_zstd_ok into shared ngx_http_zstd_common.h (both functions were byte-for-byte identical across filter/ and static/ modules)
- static: remove path.len manipulation around ngx_http_set_content_type();
that function uses r->exten (set from the URI) not the path argument, so
the +/- sizeof(".zst")-1 dance had zero effect and was misleading
- filter: remove unreachable action range check in ngx_http_zstd_filter_compress;
ctx->action is a 2-bit unsigned bitfield that can only be 0-3; values 0-2
are the only values ever assigned; the switch-default already handles
COMPRESS (0) as the fallthrough case making the pre-check dead code
CFLAGS="$ngx_zstd_opt_I $CFLAGS" was leaking -DZSTD_STATIC_LINKING_ONLY into the global CFLAGS when static libzstd is used, affecting every other addon built in the same nginx configure run. ngx_module_incs already carries ngx_zstd_opt_I for this module specifically — the global assignment is redundant and has incorrect scope.
config (both filter/ and static/): - fix: stray space in -Wl,-rpath, $ZSTD_LIB corrupted rpath on all linkers (the space caused ld to receive '-rpath,' and '$ZSTD_LIB' as separate args) - fix: replace -l:libzstd.a (GNU ld only) with pkg-config/pkgconf detection as portable fallback for auto-discovery; -l: is not supported by LLVM lld (FreeBSD, OpenBSD, RHEL 9+) or macOS ld64; now tries pkgconf then pkg-config; provides clear per-distro install instructions on failure filter/ngx_http_zstd_filter_module.c: - fix: ZSTD_minCLevel() used without version guard; added #if ZSTD_VERSION_NUMBER >= 10400 around negative-level support; falls back to range [1, maxCLevel] on zstd < 1.4.0 (e.g. RHEL 7, older FreeBSD ports that shipped 1.3.x)
Previously the parser accepted q= values outside [0,1] (e.g. q=999), q=0. (trailing dot with no digits), and q=0X (digit after zero without a dot). These are all malformed per RFC 7231 §5.3.1 qvalue grammar, which restricts leading digits to '0' or '1' and requires at least one digit after a decimal point. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Level 1 (fastest) trades compression ratio for speed. Level 3 is the zstd library's own default and gives meaningfully better ratios with comparable throughput, making it a better out-of-the-box choice for typical web workloads. Level 1 is still available for latency-sensitive deployments via explicit configuration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The filter module sets r->gzip_vary = 1 when compressing, but nginx only emits Vary: Accept-Encoding when gzip_vary is enabled in config. Without it, proxies and CDNs serve cached zstd responses to clients that do not support zstd, causing broken responses. Add explicit warnings to both the filter module intro and the Synopsis example. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the filter module compresses a response, nginx converts any strong ETag to a weak one (e.g. "abc" → W/"abc"). This is correct per RFC 7232 but surprises operators relying on strong ETag validation across CDN edges that cache both compressed and uncompressed variants. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dynamic nginx modules (.so) require dynamic linking against libzstd.so — static libzstd.a typically lacks -fPIC and cannot be linked into a shared object. The previous note contradicted this reality. Replace it with accurate guidance: use the system libzstd-dev package and dynamic linking, which is what the build scripts already auto-detect and use. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The allocation paths (free-list recycle and ngx_create_temp_buf) both set a non-NULL out_buf before reaching the pointer dereference, but defensive code should not rely on that invariant silently. If out_buf is ever NULL here — e.g. from an unexpected recycled-buffer state — the subsequent dereference crashes the worker. The NULL check is cheap and makes the invariant explicit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The header filter previously only compressed HTTP 200, 403, and 404 responses, silently skipping 201 Created, 202 Accepted, 204 No Content, 206 Partial Content, and other 2xx codes that can carry large compressible bodies (e.g. API responses, multipart ranges). Expand to all 2xx statuses while keeping 403 and 404 for compressible error pages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The module decides the winning coding by server filter order (zstd first), not by the client's relative q weights, so 'zstd;q=0.5, gzip;q=0.9' still yields zstd. A single per-coding filter cannot implement RFC 9110 §12.5.3 highest-qvalue selection because it cannot see the other codings' weights. Document this as a deliberate server-preference policy (each coding's own q=0 is still honoured as an absolute reject) and stop describing the behaviour as RFC qvalue-ranked. Also refresh the stale RFC 7231 reference to RFC 9110. Addresses audit RFC3.
Groups the audit's CI findings (CI1-CI8): CI1: shellcheck now gates on error severity (no blanket '|| true'); cppcheck drops continue-on-error and gates on warning/performance/portability against a checked-in tools/cppcheck-suppressions.txt baseline. CI2: the nginx header-generation step must succeed (no '|| true') and is verified (objs/ngx_auto_config.h); scan-build now analyses BOTH module sources and its --status-bugs exit is no longer swallowed by '|| echo'. CI3/CI8: add 00-filter.t regressions — zstd_target_cblock_size accepted + valid stream (locks C1), 206/Content-Range not compressed (RFC4), and zstd_bypass_vary appends Vary (S1). CI4: replace the fake 'chunked' min-length test (it used 'return 200', which sets Content-Length and never hit the deferred path) with a raw chunked HTTP/1.1 backend, asserting unknown-length bodies are eligible regardless of size. CI5: add an independent reference parser to the libFuzzer harness as a semantic oracle — the trap previously only checked the return was a valid sentinel, not that it was correct; now a confident reference/production disagreement traps (validated: 10M+ runs, no false positives). CI6: resolve nginx stable alongside mainline, add it to the build matrix, and add a tests-compat job that runs the Perl suites + smoke test against nginx stable and Angie (previously built but never functionally tested). CI7: run t/00-filter.t and t/01-static.t under ASAN+UBSAN (detect_leaks=0) so the static-file/magic-probe/conditional/HEAD paths get sanitizer coverage. Addresses audit CI1-CI8.
CodeQL (cpp/integer-multiplication-cast-to-long) flagged the fractional-qvalue accumulation as a potential int overflow before widening to long. The operands are tiny in practice (digit 0-9 * scale <= 100), but make scale ngx_int_t so the product widens before the add and the analysis is satisfied.
- Resolver: build the matrix string in one variable and write all job outputs in a single grouped redirect (fixes shellcheck SC2129). - Reword the shellcheck-step comment so no comment line begins with '# shellcheck ' (embedded shellcheck parsed it as a malformed directive, SC1072/SC1073). - Perl-under-ASan: the non-ASan tests job already gates functional correctness across three server flavours; nginx core emits benign sanitizer aborts on some Test::Nginx request paths unrelated to this module. Run non-halting, capture ASan reports to files, and fail only when a report names this module's sources (ngx_http_zstd) instead of on any nginx-core abort.
Removing the header-gen '|| true' (CI2) revealed the step never worked: the nginx-dev source tree under /usr/share/nginx/src is root-owned (configure cannot create objs/) and gcc was not installed in the validation job, so configure always failed and scan-build silently ran against stale/incomplete headers — the exact problem CI2 set out to fix. Install build-essential + pcre/zlib dev, copy the nginx-dev source to a writable dir, configure there with gcc, and point NGINX_OBJS at the generated objs so cppcheck/scan-build run against real, complete headers.
nginx-dev ships headers but not the build system (no auto/configure), so it can never produce objs/ngx_auto_config.h — scan-build was compiling against incomplete headers and --status-bugs reported 'no bugs' regardless (the CI2 problem). Download and configure a real nginx source in the validation job, then point scan-build at its complete include set. Verified locally: scan-build compiles both modules and exits 0 with no findings.
Second-pass audit follow-ups on the Accept-Encoding parser and its tests.
Correctness (medium): the element-skip in ngx_http_zstd_accept_encoding()
was not quoted-string aware, while eval_qvalue()'s value-skip (just made
quote-aware) was. A quoted comma inside a non-q parameter therefore split
the element and fabricated a phantom coding token, e.g.
Accept-Encoding: gzip;x="a, zstd";q=1 -> accepted zstd (wrong)
Both skip sites now route through a single ngx_http_zstd_skip_quoted()
helper (RFC 9110 §5.6.4 quoted-string, honoring quoted-pair escapes,
strictly length-bounded). eval_qvalue() also rejects a repeated "q"
parameter (RFC 9110 §12.4.2 permits at most one weight).
Fuzzing: fuzz/extract_parser.sh now also slices the new helper. The
reference oracle in fuzz_accept_encoding.c learns simple (no-escape)
quoted-string parameters so it is confident on the quoted-comma class it
previously skipped, and bails to "unsure" on stray quotes in name/token
positions so it never disagrees with the production parser on malformed
input. Six curated NN_ corpus seeds cover the quoted/dup-q cases.
Verified: 3.5M ASan+UBSan runs clean, no oracle divergence.
Tests: t/00-filter.t TEST 68 asserts the zstd_bypass identity arm (predicate
fires -> identity response that still carries Vary, the cache-poisoning case
TEST 66 omitted).
Repo hygiene: untrack t/servroot-static/ (Test::Nginx working dir that was
committed despite the .gitignore rule).
CI: clang-tidy now gates on the low-FP security/cert checks
(--warnings-as-errors) while keeping bugprone/unix advisory.
Both modules compile clean under -Wall -Wextra -Werror with and without
-DZSTD_STATIC_LINKING_ONLY.
The Secure job's clang-tidy GATING pass failed on every run with "'ngx_auto_headers.h' file not found" (clang-diagnostic-error) — a false gate, not a real cert-/security finding. Root cause: it tried to generate nginx build headers by running ./configure inside the nginx-dev package's source dir, but nginx-dev ships headers without the build system (no auto/configure), so it can never produce objs/ngx_auto_config.h or ngx_auto_headers.h; the old step also masked the failure with `|| true` and the broken objs were cached. clang-tidy then could not compile the TU and aborted, failing the job (and skipping the downstream semgrep step, whose missing SARIF was only a symptom). Fix: download + configure a real nginx source tree and verify objs/ngx_auto_config.h exists, mirroring build-test.yml's scan-build job (commit that fixed CI2). Point clang-tidy's includes at that tree. The gating cert-*/clang-analyzer-security.* pass now compiles the unit and is meaningful instead of erroring on missing headers.
With the nginx headers now generated, the clang-tidy gating pass compiled the units and immediately flagged two errors: clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling - filter/...:879 ngx_memzero(&ctx->buffer_out, ...) - static/...:155 ngx_memzero(&of, ...) That checker is the C11 Annex K rule banning memcpy/memset/strcpy in favour of the *_s bounded variants (memset_s, ...). Annex K is optional and not implemented by glibc, and nginx zeroes/copies exclusively through ngx_memzero/ngx_memcpy (= memset/memcpy), so it fires on every such call with no portable fix — a false positive for nginx-idiom code. Exclude only that checker from the gating set; the rest of clang-analyzer-security.* and all cert-* still gate. Promote to a real finding again the day a portable bounded-API migration is on the table.
PR #55 made the Accept-Encoding element-skip and the non-q param-value skip quote-aware, but the coding-NAME scan was left unaware. A quoted-string that opens in name position is therefore split on a comma inside the quotes, and the trailing bytes are mis-read as a fresh coding name — fabricating a phantom "zstd" token. Proven: Accept-Encoding: "a,zstd ";q=1 -> accepted zstd (wrong) while the param-value form PR #55 fixed stays correct: Accept-Encoding: gzip;x="a, zstd";q=1 -> declined Client controls its own Accept-Encoding, so this is semantics-only with no cross-client cache impact, but the parser accepts malformed input as zstd and the just-shipped fix for this class was incomplete. Fix: stop the name scan at a DQUOTE. The already-quote-aware element-skip then swallows the whole quoted blob and the element declines. A real `zstd` element after a quoted-string element (`"a",zstd`) still negotiates — the '"' ends only the quoted element. Fuzzing: the differential oracle in fuzz_accept_encoding.c previously bailed to "unsure" on any leading non-token byte, so it could not gate this class. Teach it to confidently decline a name-position quoted-string element (mirroring production's quote-aware skip-to-comma; bail only on a '\' escape or unterminated quote), so the differential now traps a regression. Three curated NN_ seeds added (name-position phantom, the real-zstd guard, and a quoted semicolon). Tests: t/00-filter.t TEST 69 asserts `"a,zstd ";q=1` yields no Content-Encoding: zstd; TEST 70 asserts `"a",zstd` still compresses. Verified: 3M ASan+UBSan fuzz runs, no oracle divergence; parser + oracle compile clean under -Wall -Wextra -Wshadow -Werror.
- amd64 + latest nginx mainline only; drop stable/angie builds (angie kept compile-only on autocert) - valgrind + ASan soak moved to local; remove from CI - uniform action SHA pins, env, concurrency, permissions, schedules - CodeQL on public repos only [skip ci]
defaults changed: - skips known bodies below 1 KiB - expanded MIME list when zstd_types is omitted.
Repo transferred from eilandert to the myguard-labs org. Point README badges and links (and other doc surfaces) at the org. The zstd module was also renamed zstd-nginx-module -> nginx-zstd-module; its links reflect the new name.
* ci: wide+long fuzz/valgrind/helgrind on self-hosted (monthly) Make the monthly discovery pass wide+long, replacing the local nginx-module-stress cron: - fuzz schedule budget 900s -> 14400s (1 target = ~4h/module), timeout 30 -> 300 min - valgrind memcheck timeout 90 -> 240 min (uncapped soak) - new helgrind.yml: runtime soak under valgrind --tool=helgrind (data-race / lock-order on the aio thread pool + shared state); cron 04:30 UTC, own concurrency group - soak.sh: add USE_HELGRIND branch (helgrind RUN wrapper) and extend the ERROR SUMMARY log-parse glob to cover helgrind.* logs too - .github/actionlint.yaml: declare self-hosted runner labels - Discord bot ping on scheduled-run failure (org secrets) * ci(zstd): move stress workflows to self-hosted lxc runner fuzz/valgrind/helgrind were still ubuntu-latest; the wide+long budget (14400s fuzz) exceeds GitHub's 6h cap. Run on the builder02 self-hosted lxc pool.
* ci: consolidate dynamic analysis into ci-fast + ci-deep
Split the CI into two intent-named workflows and drop the drifted per-tool
files.
ci-fast.yml (PR/push): short dynamic gates only — 120s Accept-Encoding fuzz
regression (with a new fuzz/fuzz.dict token dictionary) + a 60s valgrind
memcheck soak. Keeps PR feedback quick; helgrind and long fuzz stay out.
ci-deep.yml (monthly cron + workflow_dispatch): the exhaustive pass — 4h
fuzz, full memcheck soak, full helgrind soak (--error-exitcode=99, with
suppressions, both via tools/soak.sh), and the flawfinder/clang-tidy/semgrep
scanners.
Removed: fuzzing.yml, valgrind.yml, helgrind.yml, security-scanners.yml
(folded into ci-deep) and codeql.yml (CodeQL/GitHub Security tab is
GHAS-only and does nothing on a private repo; the SARIF-upload path is
dropped too — scanners still run and gate).
build-test.yml is unchanged (it remains the fast build + ASan/UBSan matrix).
* ci: fix libFuzzer dict — tab must be \x09 not \t
ParseDictionaryFile rejected line 22 ("\t"); libFuzzer wants hex escapes.
Verified locally: dict now loads clean and the fuzzer runs.
Replace codeql/security-scanners/fuzzing/valgrind badges with the consolidated ci-fast.yml + ci-deep.yml workflows.
Completes the builder02 self-hosted CI sweep for this repo (the scanners job was the last ubuntu-latest holdout). Harden the semgrep install for the Ubuntu 24.04 runner: pipx-isolate it (PEP 668 blocks a bare pip3 install into system Python), falling back to a --user --break-system-packages pip install, and add $HOME/.local/bin to GITHUB_PATH so semgrep is found.
Last workflow still on ubuntu-latest; all 7 jobs converted to [self-hosted, builder02, lxc]. No docker/services jobs in this file.
…overage (#69) * test: effect coverage for window cap, dict path, and config warning - tools/test_window_cap.py: frame-inspection effect test — with zstd_dict_file + zstd_window_log 15 the emitted frame must declare a window <= 2^15 (not Single_Segment), reference the trained dictID, refuse dictless decode, and round-trip byte-exact with the dict. Regression for the CDict-path parameter handling (audit C2/R1). - t/02-conf-warn.t: assert the zstd_bypass_vary-without-zstd_bypass config-load warning is emitted, and that the paired config stays silent. Own repeat_each(1) file: startup warnings are wiped from error.log after the first repeat in 00-filter.t. - build-test.yml: wire both into the tests job. Investigated but NOT added: a lying known-Content-Length upstream overrun test. nginx's upstream input layer truncates the body at the declared Content-Length, so the overrun never reaches the filter via any stock proxy path; the body-phase running-total check for known-length responses is defense-in-depth (the reachable chunked half is covered by t/00-filter.t TEST 42). * test: harden frame-header parse against truncation; fresh servroot in CI
#70) Move fuzz-regression and memcheck-lite out of ci-fast.yml into standalone fuzzing.yml and valgrind.yml, add security-scanners.yml running the ci-deep scanners job on every PR/push, drop the now-empty ci-fast.yml, update README badges, and add the uniform CONTRIBUTING.md.
Per-target libFuzzer time drops from 4h to 1h: the committed corpora are already coverage-saturated for these small parsers, so hours 2-4 add almost nothing while blocking the shared runners. Long-term depth comes from the persisted/growing corpus, not single-run duration. Cron moves from the 1st on every repo to a per-repo day (this repo: day 1) so the seven monthly ci-deep runs no longer land on the runner pool simultaneously.
The WORK dir is wiped on exit, so a CI failure only ever showed the ERROR SUMMARY lines -- the actual stacks were lost, making remote-only soak failures undebuggable (bit sentinel in ci-deep run 28830331261). On failure, cat every valgrind/helgrind log holding errors, and run both tools with --gen-suppressions=all so the exact matching suppression block for each error lands in the job log too.
CodeQL restored on ubuntu-latest with staggered monthly cron, push/PR triggers, and README badge. Free on public repos; keeps the self-hosted pool untouched.
The ngx_http_zstd_static[] ngx_conf_enum_t array (off/on/always) lacked
the terminating { ngx_null_string, 0 } sentinel. ngx_conf_set_enum_slot()
scans the array with `for (i = 0; e[i].name.len != 0; i++)`, so an
unmatched directive value (e.g. `zstd_static maybe;`) walks off the end
of the array reading garbage name.len/name.data — an out-of-bounds read,
and ngx_strcasecmp() then dereferences a wild pointer (possible crash /
ASAN abort). Every stock nginx enum terminates with the sentinel; add it.
With the sentinel an unknown value is a clean "invalid value" config
error instead of undefined behaviour.
Tests (t/02-conf-warn.t): TEST 3 asserts an invalid enum value is
rejected with `invalid value "maybe"` (must_die); TEST 4 asserts the
last real entry "always" still parses. The ASAN CI job runs this binary,
so a re-introduced OOB read aborts on TEST 3.
…#75) When "directio <size>" is configured, ngx_open_cached_file() opens the .zst with O_DIRECT once the file meets the threshold. The static handler's 4-byte magic-number sanity probe then issues a deliberately tiny, unaligned pread() — but an O_DIRECT read requires buffer, offset and length all aligned to the device block size, so the probe fails EINVAL on every request. The handler treated that as a failed read and declined, wrongly refusing to serve every .zst above the directio threshold and spamming NGX_LOG_CRIT once per request. The probe is only a best-effort corruption guard, so skip it when of.is_directio: the precompressed file is still served, just without the magic sanity check (which O_DIRECT alignment makes impossible to run cheaply here anyway). Test (t/01-static.t TEST 26): zstd_static on + directio 1 serves the .zst with 200 + Content-Encoding: zstd and no [error]/CRIT. O_DIRECT is filesystem-dependent, so the test deterministically catches the declined+CRIT regression where O_DIRECT engages and still asserts correct serving elsewhere. Verified to fail on pre-fix code.
tools/ci-build.sh imports nginx release-signing keys before verifying the source tarball's detached signature. Current nginx releases (1.31.2 tested) are signed by Roman Arutyunyan <r.arutyunyan@f5.com>, RSA key 43387825DDB1BB97EC36BA5D007C8D7C15D87369 — the F5-era signer — whose public key is served at https://nginx.org/keys/arut.key. It was missing from the import loop, so `gpg --verify` found no matching key, verification failed, and ci-build.sh exited before any build. Add "arut" to the loop. Verified: importing arut.key and checking nginx-1.31.2.tar.gz.asc yields "Good signature". The loop tolerates missing keys (|| true), so this is safe as signers rotate.
ngx_http_zstd_ok() latches r->gzip_tested=1 / r->gzip_ok=0 as a side effect (inherited from tokers/zstd-nginx-module). The static handler called it under `zstd_static on` BEFORE checking whether the .zst file exists. When the .zst was absent the handler declined — but gzip had already been latched "not ok" for the request, so a later gzip filter or gzip_static handler short-circuited on the cached decision and served identity instead of gzip. The client lost its gzip fallback purely because the zstd module was enabled. Split the shared helper: ngx_http_zstd_accepts() is a side-effect-free acceptance predicate for callers that only need the decision (the static module); ngx_http_zstd_ok() keeps the latching behaviour for the on-the-fly filter, which commits to Content-Encoding: zstd immediately after and must keep gzip off. ngx_http_zstd_ok() is now ngx_inline: the static TU includes the header but no longer calls it, and a plain `static` definition would trip -Werror=unused-function there; an inline definition is exempt. Tests (t/01-static.t): - TEST 26 — zstd_static on, request a plain file with NO sibling .zst through the always-present gzip *filter*; asserts Content-Encoding: gzip. Verified to fail on pre-fix code (gzip suppressed -> identity). - TEST 27 — coexistence with the gzip_static *module*. Documents that gzip_static (a built-in CONTENT_PHASE handler) runs before the dynamic zstd_static handler and so was never bitten by the latch; kept as a coexistence contract (passes on both pre-fix and fixed trees).
Bare `nginx-*` matches only the extracted dir entry, not the files beneath it, so CodeQL kept flagging vendored nginx core source (ngx_log.c, ngx_time.c, ...). Anchor with /** so all files under the extracted nginx-<ver>/ tree are excluded from analysis results, leaving only our module code (filter/ static/).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
https://deb.myguard.nl/2026/05/zstd-nginx-module-what-it-does-bugs-fixed/
New Directives & Features
Added optimisations:
Fixed bugs:
Added CI pipeline:
And more: