Skip to content

v2.29.0 — a block that travels

Choose a tag to compare

@sean-e-dietrich sean-e-dietrich released this 16 Sep 19:48
· 52 commits to 2.x since this release
594faa5

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() and getSleepingRules() can return three more bucket values — mark, record and redirect. If your status page matches on the bucket, it has seen three and can now see six.
  • Redis storage on a host without ext-redis no 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 that firewall-doctor reports.

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 RedisStorage degrades 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-doctor reports 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.path was doing two incompatible jobs: the value compared against getPathInfo(), which has the base path stripped, and the form action, which needs it. On example.com/ they are the same string; on example.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_url is the escape hatch for a proxy that rewrites paths. A path that 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() and getSleepingRules() read a bucket list that had never learned about mark, record and redirect, added in 2.26.0. A response: record honeypot whose backend could not be reached was not running while firewall-doctor said 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 without ext-redis raises \Error, which catch (\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 host
    

    The 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.