How to mitigate attacks on WordPress when running under a full CDN like Cloudflare

wasteofserver - Sep 19 - - Dev Community

This post was originally posted on wasteofserver.com, you may find newer revisions and additional comments there.


How to mitigate attacks on WordPress when running under a full CDN like Cloudflare

The most effective way to prevent bots from spamming your server is to drop them at the firewall. This is generally achieved using tools like Denyhosts or fail2ban, which monitor your logs, identify suspicious activity, and block the offending IP addresses before they cause harm.

Denyhosts works at the application level by adding entries to /etc/hosts.deny, whereas fail2ban operates at the firewall level using iptables, which makes it far more efficient.

However, on resource-constrained machines, fail2ban can still be taxing. A few years ago, we shared a demo of a lightweight log parser called banbylog, tailored specifically for our needs (SSH and WordPress activity monitoring) at a much lower resource cost. If that sounds like a good fit, feel free to check it out, but keep in mind that it’s not production-ready!

While the consensus is to parse logs and block hostile IPs at the firewall, it will not work under Cloudflare umbrella!

How to mitigate attacks on WordPress when running under a full CDN like Cloudflare
Ideally the attack would stop here, but it won't work with a full CDN like Cloudflare

Why can't we flag offending IPs with Cloudflare?

Because the IPs that are "attacking" your server are not the actual offending IPs, but Clouflare machines that are proxying the request to your server. Take a look at the diagram below:

How to mitigate attacks on WordPress when running under a full CDN like Cloudflare

Cloudflare acts as a middleman between your server and the users. The only IP addresses visible to your firewall are from Cloudflare, not from the original user.

In fact, you should explicitly whitelist Cloudflare IP range. If you happen to block an IP that belongs to Cloudflare, legitimate users will see your site as down.

😱

While this article's focus is on securing servers while working under a Content Distribution Network, Cloudflare offers a variety of tools - free and paid - you may leverage to achieve these same results.

If the IP we see doesn't belong to the user making the request, what can we look for?

Every request Cloudflare sends to your server has an attached header that carries the user original IP under CF-Connecting-IP. And that's what we can leverage to get them! Unfortunately, reading http headers is too upstream for iptables, thus HAProxy to the rescue!

While iptables operates at Layer 4, HAProxy can operate at OSI Layer 7.

💡

Remember the OSI model:

Layer 7 - Application; protocols (HTTP, ...)  
Layer 6 - Presentation; character encoding (ASCII, UTF8, ...)  
Layer 5 - Session; stick client to server  
Layer 4 - Transport; protocols (TCP, UDP, ...)  
Layer 3 - Network; routing protocols (IP)  
Layer 2 - Data Link; physical to network (ARP, Ethernet)  
Layer 1 - Physical; cabling, Wi-Fi
Enter fullscreen mode Exit fullscreen mode

The easiest way to test HAProxy configurations is to boot a Docker instance of HAProxy:

docker run --name haproxy -p 888:80 -v ${pwd}:/usr/local/etc/haproxy --sysctl net.ipv4.ip_unprivileged_port_start=0 haproxy:2.3
Enter fullscreen mode Exit fullscreen mode

Start HAProxy listening on host port 888

The above assumes you're using Powershell and are in the directory that contains haproxy.cfg. Change ${pwd} accordingly if not.

docker kill -s HUP haproxy
Enter fullscreen mode Exit fullscreen mode

This command forces HAProxy restart, which is useful to iterate different configs

Below is a straightforward haproxy.cfg that will put HAProxy listening on port 80, log to stdout so you can get an instant glimpse on what's going on, and also print the CF-Connecting-IP header.

global
    # output logs straight to stdout
    log stdout format raw daemon debug

defaults
    # put HAProxy in Level 7 mode allowing to inspect http protocol
    log global
    mode http

frontend main
    bind *:80

    # capture original IP from header and put it in variable txn.cf_conn_ip
    http-request set-var(txn.cf_conn_ip) hdr(CF-Connecting-IP)

    # get the original IP on logs (just to check if things are working)
    log-format "%ci\ %hr\ %ft\ %b/%s\ %Tw/%Tc/%Tt\ %B\ %ts\ %r\ %ST\ %Tr CF-IP:%{+Q}[var(txn.cf_conn_ip)]"

    # use backend ok, which always returns a 200, for debug
    use_backend ok

backend ok
    http-request return status 200 content-type "text/plain" lf-string "ok"
Enter fullscreen mode Exit fullscreen mode

This should be enough to test HAProxy

Let's make a test request to HAProxy.

I'm partial to Bruno, a portable and offline alternative to Postman. Download it, add the CF-Connecting-IP header and make a POST request to http://localhost:888/wp-login.php to test if everything's working.

How to mitigate attacks on WordPress when running under a full CDN like Cloudflare
Nothing to install, a POST request in under a minute!

If things went as planned, your HAProxy should have printed this:

172.17.0.1 main ok/<NOSRV> -1/-1/0 78 LR POST /wp-login.php HTTP/1.1 200 -1 CF-IP:"222.222.222.222"
Enter fullscreen mode Exit fullscreen mode

Now that we have the offending IP (222.222.222.222), we can block it!

On our particular case, bots are hitting wp-login.php, xmlrpc.php and xmrlpc.php (last one is a typo, but we've had more than 100k hits in the last 24h!). We also know that they're flooding the server with POST requests trying to brute-force passwords.

frontend main
    # requests that will be monitored and blocked if abused
    acl is_wp_login path_end -i /wp-login.php /xmlrpc.php /xmrlpc.php
    acl is_post method POST
Enter fullscreen mode Exit fullscreen mode

Add this to end of the frontend main block

We now have a couple of options:

a) Now that we have the offending IPs in the log, we could change banbylog to write them to a file, and have HAProxy deny those requests. While this would work, HAProxy would need to be constantly reloaded.

b) Or we may simply leverage HAProxy stick tables and do a rate-limiting on offending requests.

While "a" would allow us to ban the offending IP for an indefinite amount of time, "b" has less moving pieces.

frontend main
    # requests that will be monitored and blocked if abused
    acl is_wp_login path_end -i /wp-login.php /xmlrpc.php /xmrlpc.php
    acl is_post method POST

    # table than can store 100k IPs, entries expire after 1 minute
    stick-table type ip size 100k expire 1m store http_req_rate(1m)

    # we'll track (save to table) the original IP only if the request hits
    # one of the monitored paths with a POST request
    http-request track-sc0 hdr(CF-Connecting-IP) if is_wp_login is_post

    # we now query the stick-table and if the IP has made more than 
    # 5 requests of the offending type in the last minute, 
    # current request is denied
    http-request deny if is_wp_login is_post { sc_http_req_rate(0) gt 5 }

Enter fullscreen mode Exit fullscreen mode

HAProxy has multiple deny options, tarpit, silent drop, reject or shadowban. A tarpit deny would be something like this:

# make request hang for 20 seconds before replying back with a 403
timeout tarpit 20s
http-request tarpit deny_status 403 if is_wp_login is_post { sc_http_req_rate(0) gt 2 }
Enter fullscreen mode Exit fullscreen mode

Notice that this puts extra stress on both HAProxy and iptables as they must keep the connection open

Just for reference, here the full, simplified, HAProxy config file that blocks requests if they hit one of the monitored URL's with more than 5 hits in less than a minute:

global
    log stdout format raw daemon debug

defaults
    log global
    mode http

frontend main
    bind *:80

    # capture original IP from header and put it in variable txn.cf_conn_ip
    http-request set-var(txn.cf_conn_ip) hdr(CF-Connecting-IP)

    # get the original IP on logs (just to check if things are working)
    log-format "%ci\ %hr\ %ft\ %b/%s\ %Tw/%Tc/%Tt\ %B\ %ts\ %r\ %ST\ %Tr CF-IP:%{+Q}[var(txn.cf_conn_ip)]"

    acl is_wp_login path_end -i /wp-login.php /xmlrpc.php /xmrlpc.php
    acl is_post method POST
    stick-table type ip size 100k expire 1m store http_req_rate(1m)
    http-request track-sc0 var(txn.cf_conn_ip) if is_wp_login is_post
    http-request deny if is_wp_login is_post { sc_http_req_rate(0) gt 5 }

    # obviously change this to whatever backend you're using
    use_backend ok

backend ok
    http-request return status 200 content-type "text/plain" lf-string "ok"

Enter fullscreen mode Exit fullscreen mode

And there you have it, using your proxy as a gatekeeper!

How to mitigate attacks on WordPress when running under a full CDN like Cloudflare
The CPU toll of a wp-login.php brute-force attack and then HAProxy acting as a bouncer.

Re-Captcha, obviously!

While this article focus on the server side of things, the first thing you should obviously do, is to foolproof forms with Re-Captcha, which you can easily do with a plugin such as reCaptcha by BestWebSoft.


EDIT: A user queried: «I have some WordPress installations under Cloudflare and some exposing the server directly. Can it work on both?»

You can. It's as simple as doing something like this:

# if CF-Connecting-IP is not set, put src IP in txn.client_ip
http-request set-var(txn.client_ip) hdr(CF-Connecting-IP)
http-request set-var(txn.client_ip) src if !{ var(txn.client_ip) -m found }
Enter fullscreen mode Exit fullscreen mode

But beware, if some sites are under Cloudflare and some aren't, a spammer can forge CF-Connecting-IP to trick the simple if/else above. You must first check if src belongs to Cloudflare before extracting txn.client_ip from the header.


How to mitigate attacks on WordPress when running under a full CDN like Cloudflare

This week, I assisted a friend in upgrading to professional TP-Link access points. I'm a strong advocate for devices that excel at a single task, and these EAP610 access points do just that. Highly recommended!

. . . .