Anchor WordPress fail2ban filters to the request field - #1688
Open
jasperf wants to merge 1 commit into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1687.
Problem
The
wordpress_wp_loginandwordpress_xmlrpcfailregex patterns use an unbounded.*after"POST, so they are not anchored to the quoted request field. They matchwp-login.php/xmlrpc.phpanywhere later in the same access log line — including the Referer header.In practice, every background AJAX request fired from an open
wp-login.phptab (WordPress' heartbeat API, a 2FA plugin polling) carrieswp-login.phpas its Referer and gets counted as a failed login attempt. With the defaultfail2ban_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.phprequests.Six of those fell inside the jail's findtime window — enough on their own to trip
maxretry: 6. SSH stayed reachable throughout (separatesshdjail, 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 a200status:[^"]*cannot bleed past the closing quote into the Referer or User-Agent fields. The200requirement keys off Trellis' own nginxmainlog format inroles/nginx/templates/nginx.conf.j2, which places$statusdirectly after"$request". On these endpoints a genuine failed login re-renders the form and logs200, while a successful one logs a30xredirect — so requiring200keeps failures and drops successes.The
wordpress-xmlrpc.conf.j2filter 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-regexrun over two sites' access logs (~50k lines each), current filter vs. the anchored one:Every match remaining under the fixed regex was confirmed to be a genuine
POST .../wp-login.phprequest 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:
fail2ban-regexwith the deployed filter against that captured segment:The old regex would have matched all three lines — the two
admin-ajax.phprequests via the Referer bug, plus the realvalidate_2faPOST itself (HTTP 302), because of the missing status requirement.This test is independent of
ignoreip:fail2ban-regexoperates 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.