wordpress_wp_login fail2ban filter matches unrelated requests via Referer header, banning legitimate admins
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 84/100
Research direction
Start with roles/fail2ban/templates/filters/wordpress-wp-login.conf.j2 and compare it with wordpress-xmlrpc.conf.j2. Read the jail configuration and run fail2ban-regex against representative access logs. Done means requests with wp-login.php or xmlrpc.php only in Referer no longer match, while genuine failed POST requests still do.
Written by the indexing model from the issue text.
Description
How I ran into this
I locked myself out of two of my own Trellis-hosted sites (sharing one server/fail2ban instance) while logging into wp-admin through a 2FA step — TCP connections on 80/443 kept succeeding but TLS was getting reset mid-handshake right after, with SSH staying reachable the whole time. That pointed at fail2ban rather than a network-level block. Pulling /var/log/fail2ban.log and the raw access log for the exact ban window showed I'd been banned by the wordpress_wp_login jail after only 2 real login POSTs — which sent me looking at the filter regex itself rather than assuming it was a false alarm on my end (e.g. VPN IP reputation).
Description
The wordpress_wp_login fail2ban filter can ban a legitimate, correctly-authenticating admin — not just brute-force bots — because its failregex isn't anchored to the HTTP request field. It also matches wp-login.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's heartbeat API, a 2FA plugin polling, etc.) gets miscounted as a failed login attempt.
With the stock default fail2ban_maxretry: 6, a single normal login — especially one with a 2FA step, which gives the heartbeat API time to fire a few extra beats while the tab is open — can trip the ban threshold with zero actual failed credentials.
The same bug exists verbatim in the sibling wordpress-xmlrpc.conf.j2 filter.
Steps to reproduce
- Enable the
wordpress_wp_loginjail viafail2ban_services_custom(the documented pattern, e.g. inSECURITY-IP-BLOCKING.md). - Log in as an admin on a site with a second login step (2FA, SSO redirect, network/multisite reauth) that keeps the wp-login.php tab open for a bit.
- Watch the site's access log —
admin-ajax.phpheartbeat calls made while that tab is open carrywp-login.phpas their Referer, and each one matches the filter alongside the real login POSTs.
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)
6 matches fell inside the jail's findtime window — enough alone to trip maxretry: 6. The banned IP stayed reachable over SSH the entire time (separate sshd jail, untouched), confirming this is a filter/regex problem, not a credentials or brute-force issue.
Verified with fail2ban-regex against two sites' real production logs (~50k lines each) comparing the current filter against the anchored fix below:
| Site | Current regex matches | Anchored regex matches | False positives removed |
|---|---|---|---|
| Site A | 88 | 60 | 28 (32%) |
| Site B | 43 | 28 | 15 (35%) |
All matches that remain under the fixed regex were confirmed as genuine POST .../wp-login.php request lines — no loss of real brute-force detection.
Expected behavior
A normal admin login (including one with a 2FA step) should never trip the wordpress_wp_login jail.
Actual behavior
An unrelated admin-ajax.php request that merely carries wp-login.php in its Referer header counts toward the ban threshold, because the filter's failregex has an unbounded .* that isn't anchored to the quoted request field:
roles/fail2ban/templates/filters/wordpress-wp-login.conf.j2 (confirmed present on master as of writing):
[Definition]
failregex = ^<HOST> .* "POST .*wp-login\.php
roles/fail2ban/templates/filters/wordpress-xmlrpc.conf.j2 (same bug, currently lower-impact since that jail ships disabled by default in favor of the Nginx 444 response for XML-RPC):
[Definition]
failregex = ^<HOST> .* "POST .*xmlrpc\.php
Suggested fix
Bound the match to the quoted request field and require a 200 status (genuine failed-login responses; excludes 301/302 redirects, which on this endpoint indicate the login step succeeded):
[Definition]
failregex = ^<HOST> .* "POST [^"]*wp-login\.php[^"]*" 200
[Definition]
failregex = ^<HOST> .* "POST [^"]*xmlrpc\.php[^"]*" 200
[^"]* stops at the closing quote of the request field, so it can no longer bleed into the Referer or User-Agent fields.
Versions
- Trellis: current
master(filters confirmed unchanged as of this report) - fail2ban backend:
iptables-multiport,polling
- Dominant language
- Jinja
- Stars
- 2.6k
- Forks
- 588
- Avg merge
- 3d 8h
- Merged PRs (30d)
- 1
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from roots/trellis
-
bug
Difficulty 4/5 3-5 days Newbie friendliness 45/100
-
Difficulty 3/5 1-2 days Newbie friendliness 76/100
-
enhancement
Difficulty 3/5 1-2 days Newbie friendliness 68/100
-
bug
Difficulty 3/5 1-2 days Newbie friendliness 63/100
Similar issues
-
Difficulty 1/5 Under an hour Newbie friendliness 84/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
sympozium-ai/sympozium#627 ·
-
repo:raw-data
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
hotosm/raw-data-api#316 · 1 comment ·
-
agentic-workflows cascade-suspected
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
-
willow
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
openedx/openedx-authz#460 ·