ngxhttpstripfiltermodule

August 18, 2026 · View on GitHub

Build&Test Security Scanners Fuzzing A/UBSan CodeQL Valgrind CI Deep Windows build

A dynamic nginx response-body minifier for HTML, CSS, JavaScript, JSON, SVG, and XML. Its default mode favors semantic safety; the smaller byte-level JavaScript and whitespace transforms remain available as an explicit opt-in.

Upgrade note: output is now more conservative by default. JavaScript is passed through byte-for-byte because correct minification requires a real lexer/parser. CSS hex shortening and zero-unit stripping, plus HTML/SVG/XML character-data whitespace collapsing, are also opt-in. Set strip_aggressive on; to restore the historical byte-level behavior. See ## Directives below and CHANGES.

See also: nginx-strip-filter-module: CSS and JavaScript Minification — full write-up, benchmarks and config examples on deb.myguard.nl.

Features

Content typeWhat is stripped
text/html<!-- --> comments, boolean attrs (disabled="disabled"disabled), safe attribute-value unquoting (class="btn"class=btn); character-data whitespace collapse with strip_aggressive only
text/css/* */ comments, redundant whitespace, trailing ; before }, leading zeros (0.5.5); 6→3-digit hex colors and zero units with strip_aggressive only
application/javascript, text/javascriptbyte-identical by default; comment and whitespace removal with strip_aggressive only
application/jsonall structural whitespace
image/svg+xmlXML comments (CDATA preserved); character-data whitespace collapse with strip_aggressive only
text/xml, application/xml, *+xmlXML comments (CDATA preserved); character-data whitespace collapse with strip_aggressive only — RSS/Atom/sitemap

Smart, not brute: regions that must survive verbatim are passed through untouched:

  • HTML <pre>, <textarea>, <script>, <style>, <title>, <iframe>, <xmp>, <noembed>, and <noframes> element bodies
  • CSS and JSON string literals
  • In aggressive JavaScript mode, string, template, and regex literals plus newlines where Automatic Semicolon Insertion would fire

Runs before the compression filters so gzip/brotli/zstd compress already-minified bytes. Output is always <= input length. Unknown-length or chunked responses may occupy roughly twice their body size in request-pool memory while buffered (per-chunk snapshots plus one coalesced in-place buffer); a known Content-Length above strip_max_size bypasses before those buffers.

Directives

All directives are valid in http, server and location blocks.

DirectiveDefaultDescription
stripoffEnable HTML minification
strip_cssoffEnable CSS minification
strip_jsoffEnable JavaScript minification
strip_jsonoffEnable JSON minification
strip_svgoffEnable SVG (image/svg+xml) minification
strip_xmloffEnable XML minification (text/xml, application/xml, any +xml subtype — RSS/Atom/sitemap)
strip_aggressiveoffRestore the historical byte-level transforms: JavaScript comment/whitespace removal, CSS hex/zero-unit shortening, and HTML/SVG/XML character-data whitespace collapse. These can change valid input; default mode avoids them.
strip_min_size0Skip bodies smaller than this (bytes)
strip_max_size1mSkip bodies larger than this (buffered whole)
strip_typestext/htmlExtra MIME types treated as HTML

Quick start

load_module modules/ngx_http_strip_filter_module.so;

http {
    server {
        strip      on;       # HTML
        strip_css  on;
        strip_js   on;
        strip_json on;
    }
}

Per-location selective strip

location /api/ {
    strip_json on;
}

location /static/ {
    strip     on;
    strip_css on;
    strip_js  on;
}

Building

# dynamic module against an existing nginx source tree
./configure --with-compat --add-dynamic-module=/path/to/nginx-strip-filter-module
make modules
# result: objs/ngx_http_strip_filter_module.so

Or use ci/tools/ci-build.sh which downloads and builds nginx automatically:

bash ci/tools/ci-build.sh

Windows

The module supports both native Windows nginx toolchains:

  • MSVC builds the module statically with --add-module.
  • MinGW-w64 builds a loadable PE DLL with --add-dynamic-module; nginx keeps the conventional .so filename for that DLL.

The hosted Windows gate builds both variants, checks that nginx registered the module and its directive, rejects a bogus directive as a negative control, and compares raw and filtered in-memory HTML responses. It disables nginx's rewrite and gzip modules only to avoid unrelated PCRE and zlib build dependencies; the strip filter itself does not require either library.

Testing

Two suites, deliberately separate.

Core unit testsstrip_core.c has no nginx dependency, so its state machines are driven directly. No server, no Perl, no network; the whole suite runs in well under a second and emits TAP.

ci/tests/unit/run.sh

Request-path testsci/t/basic.t drives the filter through a real nginx via Test::Nginx::Socket, which is the right instrument for directive handling, content types and buffering.

TEST_NGINX_BINARY=/path/to/nginx \
TEST_NGINX_LOAD_MODULES=/path/to/ngx_http_strip_filter_module.so \
prove -v ci/t/

Coverage

The goal is 100% coverage of strip_core.c; the current figure is 99.81% under the unit suite below. The only uncovered line is js_regex_allowed()'s defensive trailing-space loop, whose comment explains why the current caller cannot reach it. That is the standard: every uncovered line either gets a real test or an honest note.

That figure covers strip_core.c under ci/tests/unit/ alone. ci/tools/coverage.sh reports a lower, whole-project number (it merges the nginx-typed module file and the live ci/t/ layer); the two measure different things and neither is a gate.

work=$(mktemp -d)
gcc -O0 -g --coverage -std=c11 -Isrc -o "$work/t" ci/tests/unit/test_scan.c src/strip_core.c
"$work/t" >/dev/null
gcov -o "$work" "$work/t-strip_core.gcda"
grep -n '#####' strip_core.c.gcov     # lines still needing a test or a note

There is no coverage-percent gate in CI, by design. The fastest way to move a coverage number is a test that executes lines without asserting anything. Test-group comments record the control mutations used while authoring the suite, including two cases that exposed vacuous assertions. CI does not run a mutation engine; those controls are review evidence, while exact output, negative-control, sanitizer, and fuzz checks are the automated gates.

The method is documented in full at nginx-test-harness/docs/COVERAGE.md and COVERAGE-HOWTO.md.

Layout

.
├── config                    # nginx module manifest (no ngx_module_order — see ABI gotcha in memory)
├── src/                       # the module: nginx glue + the nginx-independent core
│   ├── ngx_http_strip_filter_module.c
│   ├── strip_core.c           # (u_char*, size_t) in, verdict out — no nginx types
│   ├── strip_core_nginx_win32.c # native Windows precompiled-header adapter
│   └── strip_core.h
├── ci/
│   ├── tests/unit/            # standalone unit suite, drives strip_core.c directly
│   ├── t/                     # Test::Nginx::Socket request-path suite
│   ├── fuzz/                  # libFuzzer targets, one per content type, + seed corpora
│   ├── linter/                # local lint gate — see ci/linter/README.md
│   └── tools/                 # ci-build.sh, coverage.sh, sync-stamp.sh, bump scripts, ...
├── .github/workflows/         # see `## CI` below
├── .githooks/pre-commit       # tracked git hook — see CONTRIBUTING.md
└── CI_PERFORMANCE.md          # lane map + measured wall-clock, kept current every CI change

CI

For the Linux suite, only ci.yml has a pull_request trigger. Its PR-time workflows are workflow_call members it lanes, so a PR asks for one Linux run, not many. windows-build.yml is a separate GitHub-hosted gate because it needs the native Windows toolchains and consumes no self-hosted runner slot. ci/linter/lint-docs-drift.sh gates that this table and .github/workflows/ never drift apart — see ci/linter/README.md.

WorkflowTriggerGates
ci.ymlPR (the only Linux pull_request entry point)no gates of its own — lanes and dispatches every Linux PR-time member below
build-test.ymlPR (via ci.yml)build, Test::Nginx, ASan+UBSan, unit-core job (gcc+clang unit run of ci/tests/unit/test_scan.c against src/strip_core.c standalone, plus an ASan/UBSan unit run and an informational coverage report), ci/tools/sync-stamp.sh --check
security-scanners.ymlPR (via ci.yml)flawfinder, clang-tidy, semgrep over the module sources
fuzzing.ymlPR (via ci.yml)20s/target fast fuzz regression across all 6 strip kinds (html/css/js/json/svg/xml)
asan.ymlPR (via ci.yml)dedicated ASan+UBSan run of the Test::Nginx suite under a static build
codeql.ymlPR (via ci.yml) + monthlyCodeQL
valgrind.ymlweekly + dispatch (+ workflow_call)Test::Nginx suite once under Valgrind memcheck (lite soak) — deliberately removed from the PR lane (was the 769s budget-setter; PR-lane wall-clock went 12m52s → 5m59s); per-PR memory-safety coverage is asan.yml. See memory/labs/nginx-strip-filter-module/skeleton-findings.md § F-VG.
ci-deep.ymlmonthly + dispatchexhaustive dynamic analysis — long fuzz, full memcheck + helgrind soak, Discord failure notify
bump.ymlweekly + dispatchchecks nginx.org/angie.software for newer pins, opens a PR against master if anything moved
windows-build.ymlPR + push to master + dispatchnative MSVC x64 static build and MinGW-w64 x64 dynamic build; module registration, directive negative control, and live response-filter runtime test

There is no lint.yml in this module yet — the reference skeleton's fuller ci/linter/ (perlcritic, yamllint, zizmor, spelling, its own lint.yml runner) has not been ported; security-scanners.yml and build-test.yml cover the equivalent tools directly. See "Linting" below for what this repo does have.

Requirements

  • An nginx (or Angie) source tree, built with --with-compat, to build the dynamic module against.
  • gcc/clang, perl + Test::Nginx::Socket (for ci/t/), prove.
  • clang with libFuzzer support for ci/fuzz/.
  • See ci/linter/README.md for the local lint toolchain (flawfinder, semgrep, shellcheck, actionlint, ruff, clang-tidy).

Linting

Local lint gate lives under ci/linter/ — install with ci/linter/install.sh, run with ci/linter/run-all.sh. Full checker list, thresholds, the tracked git hook, and how it relates to .pre-commit-config.yaml: ci/linter/README.md.

Installing from deb.myguard.nl

curl -fsSL https://deb.myguard.nl/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/myguard.gpg
echo "deb https://deb.myguard.nl/ $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/myguard.list
sudo apt update && sudo apt install libnginx-mod-http-strip-filter

Then add to /etc/nginx/nginx.conf:

load_module modules/ngx_http_strip_filter_module.so;

Caveats

  • Whole-body buffering is intentional, and flush/sync buffers are not honoured. The filter accumulates the entire response body before minifying it, so a streamed or SSE-style response is held until it is complete rather than being forwarded incrementally. An upstream buffer carrying flush or sync without a terminal flag does not cause an early emit. This is a deliberate architectural constraint, not an oversight: the minifier keeps no lexer state between calls, so emitting a partial body would split it into independent minification passes and corrupt any comment or string that spans the seam, a correctness bug in exchange for latency. Buffering is bounded only in size, by strip_max_size: once the accumulated body exceeds that many bytes the filter stops buffering and passes the remainder through. That is a byte threshold, not a streaming switch, and the filter never identifies a response as streaming. An event stream or long-polling endpoint that stays under the limit is held until it completes, however long that takes, so set strip off; on those locations rather than relying on the size cap. Making the minifier resumable so flush can be honoured is tracked as future work.
  • Inline <script>/<style> bodies in HTML are preserved verbatim; they are not recursively minified. Enable strip_js/strip_css to minify standalone .js/.css files separately.
  • strip_js on; is intentionally byte-identical unless strip_aggressive on; is also set. The aggressive scanner is retained for compatibility but is not a complete ECMAScript lexer and can change valid programs.
  • Does not handle multi-part or chunked-encoded upstream responses that arrive in more than one chain beyond the last buffer — in practice nginx upstream modules always set last_buf on the final buffer of a response.
  • Attribute-value unquoting is HTML-only; SVG/XML attribute values always stay quoted (XML syntax requires it). CSS url(...) tokens are passed through verbatim (no whitespace/zero rewriting inside them).

License

BSD-2-Clause (same terms as nginx and Angie) — see LICENSE.