Fail2Ban for Craft CMS

Usage

What gets logged

Every event type owns one template, and that one string is used twice: once to write the log line and once to build the failregex that matches it. Nothing has to be kept in step by hand.

Every line ends with from <address>. fail2ban looks for the address at the end of the line, where nothing else can be mistaken for it.

Authentication (Lite and Pro)

EventJailLine
auth.failurecraft-authAuthentication failure for jo@example.com from 203.0.113.7
auth.unknownUsercraft-authAuthentication attempt for unknown user root from 203.0.113.7
auth.blockedUsercraft-hardBlocked authentication attempt for admin from 203.0.113.7
auth.cooldowncraft-authAuthentication attempt for account in cooldown jo from 203.0.113.7
auth.lockedcraft-authAccount locked for jo from 203.0.113.7
auth.suspendedcraft-authAuthentication attempt for suspended account jo from 203.0.113.7
auth.elevatedFailurecraft-authElevated session failure for jo from 203.0.113.7
auth.invalidTokencraft-hardInvalid user token … from 203.0.113.7
auth.success—Accepted password for jo from 203.0.113.7
auth.logout—Logout for jo from 203.0.113.7. Off by default.
auth.passwordReset—Password reset requested for jo from 203.0.113.7

Events with no jail are not attacks. auth.success is logged so that, after an incident, you can see who got in after the attempts that failed.

auth.cooldown, auth.locked and auth.suspended are told apart even when Craft's preventUserEnumeration is on. With that setting, Craft tells the visitor "invalid credentials" in every case, which is right for the visitor. The log records what actually happened.

Scanning and abuse (Pro)

EventJailLine
auth.enumerationcraft-authUser enumeration attempt (4 usernames) from 203.0.113.7
path.probecraft-probeProbe for /wp-login.php from 203.0.113.7
http.notFoundcraft-floodNot found /old-page from 203.0.113.7
http.forbiddencraft-floodForbidden /admin/settings from 203.0.113.7
http.badRequestcraft-floodBad request /actions/… from 203.0.113.7. Off by default.
gql.failurecraft-authGraphQL authorization failure … from 203.0.113.7
user.registered—New user registration jo from 203.0.113.7. Off by default.

auth.enumeration is derived: it fires once, when one address has tried enumerationThreshold or more different usernames inside enumerationWindow. It needs the database copy to count from.

path.probe is the event with no WordPress equivalent. On a Craft site, /wp-login.php, /xmlrpc.php, /.env and /vendor/phpunit are requests nobody makes by accident. Paths are matched as whole segments, so /wp-admin and /blog/wp-admin/setup.php match, and /news/how-we-left-wp-admin-behind does not.

404s from signed-in control panel users are never logged. Checking for a signed-in user costs nothing on the common path: the plugin asks only when a session is already open, so an anonymous 404 never starts one, and your full-page cache keeps working.

What is never in a line

  • Line breaks and control characters. Everything substituted into a line is flattened onto one line and capped at 255 characters, so nothing a visitor types can start a line of its own.
  • Spaces inside a username or path. These become _, so the filter still matches them.
  • Query-string values. Only parameter names are kept.
  • Real usernames, if pseudonymizeUsernames is on.

Raising your own events

Plenty of security events on a real site are not Craft's: a members' login form backed by an API, a gated download, a voucher field being guessed. Raise them through the same channels, jails and exemptions:

{% do craft.fail2ban.log('auth.failure', { username: form.email }) %}

log() returns whether anything was written. It writes nothing when the type is switched off, the address is on the ignore list, the plugin is disabled, or the request has already logged maxEventsPerRequest events.

The client address is filled in the same way as for Craft's own events. Do not pass ip yourself from anything in the request, or a visitor can choose which address gets banned.

The rest of craft.fail2ban:

{# Every event type, keyed by id #}
{% for id, type in craft.fail2ban.types %}
    {{ type.label() }} — {{ type.jail ?? 'no jail' }}
{% endfor %}

{# Is an address currently banned by the built-in blocker? #}
{% if craft.fail2ban.isBanned('203.0.113.7') %}…{% endif %}

{# Recent events from the database copy #}
{% for event in craft.fail2ban.events({ type: 'auth.failure' }, 10) %}…{% endfor %}

{# Counts by type over the last n hours #}
{% set counts = craft.fail2ban.counts(24) %}

From PHP:

use justinholtweb\fail2ban\Plugin;

Plugin::getInstance()->logger->raise('auth.failure', ['username' => $email]);

Filtering events in PHP

Logger::EVENT_BEFORE_LOG fires before anything is written. Set isValid to false to drop the event:

use justinholtweb\fail2ban\events\SecurityEventEvent;
use justinholtweb\fail2ban\services\Logger;
use yii\base\Event;

Event::on(Logger::class, Logger::EVENT_BEFORE_LOG, function(SecurityEventEvent $event) {
    // An uptime checker that requests a path we removed.
    if ($event->securityEvent->type === 'http.notFound'
        && $event->securityEvent->uri === '/healthz') {
        $event->isValid = false;
    }
});

Logger::EVENT_AFTER_LOG fires once every channel has been written, with the same event object.

The control panel

  • Overview: whether each channel is working, the busiest addresses this week, and the latest events.
  • Events: the database copy, filterable by type, jail, address and time, with a text search.
  • Bans (Pro, with the built-in blocker): addresses currently banned. You can release one, ban one by hand, or forget one entirely.
  • Setup: the generated files, a download for each, a check that every filter still matches its line, and Fix these first when something would make the jails ban the wrong address.

Console

php craft fail2ban/status                                  # channels, events, jails, warnings, filter agreement
php craft fail2ban/configs/show                            # print the generated files
php craft fail2ban/configs/write --directory=/etc/fail2ban # write them (default directory shown)
php craft fail2ban/configs/verify                          # non-zero exit if any filter no longer matches
php craft fail2ban/events/test                             # one line per enabled event type, from 203.0.113.7
php craft fail2ban/events/list --hours=24 --type=auth.failure --limit=50
php craft fail2ban/events/prune                            # apply ledgerRetentionDays now
php craft fail2ban/bans/list
php craft fail2ban/bans/add 203.0.113.7 --seconds=86400    # default 3600
php craft fail2ban/bans/remove 203.0.113.7
php craft fail2ban/bans/purge                              # delete released addresses

configs/verify is worth running in CI. It renders a line for every event type and matches the generated pattern against it. A failure means a filter you deploy today would match nothing.