Skip to content

Anchor WordPress fail2ban filters to the request field - #1688

Open
jasperf wants to merge 1 commit into
roots:masterfrom
jasperf:fix/fail2ban-wordpress-filter-anchoring
Open

Anchor WordPress fail2ban filters to the request field#1688
jasperf wants to merge 1 commit into
roots:masterfrom
jasperf:fix/fail2ban-wordpress-filter-anchoring

Conversation

@jasperf

@jasperf jasperf commented Aug 25, 2026

Copy link
Copy Markdown

Fixes #1687.

Problem

The wordpress_wp_login and wordpress_xmlrpc failregex patterns use an unbounded .* after "POST, so they are not anchored to the quoted request field. They match wp-login.php / xmlrpc.php anywhere later in the same access log line — including the Referer header.

In practice, every background AJAX request fired from an open wp-login.php tab (WordPress' heartbeat API, a 2FA plugin polling) carries wp-login.php as its Referer and gets counted as a failed login attempt. With the default fail2ban_maxretry: 6, a single normal admin login — especially one with a 2FA step, which keeps the tab open long enough for a few extra heartbeats — can trip the ban threshold with zero actual failed credentials.

Real production log from one such login (redacted IP): 8 matches from only 2 genuine POST /wp-login.php requests.

01:59:57  POST /wp-admin/admin-ajax.php   (Referer: .../wp-login.php)   <- false match
02:00:01  POST /wp-login.php                                            <- real (password step)
02:00:01  POST /wp-admin/admin-ajax.php   (Referer: .../wp-login.php)   <- false match
02:00:01  POST /wp-admin/admin-ajax.php   (Referer: .../wp-login.php)   <- false match
02:00:02  POST /wp-admin/admin-ajax.php   (Referer: .../wp-login.php)   <- false match
02:00:25  POST /wp-admin/admin-ajax.php   (Referer: .../wp-login.php)   <- false match
02:00:25  POST /wp-admin/admin-ajax.php   (Referer: .../wp-login.php)   <- false match
02:00:25  POST /wp-login.php                                            <- real (2FA code step)

Six of those fell inside the jail's findtime window — enough on their own to trip maxretry: 6. SSH stayed reachable throughout (separate sshd jail, untouched), confirming a filter/regex problem rather than a credentials or brute-force issue.

Secondary bug, same patterns: there was no status-code requirement at all, so a successful login (HTTP 302) matched exactly like a failed one and counted toward the threshold.

Fix

Bound the match with [^"]* so it stops at the closing quote of the request field, and require a 200 status:

failregex = ^<HOST> .* "POST [^"]*wp-login\.php[^"]*" 200
failregex = ^<HOST> .* "POST [^"]*xmlrpc\.php[^"]*" 200

[^"]* cannot bleed past the closing quote into the Referer or User-Agent fields. The 200 requirement keys off Trellis' own nginx main log format in roles/nginx/templates/nginx.conf.j2, which places $status directly after "$request". On these endpoints a genuine failed login re-renders the form and logs 200, while a successful one logs a 30x redirect — so requiring 200 keeps failures and drops successes.

The wordpress-xmlrpc.conf.j2 filter carries the identical bug and is fixed the same way. Its impact is currently lower since that jail ships disabled by default in favor of the Nginx 444 response for XML-RPC, but the pattern is wrong either way.

Verification

1. Against real production logs. fail2ban-regex run over two sites' access logs (~50k lines each), current filter vs. the anchored one:

Site Current regex matches Anchored regex matches False positives removed
Site A 88 60 28 (32%)
Site B 43 28 15 (35%)

Every match remaining under the fixed regex was confirmed to be a genuine POST .../wp-login.php request line — no loss of real brute-force detection.

2. Live re-trigger of the original failure. Deployed the anchored regex, then logged into wp-admin again through the same 2FA flow while tailing the access log, capturing the exact request sequence that caused the original ban:

POST /wp-login.php?action=validate_2fa   302   <- the actual 2FA-code submission
POST /wp-admin/admin-ajax.php  200  21  (Referer: .../wp-login.php)
POST /wp-admin/admin-ajax.php  200  75  (Referer: .../wp-login.php)

fail2ban-regex with the deployed filter against that captured segment:

Failregex: 0 total
Lines: 20 lines, 0 ignored, 0 matched, 20 missed

The old regex would have matched all three lines — the two admin-ajax.php requests via the Referer bug, plus the real validate_2fa POST itself (HTTP 302), because of the missing status requirement.

This test is independent of ignoreip: fail2ban-regex operates purely at the filter/pattern layer and does not apply jail whitelisting, so the 0-match result reflects the regex itself rather than any IP-specific exemption. It generalizes to any admin hitting the same request pattern.

Notes

No changes to defaults, jail configuration, or documentation — the two failregex lines are the whole diff. Existing jails pick the fix up on the next provision.

The wordpress_wp_login and wordpress_xmlrpc failregex patterns used an
unbounded `.*` after `"POST`, so they matched wp-login.php/xmlrpc.php
anywhere later in the access log line -- including the Referer header.
Background AJAX requests fired from an open wp-login.php tab (heartbeat
API, 2FA plugin polling) carry wp-login.php as their Referer and were
counted as failed logins, which can ban a legitimate admin mid-login
with the default maxretry of 6.

Bound the match with `[^"]*` so it stops at the closing quote of the
request field, and require a 200 status. Trellis' nginx `main` log
format puts $status directly after "$request", so a genuine failed
login logs 200 while a successful one logs a 30x redirect -- the old
pattern had no status requirement at all and matched both.
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.

wordpress_wp_login fail2ban filter matches unrelated requests via Referer header, banning legitimate admins

1 participant