For the last few weeks, the VPS powering this site received an increase in nefarious traffic arriving via IPv6. Perhaps unsurprisingly, much of this traffic came as brute-force login attempts against my WordPress site, and its arrival over IPv6 was key.
As I noted in my post on login monitoring, I already employ fail2ban, in conjunction with Konstantin Kovshenin’s technique for blocking failed WP logins. Unfortunately, fail2ban only supports IPv4, which is the only reason I even noticed this uptick in login attempts or needed to address it.
This time, rather than implementing an application-specific solution1, I opted for something at the nginx level instead: ngx_http_limit_req_module
, the request-limiting module. Besides providing the blocking I needed, this module is native to the default nginx build distributed by the project, so should be widely supported.
Getting Started
To start, WordPress must return a non-200
status code for failed logins, which is what nginx will use to measure and trigger blocking. Fortunately, fail2ban required the same, so from Kovshenin’s tutorial, I already had this in an mu-plugin:
/** * Return a 403 status header on failed logins so they are logged distinguishably * * Used with fail2ban per https://kovshenin.com/2014/fail2ban-wordpress-nginx/ * Used with nginx rate limiting */ function eth_set_failed_login_status_header() { status_header( 403 ); } add_action( 'wp_login_failed', 'eth_set_failed_login_status_header' );
Next, in nginx.conf
‘s http
block, I leveraged a map
to determine if the request method should be subject to rate-limiting. Initially, I only limited POST requests, but after sufficient testing, I now subject all request types–other than GET–to limiting.
map $request_method $limit { default $binary_remote_addr; GET ""; }
I then created two zones (or buckets) to discretely track WordPress login requests from other traffic.
limit_req_zone $limit zone=global:10m rate=1r/s; limit_req_zone $limit zone=wp_logins:10m rate=3r/m; limit_req_zone $limit zone=piwik:10m rate=30r/s;
Notice that each zone defines its level of rate limiting. The “global” zone permits one request per second, whereas the zone applied to wp-login.php
only permits three requests per minute 2. The final zone is used by Piwik, the open-source Google Analytics alternative, which, by its nature, can receive significant volume of requests.
Next, I changed two global module configurations, before enabling rate limiting for all non-GET requests.
limit_req_log_level warn; limit_req_status 429; # General rate limiting limit_req zone=global burst=5 nodelay;
The first declaration changes how rate-limited requests are logged, dropping them from errors to warnings (and shifting delayed requests from warnings to notices). Until you’re comfortable with your limiting levels, you may wish to omit this change to better-surface blocked requests.
The next addition tells nginx to issue the 429 - Too Many Requests
status code when requests are limited, in accordance with the IETF specification3. By default, nginx issues a 503 - Service Unavailable
response.
The final line of the above snippet is what enables the default rate limiting for every request nginx receives, thanks to its place in my configuration’s http
block. As necessary, I can then override rate limiting in individual server
and location
blocks, either increasing the number of allowed requests, or disabling rate limiting altogether.
Application Specifics
For WordPress logins, I added the following to the server
block that handles WP’s requests:
location = /wp-login.php { limit_req zone=wp_logins burst=3 nodelay; ... }
With all of the above changes in place, nginx now blocks excessive requests before they reach WordPress, PHP, or any other part of my hosting stack beyond the webserver.
To Piwik’s server
block, I added the following, providing for bursts of 50 requests per second should 30 prove insufficient:
server { # Allow Piwik much-higher limits limit_req zone=piwik burst=20 nodelay; ... }
Final Thoughts
If you’re concerned with monitoring rate-limited requests, two of the techniques I noted in “Four techniques for monitoring server logins” could be adapted to monitoring nginx logs for occurrences of the 429
code: swatchdog
and logwatch
. Either tool simply requires that nginx write a log to your system’s default logs directory.
Second, I include the nodelay
option in all of my limit_req
directives so that nginx returns the limit_req_status
immediately after requests exceed the limit, regardless of what is queued or processed between successive requests.
Image: “Warning: Be Prepared to Stop at Anytime” by Michael Leslie, used with permission.
- Admittedly, while WordPress is one of my primary traffic sources, it’s not the only application on this server that one might abuse. ↩
- It’s worth noting that I’m the only active user on this site, or the ten others hosted on my multisite network, which allows for such a strict login limit. ↩
- An alternative is Twitter’s
420 - Enhance Your Calm
, which their API previously returned in response to excessive search or Trends requests. ↩
For those wondering why WordPress returns a
200
status code for failed logins, see https://core.trac.wordpress.org/ticket/25446.