Skip to content

Instrument fail2ban and allow2ban bans - #720

Open
woodhull wants to merge 1 commit into
rack:mainfrom
woodhull:instrument-ban-event
Open

Instrument fail2ban and allow2ban bans#720
woodhull wants to merge 1 commit into
rack:mainfrom
woodhull:instrument-ban-event

Conversation

@woodhull

@woodhull woodhull commented Aug 3, 2026

Copy link
Copy Markdown

Problem

There is currently no way to find out that a ban has been applied.

The blocklist event fires for every request a blocklist rejects, so a client banned for an hour produces an event on each of its blocked requests, and none at all on the request that actually tripped the ban. Subscribers can see a stream of rejections, but not the transition into the ban.

This is most acute with Allow2Ban, where the request that crosses maxretry is deliberately still allowed through. The ban is written but nothing observable happens, so it stays invisible until the next request arrives.

Solution

Adds a ban.rack_attack notification, emitted once at the moment the ban is written:

ActiveSupport::Notifications.subscribe('ban.rack_attack') do |name, start, finish, request_id, payload|
  # payload => {
  #   name: 'fail2ban',        # or 'allow2ban'
  #   discriminator: '1.2.3.4',
  #   count: 3,
  #   maxretry: 3,
  #   findtime: 600,
  #   bantime: 300,
  #   request: nil
  # }
end

Relationship to #655

This revives #655 (by @woahdae), which was closed for inactivity rather than on its merits. It follows the direction @santib asked for in review there:

I wouldn't like to change existing behavior (unless it's in a new major version). I'm wondering if we can keep the existing events + adding the new one :ban that you are introducing. Maybe we can achieve that by not modifying the request.env [...] and instead passing another object to Rack::Attack.instrument?

Two differences from #655:

  1. No request.env mutation. The ban is instrumented through a separate Rack::Attack.instrument_ban(payload) rather than by annotating the request, so the blocklist/track event for that same request is emitted exactly as before. Instrument fail2ban #655 needed ||= in Check#matched_by?, which was the breaking change flagged in review. The existing "notifies when the request is blocked" acceptance test passes unmodified here, which is what pinned that behavior down.

  2. request: is optional. In Instrument fail2ban #655 the whole event was gated behind passing request: to the filter, so nobody got ban instrumentation without changing their filters first. Here the event always fires and request: merely enriches the payload. The discriminator is usually enough to identify who was banned, so existing filters get this for free.

The deprecated catch-all rack.attack event is intentionally not emitted for bans, to avoid adding events to subscribers that never asked for them.

Compatibility

  • Additive: no existing event changes name, payload, or firing conditions.
  • Fail2Ban.fail! / Allow2Ban.fail! take a new trailing optional argument. Both are protected, and the argument is optional, so external subclasses that override or call them keep working.
  • Full suite passes (161 runs, 330 assertions, 0 failures), rubocop clean.

Docs

README gains a "Knowing when a ban is applied" section under Blocking, plus a TOC entry.

Previously there was no way to know that a ban had been applied. The
`blocklist` event fires for every request a blocklist rejects, so a
client banned for an hour produces an event per blocked request and none
at all on the request that tripped the ban.

This is most acute with Allow2Ban, where the request crossing maxretry is
still allowed through, so the ban is invisible until the next request.

Adds a `ban.rack_attack` event carrying the discriminator, count and the
fail2ban settings that produced the ban.

Unlike rack#655, this does not write to `request.env`, so the existing
`blocklist`/`track` events for the same request are emitted exactly as
before -- addressing the backwards compatibility concern raised there.
Passing `request:` to the filter is optional and only enriches the
payload, so existing filters get ban instrumentation with no changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant