Development: Using Caddy to deter brute force attacks in WordPress

Yeah. We’re considering this a bug fix, although it may be a breaking change if some users already relied on that order. I hope not though :grimacing:

2 Likes

Part 2 of 4:

I’ve tested this. It works well.

    basicauth /wp-login.php {
        <username> <password-hash>
    }
1 Like

Part 3 of 4:

To allow only local addresses, I’m not sure why this didn’t work when accessing the login page externally…

@block {
    not remote_ip 10.1.1.0/24
    path /wp-login.php
}
    error @block "Unauthorized" 401

… but this did?

@block {
    not remote_ip forwarded 10.1.1.0/24
    path /wp-login.php
}
    error @block "Unauthorized" 401

Why I find this strange is that I’ve used the former version successfully elsewhere in my Caddyfile

    @split {
      expression `{online} == "split"`
      not remote_ip 10.1.1.0/24 10.1.2.0/24
    }
    redir @split https://udance.statuspage.io temporary

If Caddy is behind a proxy, then you need forwarded. If it’s not, then you don’t. Not enough context there to really say much else.

You understand that remote_ip looks at the IP address on the incoming TCP connection? If the request was proxied, then that IP address would be the one from the proxy rather than the actual client.

1 Like

I think I understand. The @block matcher is in Caddyfile for the upstream Caddy webserver. The @split matcher is in the Caddyfile on my frontend Caddy RP.

1 Like

Part 4 of 4:

I think I’m out of my depth with this one. Headers are still a bit of a mystery to me. I could do with a little extra help here please :pleading_face: I’m guessing it will be easier to translate the NginX example than either the Apache or IIS examples provided in the article.

Nginx – Deny Access to No Referrer Requests

location ~* (wp-comments-posts|wp-login)\.php$ {
      if ($http_referer !~ ^(http://example.com) ) {
           return 405;
      }
}

That one’s basically a header_regexp matcher that just looks for the Referer header (and yes, there’s a spelling mistake there and it’s been in HTTP for decades) to check that it starts with the given domain; if the header isn’t there then the request likely didn’t come from a real browser and can be ignored. The regexp itself will probably be the same thing in Caddy as it would nginx. Remember to use https://regex101.com with its Go mode for testing your regexp.

Also btw, consider using abort if you’re blocking bots, it’s more efficient because Caddy will just drop the connection immediately instead of sending back a response.

1 Like

I really want to get my head around headers and be comfortable working with them, so, back to first principles. I refer back to a query I raised in the thread The mysterious header .

The Referer header

I go to the WP login page on the site xxx.udance.com.au/wp-login.php and see a whole bunch of stuff in the inspect screen.

I click on the first entry under the Name column and the inspect screen changes.

Under the Name column, I see a list of names of what I’m not sure? On the title row, I see six headings Headers ... Cookies. Within the body, I see several sections General, Response Headers and Request Headers. Within each of these sections, I see what look like fields and values.

My first question is 'Where do I find the field Referer header?’

header_regexp syntax

I refer to the documentation on header_regexp

The syntax is as follows:

header_regexp [<name>] <field> <regexp>

The documentation says that <name> is optional, but recommended. Why?
The <field> I figure i’lI get from somewhere on the inspect screen

The <regexp> i think I use in this instance is http(|s)://xxx\.udance\.com\.au/(wp-comments-posts|wp-login)\.php$ after reverse engineering the NginX code sample.

Putting it all together

# I think I need the 'not' version of the regexp, but I'm not sure how to get that.
@noreferrer header_regexp <name> <field>  http(|s)://xxx\.udance\.com\.au/(wp-comments-posts|wp-login)\.php$
abort @noreferrer

There are plenty of blanks to be filled in. Help!

Those are all the individual HTTP requests the browser made to load that page. First the HTML page is loaded, and the HTML page has a bunch of <script src="javascript.js"> and <link rel="stylesheet" href="styles.css"> elements in the <head> which are references to JS and CSS. The browser sees those, pulls those down as well (they’re necessary to render the page with pretty styling and with reactive behaviour). Also you have some images (.svg, .jpg, etc) that are from <img> tags. Most of these are versioned with ?ver= queries, so that if the version is bumped on them they won’t be cached by the browser (if the URL isn’t exactly the same, the browser won’t load those files from its cache and will instead fetch them freshly from the server).

Headers is a list of all the request headers (which the browser sent to your server) and response headers (which your server sent back to the browser), and the top few lines are the basic properties of the HTTP request (method, url, status, remote address/destination of the request).

The Preview tab will just show you a rendering of the response content. It won’t always be useful, but it’s handy if for example some image is loaded but rendered off-screen and you want to see it, or to see some JSON data fetched by the browser in full, etc.

The Response tab is usually the “raw” body of the response, not rendered.

The Initiator tab is debugging information to see what exactly triggered the HTTP request to happen. It might be some click event if you clicked on something, or it could just be because it was a <script> tag, etc. Mainly useful for developers of the website, not so useful as a user.

The Timing tab will show you how long each part of the request lifecycle took. Useful for tracking down performance issues in loading pages or whatever.

The Cookies tab will show you all the cookies that this website has told your browser to hold onto and send back on every request.

Yep, headers are key-value pairs. A header may be specified more than once as well (you may have seen multiple Server: response headers for example).

Referer is a request header. (Whenever you don’t know what a specific header means, MDN is definitely the best resource to find out):

It will only show up on pages where you transitioned from one page to another. So if you just open up a tab and go to a page, you won’t have that header sent. If you click on a link on the page, then you will have that header in the request for that link.

The name field is necessary if you want to extract a result from the regexp. That’s called a “capture group”. Capture groups are anything within ( ) parentheses (regex101 will point out what parts are capture groups and what was captured in the right sidebar). Caddy will write the capture group results to placeholders like {re.<name>.<capture group>}.

You need to have defined a name on your matcher to be able to use the placeholder. If you don’t care about grabbing the output, then you don’t need to set a name.

Capture groups can either be numeric, i.e. assigned numbers in the order that the groups of parentheses appear in the regexp string, or they can have named capture groups which involve syntax like (?<group-name>/the-match) (in this case the < > are necessary and part of the syntax, not a “placeholder”) and this would let you grab the value with a placeholder like {re.foo.group-name} (given the regexp name was set to foo). Some reading on Go regexp syntax (includes Go code, hopefully you can follow along anyways):

The field in this case would literally be Referer since that’s the header you want to match on.

Yeah that’s probably pretty close. I’d suggest changing (|s) to simply s? because ? means “character appears 0 or 1 time” making the s optional.

So it might look like this:

@noreferrer header_regexp Referer https?://xxx\.udance\.com\.au/(wp-comments-posts|wp-login)\.php$
abort @noreferrer

I omitted the name because you aren’t using the result. But in this case the matching group 1 would have either wp-comments-posts or wp-login depending on which page the request came from. If you had set the name to referer, say, then you could use {re.referer.1} to get that page name.

Thank you for the crash course on headers and related content.: :ok_hand:

Don’t I need the not version of the regexp? I wasn’t sure how to achieve that. Atm, I believe the abort happens for valid referrals.

It seems to me that Referer matches the Request URL field on the inspect screen. Are they one and the same?

Thanks for the tip!

Thank you. That’s super useful!

Ah, that clears that up.

Yes, that’s much better!

Yeah, I guess so. Just use the not matcher in front of header_regexp :joy:

“inverting” a regexp is actually pretty difficult to do properly, cause it sometimes ends up involving negative lookaheads which can be pretty bad for performance and aren’t supported in all regexp engines (I’m not sure if Go supports them, I’d need to look into it)

If you clicked on a link or whatever that brought you right back to the same page, then yeah it will be the same. But if your last request was a navigation away from elsewhere, then it’ll be different.

1 Like

D’oh! :open_mouth:

I’m just about ready to pull it all together. Just one question before I do. Is there an easy way to test the last bit of code that denies access to no referrer requests?

Yeah, just don’t use not and if it blocks requests that should normally work, but then doesn’t when you add not, then you’re good to go. Because boolean logic, it can only be one or the other :sweat_smile:

Or try making requests with curl, cause that won’t add the Referer header.

1 Like

Thank you @francislavoie I have learnt so much from making this WP support doc Caddy ready.

1 Like

This is a draft proposal for Caddy equivalent constructs to be added to the end of the stated sections in the WP support doc Brute Force Attacks. I’ve attempted to align these to examples provided for other webservers, in particular NginX. Please review and provide any feedback before I issue a WP doc update request.

Protect Your Server #

For Caddy, you can use the error directive to protect your site. In the example below, wp_admin has been locked down.

    # Trigger a 401 error for wp_admin
    error /wp-admin* "Unauthorized" 401

    # Handle the error by serving an HTML page
    handle_errors {
        rewrite * /401.html
        file_server
    }

Password Protect wp-login.php #

For Caddy, you can password protect your wp-login.php file using the basicauth directive.

    basicauth /wp-login.php {
        # Add separate lines for each additional user
        user1 password-hash1
    }

Caddy configuration does not accept plaintext passwords; you MUST hash them before putting them into the configuration. The caddy hash-password command can help with this.

Limit Access to wp-login.php by IP #

For Caddy, use the remote_ip request matcher to limit access to wp-login.php by IP address.

    @blacklist {
        # All except the specifed addresses
        not remote_ip forwarded 203.0.113.15 203.0.113.16 203.0.113.17
        # or for the entire network
        # not remote_ip forwarded 203.0.113.0/24
        path /wp-login.php
    }

    # Block access to wp-login.php for blacklisted addresses
    respond @blacklist "Forbidden" 403 {
        close
    }

Deny Access to No Referrer Requests #

For Caddy, use the header_regexp request matcher to deny access to no referrer requests.

    # Stop spam attack logins and comments
    @noreferrer not header_regexp Referer https?://example\.com/(wp-comments-posts|wp-login)\.php$
    abort @noreferrer

Using abort for blocking bots is more efficient because Caddy will just drop the connection immediately instead of sending back a response.

A couple of notes:

  1. While I’ve tried to match existing examples closely, I’ve deviated somewhat for the section Limit Access to wp-login.php by IP. Rather than use the same error/handle_errors technique used in the section Protect Your Server, which would have aligned me with existing examples for other webservers, I opted to use respond. My reason for doing this is that I wanted to show the use of error, respond and abort across different examples.
  2. The Protect Your Server example is valid for Caddy 2.4.4 and later. Earlier versions of Caddy will require an order directive in the global section. I decided to leave this out of the proposal.
{
    order error before respond
}
1 Like

A WordPress doc update request has been submitted here Using Caddy to deter brute force attacks in WordPress · Issue #23 · WordPress/Documentation-Issue-Tracker · GitHub

2 Likes

Damn it! I forgot to test this. Now, that I have, if I remove not there is no difference to the result.

If abort ran I would have seen a blank screen. I guess this would suggest that the regexp matcher isn’t correct.

    @noreferrer header_regexp Referer https?://xxx\.udance\.com\.au/(wp-comments-posts|wp-login)\.php$
    abort @noreferrer

Aargh! :scream:

EDIT: Looking at the examples in Deny Access to No Referrer Requests, I notice they all use http_referer rather than Referer and it seems they treat the path separately from the domain… Is there any significance in this?

Nginx treats the headers differently and requires a http_ prefix for headers. It’s because they throw all their variables in the same bucket. PHP does the same sort of thing. Caddy doesn’t need to do that because things are “namespaced” with placeholders etc.

Hard to say why it’s not working for you. I’m not too sure.

I’ll do a deeper dive tonight when I get home and see if I can spot anything. If I debug, will I be able to see the value of Referer in the process log?