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)
| Event | Jail | Line |
|---|---|---|
auth.failure | craft-auth | Authentication failure for jo@example.com from 203.0.113.7 |
auth.unknownUser | craft-auth | Authentication attempt for unknown user root from 203.0.113.7 |
auth.blockedUser | craft-hard | Blocked authentication attempt for admin from 203.0.113.7 |
auth.cooldown | craft-auth | Authentication attempt for account in cooldown jo from 203.0.113.7 |
auth.locked | craft-auth | Account locked for jo from 203.0.113.7 |
auth.suspended | craft-auth | Authentication attempt for suspended account jo from 203.0.113.7 |
auth.elevatedFailure | craft-auth | Elevated session failure for jo from 203.0.113.7 |
auth.invalidToken | craft-hard | Invalid 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)
| Event | Jail | Line |
|---|---|---|
auth.enumeration | craft-auth | User enumeration attempt (4 usernames) from 203.0.113.7 |
path.probe | craft-probe | Probe for /wp-login.php from 203.0.113.7 |
http.notFound | craft-flood | Not found /old-page from 203.0.113.7 |
http.forbidden | craft-flood | Forbidden /admin/settings from 203.0.113.7 |
http.badRequest | craft-flood | Bad request /actions/… from 203.0.113.7. Off by default. |
gql.failure | craft-auth | GraphQL 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
pseudonymizeUsernamesis 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.