Skip to content

v2.8.1

Choose a tag to compare

@sean-e-dietrich sean-e-dietrich released this 27 Jul 00:34
bb24887

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 what PluginManager acts on to apply the entry's response:. The CRS plugin returned the opposite boolean, so with response: block a 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 reported action=allow score=0 for the benign request and action=block score=3 for the injection. Only the plugin's translation of that verdict into a bool was inverted. Fixed by swapping the two return values.

    mode: monitor was 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, so isBlocked() was always false and the inverted return true fired 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, contradicting PluginManager, 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's response: 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 after block_duration.
    • Attacks allowed through. Anything CRS would have caught reached the application for as long as the plugin was active. mode: monitor deployments 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.
  • Check your own plugins. If you implemented PluginInterface and followed the old @return TRUE if allowed to pass docblock 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 PluginPolarityTest contract test (#91). Every shipped plugin now declares one request that must match and one that must not, so an inverted evaluate() fails the suite. A coverage check fails the build when a plugin in src/Plugins/ has no polarity case, so the convention cannot silently go stale. GeoLocation and Asn are exempt with a stated reason — both need MaxMind databases unavailable to unit tests.

    This gap is why CI stayed green through the defect: CrsTest encoded the same misreading as the plugin, asserting TRUE for benign input and FALSE for SQLi, and testMonitorModeNeverBlocks asserted the opposite of its own name. Those assertions are corrected here.

  • phpstan/phpstan constrained to ^2.1 <2.2.6 (#92). phpstan 2.2.6 removed a private property that Rector reaches for by reflection, making every rector CI 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