v2.8.1
A single-fix patch release. If you enabled the Crs plugin in v2.8.0, upgrade immediately — its verdict was inverted, so it blocked legitimate traffic and allowed the attacks it was meant to stop. No other plugin is affected, and nothing else in v2.8.0 changes.
PHP 8.1–8.5 supported. No configuration changes required.
Who is affected
Only deployments that added Kanopi\Firewall\Plugins\Crs to their plugins: list. The plugin shipped for the first time in v2.8.0 (2026-07-26), so the exposure window is that release only. If you did not enable CRS, this release is a no-op for you.
The defect was present in src/Plugins/Crs.php from the day the plugin landed (#9); v2.8.0 was simply the first release where consumers could reach it.
Fixes
-
Crs::evaluate()returned an inverted verdict (#91, fixes #89).evaluate()reports whether a plugin matched, which is whatPluginManageracts on to apply the entry'sresponse:. The CRS plugin returned the opposite boolean, so withresponse: blocka benign request matched and was rejected, while a request CRS scored as an attack reported no match and was allowed through:benign GET / => blocked (403) GET /?id=1' UNION SELECT... => allowed (200)The engine was never wrong —
getLastVerdict()correctly reportedaction=allow score=0for the benign request andaction=block score=3for the injection. Only the plugin's translation of that verdict into a bool was inverted. Fixed by swapping the two return values.mode: monitorwas affected more severely, not less. The usual advice to roll a WAF out in monitor mode first made things worse here: the engine never sets a blocking verdict in monitor mode, soisBlocked()was always false and the invertedreturn truefired for every request. There was no configuration of this plugin that behaved correctly. -
PluginInterface::evaluate()was documented backwards (#91). The docblock read@return TRUE if allowed to pass, contradictingPluginManager, the other seven plugins, and the README's own custom-plugin example. CRS was the one implementation written against the docblock rather than the convention. The contract is now stated explicitly — TRUE means the request matched and the entry'sresponse:should be applied — and every plugin inherits the correction via{@inheritdoc}.
Upgrade notes
-
No config changes. Upgrade with
composer update kanopi/firewall. -
Audit your logs if you ran the CRS plugin. Two things to look for:
- Legitimate traffic blocked. Stored block records attribute the plugin, so entries like
{"value":{"plugin":"CRS",...,"request":{"path":"/"}}}on ordinary paths are false positives from this defect. Clearing your storage backend releases them; they also expire on their own afterblock_duration. - Attacks allowed through. Anything CRS would have caught reached the application for as long as the plugin was active.
mode: monitordeployments have no CRS log record of those requests at all, since the plugin reported a match on everything and a block on nothing — check upstream access logs instead.
- Legitimate traffic blocked. Stored block records attribute the plugin, so entries like
-
Check your own plugins. If you implemented
PluginInterfaceand followed the old@return TRUE if allowed to passdocblock literally, your plugin is inverted in exactly the same way. Correct behaviour is: return TRUE when your rules match the request. Returning FALSE means "not mine, keep evaluating". -
Blocks recorded during the affected window are not migrated. Firewall storage is ephemeral state; the release makes no attempt to reverse attributions written by the buggy verdict.
Internal
-
New
PluginPolarityTestcontract test (#91). Every shipped plugin now declares one request that must match and one that must not, so an invertedevaluate()fails the suite. A coverage check fails the build when a plugin insrc/Plugins/has no polarity case, so the convention cannot silently go stale.GeoLocationandAsnare exempt with a stated reason — both need MaxMind databases unavailable to unit tests.This gap is why CI stayed green through the defect:
CrsTestencoded the same misreading as the plugin, asserting TRUE for benign input and FALSE for SQLi, andtestMonitorModeNeverBlocksasserted the opposite of its own name. Those assertions are corrected here. -
phpstan/phpstanconstrained to^2.1 <2.2.6(#92). phpstan 2.2.6 removed a private property that Rector reaches for by reflection, making everyrectorCI job fail with a fatal error rather than a lint finding. Development dependency only — no effect on consumers. Drop the upper bound once Rector ships a compatible release.
Full Changelog: v2.8.0...v2.8.1