Repository navigation
v2.29.0 — a block that travels
v2.29.0
A block that travels.
Every release so far has made one node smarter. A fleet of ten behind a load balancer still learned about the same attacker ten separate times: an attacker got ten times the budget before any single node blocked them, a ban earned on node 3 did nothing on node 7, and blocking_escalation counted one attacker as ten first-time offenders — so the escalation that should have made a second offence expensive never fired.
PHP 8.1–8.5 supported. No behaviour changes on upgrade unless you adopt the new storage — see the one warning below.
What changes on upgrade
- New storage backend:
SharedStorage. Nothing uses it until you configure it. - New
challenge.submit_url, optional. The interstitial's form action is now resolved against the request's base path, which needs no configuration. getFailedRules()andgetSleepingRules()can return three more bucket values —mark,recordandredirect. If your status page matches on the bucket, it has seen three and can now see six.- Redis storage on a host without
ext-redisno longer stops the firewall from starting. If you were relying on that to catch a misconfigured host in CI, it is now a degraded backend thatfirewall-doctorreports.
Added
-
A block list shared across a fleet (#223)
storage: type: "Kanopi\\Firewall\\Storage\\SharedStorage" config: shared: type: "Kanopi\\Firewall\\Storage\\RedisStorage" config: { redis: { host: redis.internal, port: 6379 } } local: type: "Kanopi\\Firewall\\Storage\\FileStorage" config: { storage_file: /var/lib/firewall/blocked.data }
Shared reachable Shared unreachable Reads Shared, always Local Writes Shared, mirrored to local Local Pointing every node at one Redis has been possible since 2.22.0. What it cost is the reason this exists: an unreachable Redis meant no block list at all, because
RedisStoragedegrades by answering "nothing is blocked" for every address. That is the right posture for a store the firewall can live without and the wrong one for the list whose whole job is to say otherwise.Reads never come from the mirror while the share answers, so a ban lifted on another node applies here on the next request — no propagation delay, no cache to go stale. During an outage the node keeps enforcing what it knew and records what it learns. Bans written during an outage are deliberately not replayed into a recovered share: reconciling ten nodes' disagreements about one address is a worse failure than a ban enforced on one node rather than ten.
firewall-doctorreports the arrangement and checks the local copy is writable, which matters more than it looks — the local copy is the entire reason the arrangement exists.!!! warning "Existing fleet installs will see bans get longer"
`blocking_escalation` lengthens a ban by how often an address has offended. Counted per node, ten nodes see one attacker as ten first-timers. Counted once, the second offence is finally the second offence. Single-node installs are unaffected; if you are moving a fleet onto a shared list, re-read thresholds tuned against per-node counts before you switch.
Fixed
-
A challenge no human could complete on a subdirectory install (#358)
challenge.pathwas doing two incompatible jobs: the value compared againstgetPathInfo(), which has the base path stripped, and the form action, which needs it. Onexample.com/they are the same string; onexample.com/app/they are not, and configuring either broke the other.So the form posted to the web server root, the answer never reached the firewall, no pass token was minted, and the visitor was challenged again on the next request — a challenge rule refused every human who tried to satisfy it, indefinitely, while a bot that ignored the interstitial was unaffected. It presented as a broken provider rather than a path problem.
The base path now comes from the request, so this needs no configuration.
challenge.submit_urlis the escape hatch for a proxy that rewrites paths. Apaththat is not rooted at/, or that already carries the base path, is left exactly as written.Reported from
drupal/basic_firewall, whose end-to-end test passed locally and failed on drupal.org CI, which serves from a subdirectory. -
Three of the six response buckets were reported as healthy whatever they were doing (#353)
getFailedRules()andgetSleepingRules()read a bucket list that had never learned aboutmark,recordandredirect, added in 2.26.0. Aresponse: recordhoneypot whose backend could not be reached was not running whilefirewall-doctorsaid every configured rule was — #260's failure, reopened for the buckets added since. -
Redis without the extension took the firewall down (#356)
new Redis()on a host withoutext-redisraises\Error, whichcatch (\Exception …)never saw — so a config that is correct in production stopped the firewall from starting when checked out on a laptop or into a CI container. It degrades and says which problem it is:✗ The block list is running without its store the redis extension is not installed on this hostThe rate limit store's connection lived in a non-nullable typed property, so a failed construction left it uninitialized — and reading one of those also raises
\Error. Fixing only the constructor would have turned a fatal at boot into a fatal on the first request that counted anything.
Coverage
100% of lines and methods, enforced in CI on PHP 8.1 through 8.5. 3440 tests.