NewYour coding agent can read the release notes before it upgrades.Set up the MCP server →
Go modules · #2141 by repository stars
Last release 3 days ago
05 Oct 2026
Release timing varies
gaps range from 8 days to 3 months
Most releases are documented
notes for 34 of 43 stable releases
Nothing withdrawn
no release was ever pulled
2 years old
265 releases · first in 2025
One column per month.
docs(site): SEO metadata — canonical, JSON-LD, llms.txt, security.txt by @fabriziosalmi in #207
Full Changelog: v0.4.15...v0.4.16
xcaddy, caddy add-package, or from source) now needs Go 1.26 or newer. Bundled Caddy pin moved to 2.11.6.zap.Field slices for several debug logs in handlePhase were being built even at the default info level; they are now gated behind a single debug-level check. The benign-request benchmark drops from 152 to 59 allocations/op (−61%) and ~16 KB to ~9 KB/op; a blocked request drops from 96 to 68 allocations/op. Also stopped formatting an error for every absent extraction target (sentinel errors) and computing log-field redaction before a match.-race) coverage for the rate-limiter max_entries memory bound.rel=canonical, SoftwareApplication JSON-LD, llms.txt, /.well-known/security.txt (RFC 9116), descriptive titles, and max-image-preview:large.redact_sensitive_data default-on, single anomaly_threshold default of 5, and the metrics_allow_from / block_oversize_request_body directives).get_blacklisted_ip.py fetches the CI Army blocklist over HTTPS.Nothing published for this version
feat(tools): rewrite get_owasp_rules.py as a SecLang→caddy-waf translator by @seanthegeek in #203
Full Changelog: v0.4.14...v0.4.15
password/token/…), so a secret carried in a generic target (ARGS, BODY, the full HEADERS blob) was written verbatim even with redact_sensitive_data enabled. It now also redacts when the value carries a sensitive marker, and redact_sensitive_data is a nullable bool that defaults on for both Caddyfile and JSON (disable with redact_sensitive_data off / JSON false).block_oversize_request_body (off by default): a request body larger than max_request_body_size was forwarded to the upstream with only its prefix inspected, so a payload placed past the window evaded body rules. The oversize case is now always logged, and this directive makes it fail closed.200).metrics_allow_from: optional IP/CIDR allow-list restricting the dashboard/metrics/prometheus endpoints (default: no built-in restriction, as before).anomaly_threshold has a single default (5) shared by the Caddyfile and JSON sources (JSON previously defaulted to 20, silently four times more permissive).docker-compose.yml no longer binds Caddy's unauthenticated admin API to 0.0.0.0:2019 (back to the loopback default).caddywaf_rules_loaded Prometheus gauge and rules_loaded JSON metric, so an inert WAF (zero rules) is distinguishable from clean traffic — alert on == 0.go test -race), and a govulncheck workflow surfaces advisories on push/PR/weekly.rate_limit { max_entries } (default 1,000,000) bounds the rate-limiter key table so a flood of distinct source IPs cannot grow it without limit.get_owasp_rules.py is now a real SecLang → caddy-waf translator (was a regex scrape of SecRule lines that validated patterns with Python's re, ignored chains and t: chains, and could not read @pmFromFile keyword lists). It parses continuations, chained rules and quoted actions; ports @rx rules as-is and @pm/@pmFromFile lists as a case-insensitive alternation (sorted so Go's regexp factors the shared prefixes, which is 10–20× faster to match than an unsorted list); maps CRS variables onto targets (ARGS → ARGS + BODY, REQUEST_HEADERS:Host → HOST, XML → BODY, …) and t: chains onto the engine's transformations field; rewrites a leading ^/trailing $ on collection targets to a member boundary (ModSecurity matches each parameter value on its own; caddy-waf matches the raw query string); derives the score from the CRS severity and emits every rule as log so the output is advisory until log_scores_block is set; splits the output by paranoia level and by request/response phase; and applies a tuning file (<id> remove, <id> remove-target BODY, the caddy-waf equivalent of SecRuleUpdateTargetById). Every pattern is validated by tools/re2check, a small Go program that compiles it with the same regexp package the WAF uses, so a pattern RE2 rejects is skipped at generation time instead of failing to load. A generated COVERAGE.md lists every skipped rule with its reason (chained, non-regex operator, ^$ presence check, unsupported transformation, …) and every ported rule that lost a transformation, exclusion or anchor on the way. Unit tests in test_get_owasp_rules.py (run by CI). Documented in docs/scripts.md.rules/crs/: opt-in OWASP CRS v4.9.0 paranoia-level-1 bundles, generated by the translator above: crs-pl1.json (101 request rules: scanner UAs, protocol attacks, LFI/RFI, RCE, PHP/Java/Node injection, XSS, SQLi, session fixation) and crs-pl1-response.json (50 phase-4 rules: SQL/PHP/Java/IIS error leakage, directory listings, web-shell fingerprints). Every rule is action: "log" with its CRS severity as the score, so the bundle is advisory by default; log_scores_block with anomaly_threshold 5 gives CRS-style anomaly blocking. COVERAGE.md documents the 353 CRS rules that were not ported and why (260 control-flow, 57 chained, 19 non-regex operators such as @detectSQLi, …), and tuning.txt records the three rules that lose the raw BODY target because they match JSON/YAML/multipart framing (921150, 932130, 942190). The bundle complements rules.json rather than replacing it: CRS catches the classic ' OR 1=1-- tautology with libinjection, which has no regex form, and rules.json covers it. crs_bundle_test.go loads the bundle in CRS mode and pins 28 benign browser-shaped requests that must pass, 24 textbook attacks that must block, per-rule regex coverage, the advisory-by-default behaviour, and that known gap. Cost: about 0.6 ms per small JSON POST for the request bundle (BenchmarkCRSPL1BenignPOST), growing linearly with body size; see rules/crs/README.md.rules/crs/crs-pl2.json, crs-pl2-response.json, crs-pl3.json, crs-pl4.json: the OWASP CRS paranoia-level 2–4 rules (54 + 1, 22 and 5 rules), cumulative on top of PL1. CRS documents these levels as needing per-site tuning, and matching a raw body adds JSON/YAML syntax to what the stricter rules see, so TestCRSPL2BenignCorpusPinned pins exactly which benign corpus requests PL1+PL2 blocks (six of 29: JSON bodies via 942200, a GraphQL query via 942150, CI YAML via 941380, a prose field via 942200 plus the advisory 920230) and rules/crs/README.md explains how to tune them out. 932236 (2–3 character Unix commands after a separator) is removed in tuning.txt because the raw query string puts &id= in front of every id parameter.Shutdown. They previously had no cancellation, so every Caddy reload leaked a watcher goroutine and its inotify handle bound to the defunct module; they are now bound to the module via a stop channel and waited on at shutdown.logger.Check/ce.Write), removing a per-rule/per-target field-slice allocation at the default log level.xcaddy (v0.4.7) and the runtime base (alpine:3.21) and no longer bakes a GeoLite2 database from a community mirror; the release build's -ldflags -X now targets a real ModuleVersion symbol so the git tag is embedded in release binaries.Rule.Action is a named type (RuleAction) with block/log constants, and handlePhase was simplified by extracting the match dispatch.nanoid past its advisory (npm audit fix)..fossa.yml dependency inventory and the docs/introduction.md claim that the WAF has no native Prometheus endpoint (it does).wafVersionDefault to v0.4.15.Link the privacy notice from the docs footer by @fabriziosalmi in #195
Full Changelog: v0.4.13...v0.4.14
double-encoded-injection-chars rule (rules.json, rules-browser-friendly.json): blocks double-encoded quote / angle-bracket / NUL (%2527, %2522, %253C, %253E, %2500). Deliberately excludes %252F, which legitimate nested-URL parameters carry; double-encoded traversal is caught by a new ..%252F branch in path-traversal instead.sql-injection-body rule: high-confidence body-only SQLi (adjacent UNION SELECT, tautologies, timing functions, xp_cmdshell, load_file, INTO OUTFILE), split out of sql-injection so the main rule can tolerate prose in bodies without losing detection.fp_browser_regression_test.go (14 browser-shaped requests that each returned 403 before this change, plus pins that script-tag XSS, UNION SELECT, traversal, cloud-metadata SSRF, Java deserialization and header-borne SQLi still block), rules_rewrite_test.go (11 closed false negatives, 6 removed prose false positives) and rules/bundle_fixes_test.go (regex-level pins for every closed bundle bypass).log-action rules are observational by default and no longer block via score accumulation. Previously every matched rule, including action: "log", added its score to the single TotalScore that drives the block decision, so a handful of independent low-confidence log signals could sum past anomaly_threshold and return a 403 even though no rule intended to block (the v0.4.12 idor-attacks fix in #185 called out exactly this failure mode; on current main, 51 shipped log rules are scored at or above the default threshold of 5 and therefore blocked on their own on a single match — 4 in rules.json, the rest in the opt-in rules/ bundles). A log rule's score now accumulates into a separate advisory tally (WAFState.AdvisoryScore) that is logged (advisory_score on the request-completion record) and reported but does not affect blocking. block rules are unchanged: they block on match, and their scores still feed TotalScore.
log rules adding up to a block (CRS-style pure anomaly scoring), add the new Caddyfile directive log_scores_block (JSON: "log_scores_block": true) to restore the previous accumulation. Otherwise no action is needed; requests that were only ever blocked by accumulated log scores will now pass and be recorded with a non-zero advisory_score. Anything you want blocked should carry action: "block".Rewrote the default rules that blocked ordinary traffic or missed textbook payloads (rules.json and rules-browser-friendly.json, same change in both):
sql-injection allows content between keyword pairs but not a & (real SELECT col1, col2 FROM t is caught; ?select=name&from=2024 and "select an item from the list" are not), adds string tautologies (' OR 'a'='b), OR true/false, waitfor delay (the old pattern demanded a paren T-SQL never uses), pg_sleep, load_file, INTO OUTFILE. BODY moved to sql-injection-body.xss-attacks replaces its fixed event-handler whitelist with a generic in-tag on*= match accepting / as a separator (<svg/onload=…>, <details ontoggle=…>, <body onpointerdown=…> are now caught); dialog/eval calls require no space before the paren so "please confirm (by clicking)" does not match; the data: / style= / HTML-entity / %XX-anything branches that matched ordinary traffic are gone.rce-commands-expanded adds rm/nc/ncat/env/awk/sed/dd/chmod/chown/mkfifo, %0a/%0d separators and separator-anchored recon commands (uname/whoami/hostname/systeminfo); drops the HEADERS target (the joined Cookie header made ; id= match).block-scanners is word-bounded and deduplicated (bare zap blocked Zapier webhooks) and adds masscan/ffuf/dirsearch/feroxbuster/zaproxy.ssrf-attacks drops HEADERS (a LAN Referer like http://nas.local/ blocked intranet apps) and adds bracketed IPv6 and hex (0x7f000001) hosts; ssrf-reserved-ip no longer matches class E/broadcast, so mask=255.255.255.0 passes.crlf-injection-headers requires an encoded CRLF pair (a lone %0a appears in legitimately encoded Referer URLs), drops the raw \r/\n branches Go's server makes unreachable, and scores below the default threshold.path-traversal is case-insensitive and adds a ..%252F branch; insecure-deserialization-java drops the \xac\xed branch (Go regexp compiles \xac as U+00AC, never raw bytes) and matches hex in either case; sensitive-files drops the generic (config|backup|…).(json|yaml|…) branch that blocked /config.json.nosql-injection-attacks → nosql-operator-injection: matches a quoted "$op": key or [$op] parameter form instead of the bare words (count, find, db) the old rule matched in any JSON body.log4shell-jndi (in rules.json) replaces the removed log4j-14/15/16: catches ${…jndi…}, jndi: schemes and the nested ${${lower:j}ndi…} obfuscation without matching CI syntax like ${{ inputs.x }}.Fixed the rules/ category bundles' bypasses, duplicates and remaining false positives:
xxe.json: closed the single-quote (SYSTEM 'file://…'), parameter-entity (<!ENTITY % xxe SYSTEM …>) and no-space-before-[ bypasses; the DOCTYPE rule also catches external-DTD XXE and no longer matches <!DOCTYPE html>; dropped the pointless HEADERS target.xss.json: tag/handler rules accept / as a separator, use a generic on*= match (catches ontoggle, onpointerdown) and require no space before ( in alert(/eval( so "retrieval(cached)" no longer blocks.sql-injection.json: union(select…) caught; boolean tautologies match double-quote context; WAITFOR DELAY (no paren) caught; blind injection matches 2=2/'a'='a'/true; sqli-function-injection no longer blocks user(id: 1) GraphQL/ORM calls and drops below the threshold; sqli-limit-injection loses an unreachable alternation.ssti.json (13 → 5): the byte-identical {{…}}, {%…%}, ${…}, <%…%> duplicates are consolidated into ssti-curly-expression, ssti-block-statement, ssti-dollar-brace, ssti-erb-ejs-tag (a single ${…} used to stack 21–28 points and block CI YAML), scored below the threshold.lfi.json: lfi-windows-sensitive-files accepts either slash direction (it required forward slashes, so c:\windows\win.ini never matched); lfi-apache-config adds the RedHat layout; lfi-common-sensitive-files escapes a stray . in grub.cfg.rce.json: fixed the \b(?:;|…) construct whose word boundary made q=;rm unmatchable; added single-pipe coverage and separator-anchored recon commands; dropped the HEADERS target; rce-java-exec rewritten to catch Runtime.getRuntime().exec(, Runtime.exec( and ProcessBuilder( (its old trailing \s*\( required a double paren and never matched).ssrf.json: file/ftp/gopher rules catch the single-slash form (file:/etc/passwd) Java and curl accept.insecure-deserialization.json: Java hex form case-insensitive; PHP rule catches C: custom-serializable and namespaced classes; YAML rule catches !!python/object/apply: anywhere; prototype-pollution rule closes the "__proto__" : spacing bypass.graphql.json: introspection rule catches aliased/nested __schema and __type( probes.authentication.json: auth-jwt-algorithm-none → auth-jwt-alg-none, matching the base64url encodings of an alg:none header as they appear on the wire (the plaintext "alg":"none" never occurs in an encoded token).Bumped version constant wafVersion to v0.4.14.
rules.json / rules-browser-friendly.json: rfi-http-url and open-redirect-attempt (any Referer/Origin header is a URL), sql-injection-improved-basic (-{2,} matched every multipart boundary), unusual-paths (blocked /login, /admin, *.php), http-request-smuggling (its live branch blocked Content-Length: 0; the Transfer-Encoding branches are unreachable behind Go's server), the Sec-Fetch scoring pair browser-integrity-sec-fetch-mode-no-cors-document-log-score + …-site-cross-site-document-log-score (3+2 = threshold on every cross-site subresource), xss-improved-encoding (duplicate of xss-attacks plus a %XX-anything branch), allow-legit-browsers (+1 on every browser request, no allow semantics), sensitive-files-expanded (subset of sensitive-files, double-scored every hit). From the bundles: rce-11 ((?i)| id has an empty alternation and matched every request with action: block), rce-common-commands (\bid\b blocked every ?id=), rce-encoded-separators, rce-environment-variables ($\w+ matched $5), rce-os-info-commands, rce-process-manipulation (?top=10), deserial-python-pickle (g + any 4 bytes fired on nearly all traffic), graphql-union-type-abuse (matched the English word "on"), hpp-array-syntax (standard filter[a]=1), lfi-absolute-paths (every static path), lfi-encoded-slash-bypass (any %2f), rfi-http-url, rfi-compressed-files (?file=report.zip), sqli-alternative-encodings (any quote), sqli-basic-keywords ("delete from my list"), sqli-database-specific-keywords, sqli-exec-commands ("docker exec"), sqli-hex-encoded-injection (any 0x literal), sqli-mysql-specific-comments (any # or --), ssti-attacks ("set a date"), ssti-velocity-directive (any two #), xss-comment-bypass, xss-data-href, xss-encoded-html-entities (’), xss-expression-attribute, xss-style-attribute (?style=dark), xss-unescaped-quotes, xss-url-encoded (any percent-encoded byte), xss-base64-encode/-decode and xss-javascript-tags (not XSS vectors), xxe-internal-ip (10. in every Windows User-Agent), xxe-parameter-entity (%\w+; in urlencoded bodies), auth-basic-header-suspicious (every Basic-auth request), auth-weak-password-indicators (describes responses), and the redundant traversal subsets lfi-13, lfi-long-path-bypass, lfi-symlink-traversal, lfi-windows-path-traversal. Remote-URL RFI was deliberately not re-added: blocking any external http(s):// in a parameter breaks OAuth redirects and webhooks.rules.json and rules-browser-friendly.json copies count twice) whose pattern is structurally unable to match the traffic they describe, so deleting them changes no blocking or logging outcome. In rules.json and rules-browser-friendly.json: every ^$ "missing header/token" rule (auth-login-form-missing, csrf-missing-token-post, the four browser-integrity-sec-fetch-*-missing-* rules), which can never match because a missing header or empty body makes the extractor skip the rule; browser-integrity-sec-fetch-dest-not-document-ua-suspicious-log-score, whose target HEADERS:Sec-Fetch-Dest-Not is not a header any client sends; and jwt-tampering, whose ^eyJ anchor is defeated by the Bearer / cookie-name prefix it is matched against. In the rules/ bundles: rce-9 and log4j-14/15/16 (a mid-pattern $ end-anchor), xss-0/1/2 ((1) is a capture group, so alert(1) never matches), sqli-5 (* quantifies a space), lfi-data-wrapper and rfi-data-uri (require data://, which a data URI never has), lfi-windows-cifs (four backslashes where a UNC path has two), ssti-freemarker-directive (FreeMarker directives never end in #>), sqli-xpath-injection (demands = right after //node[), graphql-batching (needs a bare {query, which JSON never contains), ssrf-protocol-whitelist (^https?:// anchored against k=v / /path), ssrf-redirects (Location/Redirect are response headers), hpp-duplicate-parameters (cannot consume the & between pairs), auth-jwt-no-signature (same anchor problem as jwt-tampering), auth-no-cookies-set (Set-Cookie is a response header), auth-login-form-missing (^$), and sqli-null-byte (the default transformation chain strips NUL before matching, and net/http rejects a raw NUL in headers or the URL). dead_rules_test.go pins each removed pattern against the payload its description advertises and the ^$ skip behaviour, so the deletions are verifiable.rules/ category bundles that provided no usable coverage. None of these files is loaded by default; they were opt-in bundles.
rules/smuggling.json (6 rules). Go's net/http server strips Transfer-Encoding from r.Header and rejects conflicting Content-Length/Transfer-Encoding combinations and duplicate Content-Length before the WAF runs, so hrs-cl-te-mismatch, hrs-multiple-content-length, hrs-chunked-encoding-without-terminator and hrs-te-with-non-compliant-chars could never see what they looked for (two of them also required a \n in the joined HEADERS string, which never contains one). The two rules that could fire were false positives: hrs-invalid-te matched gzip anywhere (every Accept-Encoding: gzip request) and hrs-content-length-zero logged every legitimate Content-Length: 0 POST/PUT.rules/csfr.json (7 rules). csrf-missing-referer, csrf-missing-token-args, csrf-missing-token-post and csrf-token-not-present-in-cookies are ^$ presence checks, which this engine never evaluates (a missing target skips the rule). csrf-token-name-patterns and csrf-double-submission-cookies logged exactly the well-protected traffic (every form carrying a csrf/nonce token, every site using double-submit cookies), and csrf-token-length-check matched any 32+ character token. CSRF cannot be decided from one request by regex; it needs the application's server-side token check.rules/data-validation.json (5 rules). data-validation-invalid-email, -invalid-phone and -invalid-date use (?:…){0,} / "any non-matching character somewhere" constructions that match every request; data-validation-int-overflow (\d{15,}) logged Snowflake IDs and nanosecond timestamps; data-validation-long-string logged every body over 5000 characters (minified JSON, base64 uploads) and is bypassed by one newline.docs/attacks.md records the retirement for the smuggling and CSRF categories. Anyone who still wants the old patterns can copy them from the last release that shipped them.action field was never read. Rule.Action was tagged json:"mode", but every shipped rule file (and the documentation) keys it "action", so Action unmarshalled to the empty string for every rule. In practice action: "block" never triggered an explicit block: all blocking happened only when accumulated scores crossed anomaly_threshold, and the log-vs-block distinction did not exist at runtime. The tag is now json:"action", and mode is still accepted as an alias (with action winning when both are present) so rule files written against the earlier documentation, which named the key mode, keep working. docs/rules.md now documents action as the canonical key. Two consequences worth knowing when upgrading: (1) a block rule now blocks on its first match regardless of score, which matters if your anomaly_threshold is higher than a rule's score (no rule shipped in rules.json or the rules/ bundles is scored below the Caddyfile default of 5, but the JSON-config fallback threshold is 20, and 23 rules.json block rules score below that); (2) the load-time validation that only accepts block or log is now effective, so a custom rule with any other action value fails to load instead of silently running as a score-only rule. rule_action_test.go pins both the unmarshalling and the runtime effect.perf: static dispatch for value extraction — ~67% fewer allocs/request ( #115 ) by @fabriziosalmi in #188
Full Changelog: v0.4.12...v0.4.13
rules.json went from 1790 → 331 allocations/op and 170KB → 47KB/op (≈122µs → ≈80µs), with no behaviour change:
atomic.Int64, so the per-request path takes no metrics lock (#190).compressWhitespace returns the input unchanged when nothing needs folding (#192).wafVersion to v0.4.13.Nothing published for this version
fix: stop idor-attacks and rce-commands-expanded blocking ordinary requests ( #185 ) by @fabriziosalmi in #186
Full Changelog: v0.4.11...v0.4.12
rules.json/rules-browser-friendly.json:
idor-attacks matched any common parameter name (id=, user=, file=, download=, …) and any /<digits>/ path segment, and its score: 7 made it block on its own despite action: log. It now matches only opaque object references (a UUID, or a 32/40-hex path segment) as a non-blocking low-score log signal — IDOR cannot be decided from the request alone.rce-commands-expanded matched bare words (cat, id, ls, curl, wget, python, …), so ?cat=animals, ?id=5 and curl/wget/python User-Agents were all blocked. It now requires an injection context: a command after a shell metacharacter (; | ` $(), a command reading a sensitive path, curl/wget fetching a URL, or a pipe to a shell.fp_regression_test.go pinning that benign params/bodies/User-Agents pass and real command injection is still blocked.test: ReDoS resistance audit + corpus, document the RE2 guarantee ( #111 ) by @fabriziosalmi in #181
Full Changelog: v0.4.10...v0.4.11
path-traversal-body rule catches LFI delivered through a POST body (form fields, JSON) — a repeated ../ or a direct sensitive-file path (etc/passwd, /proc/self/environ, …) — while a single legitimate ../ in a body does not trip it. The previous path-traversal rule covered only the URI and headers.geoip_fail_open Caddyfile directive (#113). The knob that flips a failed GeoIP lookup from block (403, the secure default) to allow was previously reachable only through raw JSON.lfi.json, rfi.json), rewrote data-validation's over-cap repeat, removed backreference rules (hpp, sql-injection), and removed the non-functional ModSecurity dump rules/spiderlabs.json. TestBundledRulePatternsCompile now compiles every rules/*.json bundle and rejects duplicate IDs. New rules/README.md documents the shipped set vs the opt-in bundle menu.wafVersion to v0.4.11.Nothing published for this version
fix: audit modular rules/*.json — invalid JSON + RE2-incompatible patterns ( #172 ) by @fabriziosalmi in #179
Full Changelog: v0.4.9...v0.4.10
wafVersion to v0.4.10.rules/*.json bundles (pointed at with rule_file, not loaded by the default rules.json) are now all valid and RE2-compatible:
rules/lfi.json and rules/rfi.json (single-backslash regex escapes JSON rejected).data-validation's ^.{5000,}$ (over RE2's 1000 repeat cap) as concatenated .{1000} runs.hpp-parameter-combining, sqli-quoted-injection).rules/spiderlabs.json — a raw ModSecurity CRS export whose @rx/@eq/@pmFromFile operator syntax this engine does not interpret, so its rules never matched. Generate an RE2-compatible bundle with get_spiderlabs_rules.py instead.TestBundledRulePatternsCompile now compiles every rules/*.json bundle under RE2 and rejects duplicate IDs, so a broken bundle fails CI instead of shipping inert.rules/README.md documenting the shipped set vs. the opt-in bundle menu and the RE2 pattern constraints.Nothing published for this version
Update URL for hagezi ultimate DNS blocklist by @mcbloch in #150
Full Changelog: v0.4.8...v0.4.9
prometheus_endpoint <path> serves the WAF counters and a caddywaf_request_duration_seconds latency histogram in the Prometheus text exposition format — scrape it directly, no exporter. Latency is recorded lock-free on the hot path.wafVersion to v0.4.9.refactor(dashboard): modular, decoupled UI + automatic light/dark theme ( #143 ) by @fabriziosalmi in #170
Full Changelog: v0.4.7...v0.4.8
ui/index.html (structure), ui/dashboard.css (presentation), ui/dashboard.js (behaviour, in decoupled config/store/view/api modules) — served beneath the dashboard path. The theme follows the viewer's system light/dark preference automatically, with an explicit override honoured. No behaviour change for operators; the directive and opt-in are unchanged. (#170, #143)wafVersion to v0.4.8.dashboard path configured with a trailing slash now matches and serves its assets correctly.feat: built-in dashboard UI shell (M2) — self-contained, opt-in ( #143 ) by @fabriziosalmi in #168
Full Changelog: v0.4.6...v0.4.7
metrics_endpoint; a single self-contained page with no vendored libraries and no third-party runtime requests. Off unless enabled at two levels: the with_ui build tag and the dashboard <path> directive. Read-only — it cannot mutate WAF state — and carries no auth of its own, so protect it with Caddy (see docs/dashboard.md). (#168, #143)wafVersion to v0.4.7.ui/ (a page hardcoding localhost:8080 plus ~1 MB of vendored chart.js and font-awesome).feat: dashboard M1 backend — recent blocks, top IPs, by-country, reasons ( #143 ) by @fabriziosalmi in #166
Full Changelog: v0.4.5...v0.4.6
/waf_metrics gains a schema_version (2) and a set of back-compatible fields for the planned built-in dashboard (#143): recent — a bounded ring (256) of the most recent blocked decisions (timestamp, log id, client IP, method, path, reason, rule id, status, score, country); top_ips — the top offending client IPs by block count (bounded); by_country — blocks per ISO country (wiring up the previously unused per-country counter); blocked_by_reason; top_rules; and server_time_ms + uptime_seconds so a client can derive rates by diffing snapshots. Recorded once per block under a single mutex; all sections are bounded. Every pre-existing metrics field is unchanged. See docs/metrics.md.wafVersion to v0.4.6.feat: trusted_proxies + XFF-aware client IP (rate limiter, GeoIP/ASN, REMOTE_IP) by @fabriziosalmi in #163
Full Changelog: v0.4.4...v0.4.5
trusted_proxies: X-Forwarded-For is no longer trusted by default. The GeoIP country/ASN checks previously honoured X-Forwarded-For unconditionally, so a client reaching the WAF directly could spoof it to move its apparent IP past those filters. Client-IP resolution now has a trust boundary: forwarding headers are honoured only when the immediate peer is a configured trusted proxy, otherwise the peer address is used and the header is ignored. (#163, closes #94)trusted_proxies (bare IPs, CIDR ranges, or private_ranges) — the peers allowed to speak for their clients. When the peer is trusted, the real client is taken from client_ip_header (e.g. CF-Connecting-IP) if set, else by walking X-Forwarded-For right-to-left to the first non-trusted address (resolving a trusted proxy chain). The resolved client IP now feeds the rate limiter (so a CDN's edges no longer share one bucket), the GeoIP country/ASN filters and the REMOTE_IP rule target. The IP blacklist is unchanged (peer + all hops). See Client IP & trusted proxies.wafVersion to v0.4.5.REMOTE_IP now yields a bare IP (no port), consistent with the resolved client IP.getClientIP helper that trusted X-Forwarded-For unconditionally.This changes a default in the secure direction. If you run behind a CDN or reverse proxy and rely on filtering by the real client's country/ASN — or on per-client rate limiting, or REMOTE_IP rules — set trusted_proxies (and, for header-based CDNs like Cloudflare, client_ip_header). Without it, those controls now judge the proxy's address instead of the client's. Full guidance and a Cloudflare example: docs/client-ip.md.
fix(rules): stop SSRF rules blocking benign query strings (socket.io) by @fabriziosalmi in #160
Full Changelog: v0.4.3...v0.4.4
ssrf-internal-ip and ssrf-reserved-ip matched bare prefixes (10\., 0\., 224\., …), so any digit-dot substring tripped them — a legitimate /socket.io/?EIO=4&transport=polling&… request whose Engine.IO cache-buster or sid contained something like 0.1 or 10. accrued SSRF score until it hit a 403 at anomaly_threshold 20. The patterns now require full private/reserved dotted-quad IPs with word boundaries, so benign values no longer match while real SSRF still does — internal ranges (RFC1918, loopback), 0.0.0.0, link-local 169.254.0.0/16 (the cloud metadata IP), and multicast/reserved. ssrf-attacks' bare cloud-keyword alternative was likewise narrowed to the actual metadata hostname. Applied across rules.json, rules-browser-friendly.json and rules/ssrf.json. (#160)wafVersion to v0.4.4.`whitelist_file` — load whitelisted IPs from a file, hot-reloaded on change. The counterpart to ip_blacklist_file: point it at a text file of IPs/CIDR
whitelist_file — load whitelisted IPs from a file, hot-reloaded on change. The counterpart to ip_blacklist_file: point it at a text file of IPs/CIDR ranges (one per line, # comments allowed) exempt from the IP-reputation checks, so a job that refreshes the list (a cloud provider's published ranges, a partner's egress IPs, country-blocking exceptions) takes effect without a restart. Inline whitelist_ip entries and the file feed the same trie; the file need not exist at startup and is picked up when it appears. (#158, closes #151)wafVersion to v0.4.3.Every `POST` carrying a body no longer fails with a 502. The WAF drained the request body during rule inspection and the upstream received an empty bo
POST carrying a body no longer fails with a 502. The WAF drained the request body during rule inspection and the upstream received an empty body while Content-Length kept its value, so reverse_proxy reported ContentLength=N with Body length 0 and broke the connection. Rule extraction runs against per-rule request copies (handlePhase clones the request to tag the rule id), so restoring the body there never reached the request handed to the upstream. The body is now buffered once, up front, on the request that flows downstream and read from context during extraction; bodies within the inspection limit are re-readable with GetBody set, and larger bodies are forwarded whole (no upload truncation). (#156)Write, so an atomic update (write temp + os.Rename over the target — the standard blocklist/rule refresh) replaced the inode and left the watch permanently deaf. It now watches the parent directory, filters by basename, and reloads on Create/Rename/Write. (#155)isIPBlacklisted probed m.ipBlacklist outside the read lock, racing ReloadConfig's locked swap (which hot reload performs). The redundant unsynchronised probe was removed; go test -race ./... is clean. (#155)wafVersion to v0.4.2.main: stretchr/testify 1.12.1, google.golang.org/grpc 1.83.1, and the Docker builder image to Go 1.27-alpine.fix: response-target rule in an early phase panics the whole request ( #144 ) by @fabriziosalmi in #146
Full Changelog: v0.4.0...v0.4.1
RESPONSE_HEADERS (or RESPONSE_HEADERS:<name>) in phase 1 or 2 — as several OWASP CRS rules do, e.g. 950010 — reached w.Header() on the nil http.ResponseWriter that handlePhase passes before the response exists. The panic was recovered into an HTTP 500, so every request behind the WAF failed. Response-header extraction is now nil-safe (mirroring the existing response-body guard): an out-of-phase response target degrades to a skipped target instead of crashing. Reported on OPNsense with OWASP rules (#144, #146).wafVersion to v0.4.1.TestTorConfig_Provision is now hermetic — it serves the Tor exit-node list from a local httptest.Server instead of a live external CDN, removing the last third-party network dependency from CI (#147).feat: input normalization for rule matching (v0.4.0) — close encoding evasion by @fabriziosalmi in #142
Full Changelog: v0.3.11...v0.4.0
Rule matching now inspects requests the way the application will decode them, closing encoding evasion. Confirmed empirically before the fix: a percent-encoded attack in a raw request target slipped past literal rule patterns. rules/sql-injection.json blocked id=1 UNION SELECT but not id=1 %55NION%20SELECT, nor %75nion, nor the same payload in an application/x-www-form-urlencoded body. Because the application decodes the request before acting on it, the WAF must match the decoded form. This affected the raw targets ARGS, URI, URL and BODY — the targets used by the bulk of the bundled and modular rules (SQLi, XSS, RCE, SSTI, SSRF).
The fix is additive dual-match: each target is matched against the raw value first, then against a normalized copy, and the rule fires if either matches. Testing the raw value first is a mathematical guarantee that no rule which matches today can stop matching — the change only adds coverage, never removes it. Unencoded traffic runs no extra regex (the normalized pass is skipped when normalization does not change the value).
Design followed ModSecurity/OWASP-CRS/Coraza prior art, including its guardrails:
%2555 decodes to the literal %55, matching what a single-decoding backend sees; decoding twice would manufacture false positives and diverge from reality.+: a space in query/body context, literal in the path portion of URI/URL, which are split on the first ?.%, %zz, a truncated %a) is left literal, never dropped — Go's url.QueryUnescape blanks the whole value on one bad byte, which would itself be a bypass.transformations field (ModSecurity/CRS-style pipeline): ["urlDecodeUni","removeNulls","replaceComments","htmlEntityDecode",…]. Absent means the per-target default chain (urlDecode, removeNulls, compressWhitespace); an explicit [] means match the raw value only. Names are case-insensitive, accept a t: prefix, and an unknown name fails at load time rather than silently doing nothing.REQUEST_HEADERS→HEADERS, REQUEST_COOKIES→COOKIES, QUERY_STRING→ARGS, REQUEST_URI→URI, REQUEST_BODY→BODY, REQUEST_FILENAME→PATH. Previously these fell through to "unknown extraction target" and were skipped, so 135 bundled rules (88 using REQUEST_COOKIES, 47 REQUEST_HEADERS) silently lost cookie/header coverage and one rule was fully inert.transform.go with the transformation registry and lenient single-pass decoders; normalization_test.go and transform_test.go covering the closed evasions, the zero-regression property, per-rule transformations, the aliases, load-time validation, and — as an explicit test — the documented limit that double-encoding is not decoded twice.Single-pass decoding does not catch double-encoding (correct against a single-decoding backend), and %uXXXX / overlong-UTF-8 are not decoded by the default pipeline. Cookie and header targets are not normalized by default; a rule that needs it can set transformations. See Input normalization.
This changes what every rule targeting ARGS/URI/URL/BODY sees. Because matching is additive (raw tested first), no existing rule stops firing and no config change is required. Rule authors who want the raw, un-normalized value only can set "transformations": [] on a rule. Custom rule files using ModSecurity target names now gain coverage they were silently missing.
wafVersion to v0.4.0.docs: document the published container images by @fabriziosalmi in #134
Full Changelog: v0.3.10...v0.3.11
whitelist_ip — exempt addresses from the IP-reputation checks without switching the WAF off for them. Accepts bare IPs, CIDR ranges, or the token private_ranges, and is repeatable:
whitelist_ip private_ranges
whitelist_ip 203.0.113.4 198.51.100.0/24
Requested in #137 by @nozonyan: whitelist_countries blocks anything it cannot geolocate, which includes every address on the local network, so enabling it locks you out of your own service from inside the LAN. The only workarounds were geoip_fail_open — which also admits every unresolvable public address — or maintaining two site blocks with separate rule sets, with the risk of leaving the public one unprotected after a test.
The exemption covers the checks that judge a client by where it comes from: the IP blacklist (including Tor exit nodes fed into it), whitelist_countries / block_countries, and block_asns. It deliberately does not cover the DNS blacklist (which judges the requested host, not the client), the rate limiter, or the regex rules in any phase. Exempting an address from geolocation is the fix; stopping inspection of its requests is not.
private_ranges expands to exactly the set Caddy uses for its own placeholder — 192.168.0.0/16, 172.16.0.0/12, 10.0.0.0/8, 127.0.0.1/8, fd00::/8, ::1. Identical rather than "improved": a WAF and the server in front of it disagreeing about which addresses are private is how bypasses get built. An entry that does not parse fails startup rather than being skipped with a warning.
X-Forwarded-For. This is the deliberate opposite of the blacklist, which checks the peer address and every forwarded hop. When blocking, consulting extra addresses can only block more; when allowing, honouring a client-supplied header would let anyone send X-Forwarded-For: 10.0.0.1 and exempt themselves from the blacklist, the country filter and the ASN filter in a single header. Covered by TestWhitelistIgnoresForwardedHeaders.private_ranges is only safe when caddy-waf is the edge. Because the check is on the peer address, running behind another proxy makes the peer that proxy — typically a private or loopback address — which would exempt every request passing through it. The WAF now logs a warning at startup when private_ranges is whitelisted, and docs/configuration.md documents the trap.wafVersion to v0.3.11.Nothing published for this version
feat: publish container images to ghcr, and make docker build use the source by @fabriziosalmi in #133
Full Changelog: v0.3.9...v0.3.10
docker build . ignored your source tree. The Dockerfile ran git clone https://github.com/fabriziosalmi/caddy-waf.git and built that, so the build context was never used: the image contained whatever happened to be on main at build time, could not be pinned to a version, and a CI image build would have tested the wrong code. The build context is now the source.go.mod requires. golang:1.24-alpine against a module declaring go 1.25.1; it only worked because GOTOOLCHAIN=auto silently downloaded a newer toolchain mid-build. Now golang:1.26-alpine.ghcr.io/fabriziosalmi/caddy-waf, built on release tags for linux/amd64 and linux/arm64. Tagged by version as well as latest, so a deployment can pin — latest alone would leave anyone who pulled before a security release with no way to name the image they wanted..github/workflows/docker.yml builds the image on pull requests without pushing, and asserts caddy list-modules reports http.handlers.waf rather than trusting a green build. Nothing built this image before, which is how it came to clone the repository instead of using the context, and to pin a stale Go version..dockerignore extended so the context excludes node_modules, docs/, tests and helper scripts. ui/ is deliberately kept: assets.go embeds it behind the with_ui build tag.wafVersion to v0.3.10.fix: ReloadRules deadlock, DNS blacklist bypass, IP blacklist data race by @fabriziosalmi in #132
Full Changelog: v0.3.8...v0.3.9
Two further defects in the same subsystem, one of them the sibling of a bug fixed in v0.3.7. Both were surfaced by an adversarial sweep that drives real requests through ServeHTTP and asks whether the client is actually refused, rather than whether a helper returns true.
ReloadRules had the identical self-deadlock fixed in ReloadConfig. It took m.mu and then called loadRules, which takes m.mu again; the goroutine blocked forever while owning the write lock, so every later request stalled on the RLock in the request path. This is the primary hot-reload branch: startFileWatcher routes any changed path containing "rule" to ReloadRules, which means editing rules.json — the case docs/dynamicupdates.md documents — wedged the server. v0.3.7 fixed one branch of the watcher and missed the other. Found by the automated review on #130.
The DNS blacklist was bypassable, and inert on non-default ports. isDNSBlacklisted only lowercased and trimmed the Host header, so evil.example:8080 and evil.example. both missed an entry for evil.example. r.Host carries the port whenever the site is served on anything other than 80/443 — which every example in this repository does — so those deployments had no DNS filtering at all; and a client may send an explicit :443 even on the default port, making it a one-header bypass. Hosts are now normalised (lowercase, port stripped, trailing dot removed, IPv6 brackets removed).
ReloadConfig swapped m.ipBlacklist under m.mu, but isIPBlacklisted read it without taking the lock, so the swap never synchronised with in-flight requests. The read is now under RLock, mirroring isDNSBlacklisted. Found by the automated review on #130.X-Forwarded-For behaviour ("first XFF value if present, otherwise r.RemoteAddr"), which stopped being true when that bypass was closed.TestReloadRulesDoesNotDeadlock — the branch v0.3.7 missed, asserted on a deadline and followed by a reader that must get through.-race.go.mod declares it because caddy/v2 v2.11.4 and go.step.sm/crypto require it and Go propagates the maximum; forcing 1.25.0 breaks the build.wafVersion to v0.3.9.fix: a forged X-Forwarded-For bypassed the IP blacklist entirely by @fabriziosalmi in #131
Full Changelog: v0.3.7...v0.3.8
A single request header bypassed the IP blacklist entirely. Phase 1 consulted X-Forwarded-For instead of r.RemoteAddr whenever the header was present:
if xForwardedFor != "" {
if m.isIPBlacklisted(firstIP) { block }
// no else -- r.RemoteAddr was never checked
} else {
if m.isIPBlacklisted(r.RemoteAddr) { block }
}
Any blacklisted client could send X-Forwarded-For: 8.8.8.8 and skip the check. No tooling, no preconditions, no authentication — one arbitrary header. Demonstrated end to end: the same client is refused with 403 without the header and served 200 with it.
The peer address is now checked first and unconditionally, since it is the only value a client cannot forge, and the forwarded chain is checked in addition rather than instead. Checking more addresses can only block more, never less. A client can therefore blacklist itself by forging a listed address, which is harmless. Deciding which forwarded values to trust requires a trusted_proxies option and is tracked in #94.
This was masked until v0.3.7: before that the blacklist was never populated at all (see v0.3.7), so nothing was bypassable because nothing was enforced. Fixing enforcement made this the live bypass, which is why it ships one release later.
Covered by GHSA-w6gv-76q4-prqg, updated to reflect v0.3.8 as the patched version.
TestBlacklistedIPIsBlockedEndToEnd/a_forged_X-Forwarded-For_cannot_skip_the_check — a blacklisted peer sending a clean X-Forwarded-For must still be refused.wafVersion to v0.3.8.fix: the IP blacklist never blocked, and hot reload deadlocked the server by @fabriziosalmi in #130
Full Changelog: v0.3.6...v0.3.7
Two defects in the blacklist subsystem, both silent. Reported in substance by @doogienz in discussion #96 on 2026-05-21, with the log evidence that pinned it down.
The IP blacklist never blocked anything. loadIPBlacklist took the trie by value, and both callers — Provision and ReloadConfig — dereferenced their pointer to satisfy that signature. Every Insert therefore landed in a copy discarded on return: the trie the middleware consults stayed empty, while the loader still logged IP blacklist loaded {"valid_entries": N}. Any deployment relying on ip_blacklist_file, including the 223,770-entry list bundled with the project, had no IP filtering at all and no indication of it. Present since v0.0.7 (commit c905277, "switch to go-trie", 2025-10-10) — 15 releases.
Hot-reloading a blacklist deadlocked the server. ReloadConfig held m.mu and then called loadRules, which takes m.mu again; on Go's non-reentrant RWMutex the goroutine blocked forever while still owning the write lock. Since isDNSBlacklisted takes m.mu.RLock() on every request, all subsequent requests blocked forever — no crash, no log line. The file watcher calls ReloadConfig whenever ip_blacklist_file or dns_blacklist_file changes, and the documented Tor setup (docs/dynamicupdates.md) points ip_blacklist_file at the file the Tor fetcher rewrites every update_interval (default 24h), so the configuration the docs recommend wedges the server within a day of starting, unattended.
Both are fixed: the trie is passed by pointer, and ReloadConfig builds the new structures outside the lock, swaps them under it, and never holds m.mu across a call that takes it again.
blacklist_enforcement_test.go — four regression tests: the trie is actually populated (IPv4, IPv6, CIDR, with and without a port), the reload path repopulates it, ReloadConfig completes and releases the lock under a deadline, and a full ServeHTTP pass confirms a blacklisted client is refused, never reaches the upstream, and that X-Forwarded-For is honoured.wafVersion to v0.3.7.docs: caddy-waf is registered in Caddy's package registry by @fabriziosalmi in #122
Full Changelog: v0.3.5...v0.3.6
Cleared the Dependabot backlog on the default branch: 25 of 30 open alerts — 7 critical, 5 high, 13 moderate. Every bump was verified by building and testing, not by trusting the suggestion.
| Module | From | To | Alerts closed |
|---|---|---|---|
golang.org/x/crypto |
v0.49.0 | v0.52.0 | 7 critical, 2 high, 4 moderate |
github.com/caddyserver/caddy/v2 |
v2.11.2 | v2.11.4 | 2 high, 3 moderate |
google.golang.org/grpc |
v1.79.3 | v1.82.1 | 1 high |
golang.org/x/net |
v0.52.0 | v0.55.0 | 1 moderate |
github.com/quic-go/quic-go |
v0.59.0 | v0.59.1 | 1 moderate |
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp |
v1.43.0 | v1.44.0 | 1 moderate |
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp |
v0.19.0 | v0.20.0 | 1 moderate |
No source changes were required. go build, go vet and the full unit suite pass, and xcaddy build against the bumped tree produces a working Caddy v2.11.4 binary that registers http.handlers.waf.
Five alerts remain open. Leaving them undocumented would be worse than leaving them open.
github.com/google/cel-go (moderate, GHSA-gcjh-h69q-9w9g) — the suggested v0.29.0 does not compile against Caddy v2.11.4: interpreter.NewCall changed from []interpreter.Interpretable to []interpreter.InterpretableV2, and caddyhttp/celmatcher.go still passes the former. Caddy's own go.mod pins v0.28.1. Taking the bump would trade a moderate transitive advisory for a build that does not exist. Blocked until Caddy updates.vite ×3 (1 high, 2 moderate) and esbuild ×1 (moderate) — introduced in v0.3.5 by the package-lock.json for the VitePress docs site. All four are development-server issues (arbitrary origins reading dev-server responses; server.fs.deny bypass on Windows; NTLMv2 disclosure via UNC paths on Windows; path traversal in optimized-deps .map handling). CI only ever runs vitepress build, the published site is static HTML, and npm audit --omit=dev reports zero. The fix requires vite ≥ 6.4.3, which no stable VitePress pulls — latest is 1.6.4 and pins vite ^5.4.14; only the 2.0.0-alpha line moves to vite 6. Running an alpha documentation generator to silence dev-only advisories is the worse trade.wafVersion to v0.3.6.Nothing published for this version
Nothing published for this version
docs: refresh SECURITY.md supported versions and reporting channel by @fabriziosalmi in #120
Full Changelog: v0.3.4...v0.3.5
Caddy package registry could not scan this module. caddy.RegisterModule(&Middleware{}) parses as an ast.UnaryExpr wrapping the literal, and the static analyzer behind https://caddyserver.com/account/register-package accepts only a composite literal or new(). Every registration attempt therefore failed with the opaque portal error unable to scan modules in package github.com/fabriziosalmi/caddy-waf, which never names the offending line — leaving the module absent from https://caddyserver.com/download and caddy add-package github.com/fabriziosalmi/caddy-waf returning HTTP 400. Both caddy.RegisterModule and ModuleInfo.New now use new(Middleware).
The two forms are semantically identical (each allocates a zeroed Middleware and yields a pointer), so there is no behavioural change. The pointer is required regardless: CaddyModule has a pointer receiver and Middleware carries mutexes that must not be copied.
Diagnosis courtesy of the Caddy community thread Unable to register module in the portal, where the underlying analyzer error is quoted as unexpected argument to RegisterModule(): &ast.UnaryExpr{...} - expect either composite literal or new().
TestRegisterModuleArgumentIsScannable — parses the package's own AST and asserts the caddy.RegisterModule argument stays a composite literal or new(), so the registry constraint cannot silently regress on a future edit. Verified to fail against the v0.3.4 pattern and pass against the fix.CADDY_MODULE_REGISTRATION.md, which was stale (referenced v0.0.6 and Caddy v2.9.1) and speculated that the failures were server-side and "may resolve automatically". It now records the verified root cause and the maintenance notes.wafVersion to v0.3.5.With the scan fixed, github.com/fabriziosalmi/caddy-waf was claimed in Caddy's package registry on 2026-07-28 at 10:05:52 UTC, at v0.3.5. The build service now serves it (GET /api/download?p=github.com%2Ffabriziosalmi%2Fcaddy-waf returns a binary instead of HTTP 400), so caddy add-package github.com/fabriziosalmi/caddy-waf works and the module is selectable on https://caddyserver.com/download. README.md, docs/installation.md and docs/add-package-guide.md updated accordingly — they previously documented the install path as unavailable.
Note the module documentation shown on caddyserver.com is extracted from the doc comment on the Middleware struct in types.go.
vendor: self-host CDN assets same-origin (kill third-party egress) by @fabriziosalmi in #98
Full Changelog: v0.3.3...v0.3.4
Fixed unbounded response buffering (GHSA-gfj3-cmff-q8wh, CWE-400, CVSS 3.1 7.5 high, remote unauthenticated DoS). Up to and including v0.3.3, responseRecorder accumulated the entire upstream response body in an in-memory bytes.Buffer before releasing a single byte to the client, with no configurable or hard-coded ceiling. A single unauthenticated request for a large or streaming resource made the Caddy process's heap grow in step with the response size, so an attacker could OOM-kill the process and take down every site served by that instance. Reported by @EQSTLab.
The response body is now buffered only when it can actually be used, and only up to a bound:
ServeHTTP now asks hasResponseBodyRules() before capturing anything; with no RESPONSE_BODY rule loaded the recorder is a pass-through that forwards writes as they arrive. The bundled rules.json has no Phase 4 rules, so the default configuration buffers nothing at all and no longer defeats HTTP streaming.max_response_body_size (new setting, default 10 MiB). When a response outgrows the budget, the recorder writes out what it holds and streams the remainder straight to the client, so peak memory is bounded by the limit rather than by the response size.warn ("Response body exceeded the WAF inspection limit; Phase 4 rules were not applied") rather than scoring a truncated body and reporting the response as vetted.Measured on a 512 MiB response through a WAF-protected route: heap allocated during ServeHTTP drops from 1535 MiB to 0 MiB, with all 512 MiB still delivered to the client.
max_response_body_size (Caddyfile directive and JSON field, default 10485760) — ceiling on how much of the response body is retained for Phase 4 inspection. Validated as non-negative by Validate; 0 selects the default.max_request_body_size is now settable from the Caddyfile as well, not only from JSON.responseRecorder implements http.Flusher.custom_response body. ServeHTTP wrote the custom response into the recorder, whose buffer is discarded on the blocked path, so the client received an empty body; it now writes to the real ResponseWriter.responseRecorder.WriteHeader forwards the status to the underlying ResponseWriter as soon as the upstream sets it, so by the time the response phases run the status line is already committed and a block surfaces as 200 with the custom body. This is pre-existing behaviour, unrelated to the advisory above, and is tracked separately.wafVersion to v0.3.4.Nothing published for this version
Patched 3 critical and 10 high severity Dependabot alerts by upgrading the affected dependencies to their fixed versions:
Patched 3 critical and 10 high severity Dependabot alerts by upgrading the affected dependencies to their fixed versions:
github.com/caddyserver/caddy/v2 v2.10.2 → v2.11.2 — fixes 4 high (FastCGI split_path Unicode case-folding bypass, MatchHost case-sensitivity bypass on >100 hosts, MatchPath %xx case normalization bypass, mTLS silent fail-open on missing CA file) and 2 medium (admin API CSRF on /load, file matcher glob sanitization).google.golang.org/grpc v1.78.0 → v1.79.3 — fixes 1 critical (authorization bypass via missing leading slash in :path).github.com/jackc/pgx/v5 v5.8.0 → v5.9.2 — fixes 1 critical (memory-safety) and 1 low (SQL injection via dollar-quoted placeholder confusion).github.com/smallstep/certificates v0.29.0 → v0.30.2 — fixes 1 critical (unauthenticated certificate issuance via SCEP UpdateReq MessageType=18) and 1 low (TPM EKU validation index-out-of-bounds panic).go.opentelemetry.io/otel v1.39.0 → v1.43.0 — fixes 1 high (multi-value baggage header DoS amplification).go.opentelemetry.io/otel/sdk v1.39.0 → v1.43.0 — fixes 2 high (BSD kenv PATH hijacking; arbitrary code execution via PATH hijacking).github.com/go-jose/go-jose/v4 v4.1.3 → v4.1.4 — fixes 1 high (JWE decryption panic).github.com/go-jose/go-jose/v3 v3.0.4 → v3.0.5 — fixes 1 high (JWE decryption panic).github.com/slackhq/nebula v1.9.7 → v1.10.3 — fixes 1 high (blocklist bypass via ECDSA signature malleability).github.com/cloudflare/circl upgraded to v1.6.3 — fixes 1 low (incorrect secp384r1 CombinedMult calculation).filippo.io/edwards25519 upgraded to v1.2.0 — fixes 1 low (MultiScalarMult invalid results when receiver is not the identity).No source-code changes required; the WAF compiles and the full unit test suite passes against the upgraded dependency tree.
wafVersion to v0.3.2.Rewrote README.md, MODULE.md, caddyfile.example, and the entire docs/ tree to be 1:1 accurate with the current source code.
README.md, MODULE.md, caddyfile.example, and the entire docs/ tree to be 1:1 accurate with the current source code.docs/configuration.md now lists every Caddyfile directive recognised by config.go, every JSON-only field on the Middleware struct, the precise Phase 1 evaluation order, and the parser- vs. Provision-time defaults.docs/rules.md documents the JSON tag mismatch on Rule.Action (struct tag is mode, while the bundled rule files commonly use action), so authors know which key is actually parsed.docs/ratelimit.md corrects the match_all_paths semantics to match ratelimiter.go (true ⇒ rate-limit every request; false + non-empty paths ⇒ rate-limit only matching paths).docs/dynamicupdates.md adds an explicit reload matrix showing which settings are reloaded by fsnotify and which require caddy reload.docs/metrics.md documents the actual response schema returned by handleMetricsRequest and clarifies that all counters are process-local and reset on restart.docs/prometheus.md switches the example exporter from Counter.inc(absolute) to Gauge.set(absolute) to match the WAF's monotonic process-local counter semantics.caddyfile.example no longer references non-existent directives (country_block, custom_response { … } block form).wafVersion to v0.3.1.Resolved duplicate response headers when a custom block response was emitted.
net.ParseCIDR is tried before net.ParseIP).Nothing published for this version
Fixed potential panic in isIPBlacklisted() when parsing malformed IP addresses - now uses netip.ParseAddr() instead of netip.MustParseAddr().
isIPBlacklisted() when parsing malformed IP addresses - now uses netip.ParseAddr() instead of netip.MustParseAddr().processRuleMatch() - now uses safe getLogID() helper function.extractIP() and getClientIP() when handling empty or malformed input.tor.go to prevent hanging requests during Tor exit node list fetches.Validate() method for negative threshold/limit values.NewRateLimiter() to ensure positive values.caddy add-package is not available (module not registered in Caddy's package registry).CADDY_MODULE_REGISTRATION.md with current registration status.caddy add-package limitations in README.md, installation.md, and add-package-guide.md.Minor bug fixes and stability improvements.
Nothing published for this version
Fixed critical bug where POST request bodies were lost or truncated by using io.MultiReader to restore the full body stream (fixes #76).
io.MultiReader to restore the full body stream (fixes #76).Fixed Panic vulnerability in quic-go by upgrading to v0.54.0 (requires Caddy v2.10.x and Go 1.25).
quic-go by upgrading to v0.54.0 (requires Caddy v2.10.x and Go 1.25).v2.10.2.1.25.Downgraded quic-go to v0.48.2 and Caddy to v2.9.1 to temporarily resolve Go version conflicts (superseded by v0.1.4).
quic-go to v0.48.2 and Caddy to v2.9.1 to temporarily resolve Go version conflicts (superseded by v0.1.4).gci linter compliance.SOTA Engineering patterns (Zero-Copy headers, Wait-Free Ring Buffer, Circuit Breaker).
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Your coding agent can read these notes before it upgrades. Set up the MCP server →