ngxhttpstripfiltermodule
August 18, 2026 · View on GitHub
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## Directivesbelow andCHANGES.
See also: nginx-strip-filter-module: CSS and JavaScript Minification — full write-up, benchmarks and config examples on deb.myguard.nl.
Features
| Content type | What 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/javascript | byte-identical by default; comment and whitespace removal with strip_aggressive only |
application/json | all structural whitespace |
image/svg+xml | XML comments (CDATA preserved); character-data whitespace collapse with strip_aggressive only |
text/xml, application/xml, *+xml | XML 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.
| Directive | Default | Description |
|---|---|---|
strip | off | Enable HTML minification |
strip_css | off | Enable CSS minification |
strip_js | off | Enable JavaScript minification |
strip_json | off | Enable JSON minification |
strip_svg | off | Enable SVG (image/svg+xml) minification |
strip_xml | off | Enable XML minification (text/xml, application/xml, any +xml subtype — RSS/Atom/sitemap) |
strip_aggressive | off | Restore 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_size | 0 | Skip bodies smaller than this (bytes) |
strip_max_size | 1m | Skip bodies larger than this (buffered whole) |
strip_types | text/html | Extra 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.sofilename 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 tests — strip_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 tests — ci/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.
| Workflow | Trigger | Gates |
|---|---|---|
ci.yml | PR (the only Linux pull_request entry point) | no gates of its own — lanes and dispatches every Linux PR-time member below |
build-test.yml | PR (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.yml | PR (via ci.yml) | flawfinder, clang-tidy, semgrep over the module sources |
fuzzing.yml | PR (via ci.yml) | 20s/target fast fuzz regression across all 6 strip kinds (html/css/js/json/svg/xml) |
asan.yml | PR (via ci.yml) | dedicated ASan+UBSan run of the Test::Nginx suite under a static build |
codeql.yml | PR (via ci.yml) + monthly | CodeQL |
valgrind.yml | weekly + 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.yml | monthly + dispatch | exhaustive dynamic analysis — long fuzz, full memcheck + helgrind soak, Discord failure notify |
bump.yml | weekly + dispatch | checks nginx.org/angie.software for newer pins, opens a PR against master if anything moved |
windows-build.yml | PR + push to master + dispatch | native 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(forci/t/),prove.clangwith libFuzzer support forci/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/syncbuffers 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 carryingflushorsyncwithout 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, bystrip_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 setstrip 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. Enablestrip_js/strip_cssto minify standalone.js/.cssfiles separately. strip_js on;is intentionally byte-identical unlessstrip_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_bufon 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.