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

Open Beginner friendly
#1,687 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
84/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
ansible, php
Domain
devops, security

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

  1. Enable the wordpress_wp_login jail via fail2ban_services_custom (the documented pattern, e.g. in SECURITY-IP-BLOCKING.md).
  2. 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.
  3. Watch the site's access log — admin-ajax.php heartbeat calls made while that tab is open carry wp-login.php as 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from roots/trellis

All issues in roots/trellis

Similar issues

More DevOps issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.