Development: Using Caddy to deter brute force attacks in WordPress

1. Caddy version (caddy version):

n/a

2. How I run Caddy:

a. System environment:

n/a

b. Command:

n/a

c. Service/unit/compose file:

n/a

d. My complete Caddyfile or JSON config:

n/a

3. The problem I’m having:

I’m looking to provide some Caddy examples for the WordPress support article Brute Force Attacks that presently has examples for Apache, NginX and IIS. More details in the next post.

4. Error messages and/or full log output:

n/a

5. What I already tried:

n/a

6. Links to relevant resources:

  1. Brute Force Attacks

It’s these sections of the WP article that I’m hoping to provide Caddy equivalent examples for.

  1. Protect Your Server
  2. Password Protect wp-login.php
  3. Limit Access to wp-login.php by IP
  4. Deny Access to No Referrer Requests

I’ll deal with each one in turn in posts to follow.

Part 1 of 4: Extract

Protect Your Server #

If you decide to lock down wp-login.php or wp-admin, you may find you get a 404 or 401 error when accessing those pages. To avoid that, you will need to add the following to your .htaccess file.

ErrorDocument 401 default

You can have the 401 point to 401.html, but the point is to aim it at not WordPress.

For Nginx you can use the error_page directive but must supply an absolute url.

error_page 401 http://example.com/forbidden.html;

On IIS web servers you can use the httpErrors element in your web.config, set errorMode="custom":

<httpErrors errorMode="Custom">
<error statusCode="401"
subStatusCode="2"
prefixLanguageFilePath=""
path="401.htm"
responseMode="File" />
</httpErrors>

Part 1 of 4: Caddy Code design

This is not something I’ve dealt with before, but I suspect it will be something along the lines of this Caddy doc example error (Caddyfile directive) — Caddy Documentation

    # Trigger errors for certain paths
    error /wp-login.php "Unauthorized" 401
    error /wp-admin* "Unauthorized" 401

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

Am I on the right track?

Hmm… i’m not sure this is working. I add the code to my Caddyfile on the test site…

{
#    debug
    log {
        format json {
            time_format iso8601
        }
    }
}

:80 {

    encode gzip zstd
    log {
        format json {
            time_format iso8601
        }

        output file /var/log/caddy/access.log {
            roll_keep 7
        }
    }

    root * /usr/local/www/wordpress

    php_fastcgi 127.0.0.1:9000 {
        env SERVER_PORT 80
    }

    # Trigger errors for certain paths
    error /wp-login.php "Unauthorized" 401
    error /wp-admin* "Unauthorized" 401

    # Handle the error by serving an HTML page
    handle_errors {
        # respond "You've been sprung!"
        rewrite * /401.html
        file_server
    }

    file_server

  # External access denied to these files.
  @forbidden {
    not path /wp-includes/ms-files.php
    path /wp-admin/includes*
    path /wp-includes*
    path /wp-config.php
  }
  respond @forbidden "Access denied" 403
}

When I try to break-i, this is what I see…

This would suggest one of two things:

  1. I’ve done something wrong; or
  2. WP is now handling errors internally and the section in the support doc is outdated.

Thoughts?

If my Caddy approach is incorrect here, please let me know. If not, I’ll raise a query in the WP forum just to see if WP is now trapping errors, which may mean this section of the documentation is out of date.

This is saying “if you enable any of the below methods, then be aware of this”. That in of itself isn’t a way to “protect your server”. That’s just throwing an error all the time which isn’t useful :stuck_out_tongue: It’s also specific to Apache, due to the way it deals with errors. Not needed for Caddy.

This is easy, just use basicauth with a path matcher.

Also easy, remote_ip matcher paired with respond or error or abort.

This would involve a header matcher on the Referer header, paired with respond or error or abort.

2 Likes

Ah, I’ve misunderstood this.

I’m not so sure about this :thinking: Examples were also provided for NginX and IIS.

I’m interpreting this in the following manner now. A WP web designer has completed building a website for a client. They adopt a ‘belts and braces’ approach and lock down the site by preventing access to the WP admin area. Rather than throwing up a 400 series error, they choose to direct requests to the admin area to another page.

What’s not clear to me is the Caddy code that allows me to achieve this. For instance, I can force a 401 error using the following code:

    @forbidden {
        path /wp-login.php
    }
    respond @forbidden "Unauthorized" 401

However, I don’t seem to be able to get this error trapping to work.

    error /wp-login.php "Unauthorized" 401
    handle_errors {
        rewrite * /401.html
        file_server
    }

See post #5.

That’s just because of they way they handle cascading errors. It would be the equivalent of using the error handler then using handle_errors to render the error. But if you use respond or abort, that’s not necessary.

My understanding is that they just intend to block access to the admin to some users by doing selective blocking. The point of this is that /wp-login.php is ripe for bots to hammer with requests to try to brute force password attempts, which can also make your server use a ton of extra CPU because password hashing is very slow/costly to compute on purpose. It’s not really useful to block it for everyone, cause that means locking yourself out too.

This is the weird bit. I haven’t been able to manually trigger an error. I would have expected to see a response with the code below.

    error /subdir* "Unauthorized" 401
   ...
    handle_errors {
        respond "{http.error.status_code} {http.error.status_text}"
    }

Instead, I get WP trapping the error.

Caddy error trapping appears to have been ignored, or have I done something wrong?

The version of Caddy I’m using…

root@wp-xxx:~ # caddy version
v2.4.3 h1:Y1FaV2N4WO3rBqxSYA8UZsZTQdN+PwcoOcAiZTM8C0I=

Oh right. Dammit. :man_facepalming: Directive order.

error is last in the order, so it ends up sorted after everything else. We probably didn’t pick the “correct” default order for this handler.

So uh, route or order or handle are your friends here. I know that’s not so nice for something we suggest for the WordPress docs though.

{
    order error before respond
}

:8881 {
    error /foo* "Caddy Unauthorized" 401
    handle_errors {
        respond "{http.error.status_code} {http.error.status_text}"
    }
    respond "No error" 200
}

Edit: Spoke with Matt, we’ll change the directive order for abort and error. We’ve seen the error of our ways, and we’re aborting the stupidity :rofl:

2 Likes

Got it! Thanks! For a moment there, I thought I was losing my mind :crazy_face:

2 Likes

I guess what this means is that in 2.4.4, I can leave out the order directive in the global section.

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;
      }
}