Using Caddy to harden WordPress

Caddy equivalent code for specific sections of the WordPress support article Hardening WordPress .

Securing wp-includes #

For a Caddy web server, use a named matcher set to secure the include paths while still allowing access to ms-files.php for multisite.

    @forbidden {
        not path /wp-includes/ms-files.php
        path /wp-admin/includes/*.php
        path /wp-includes/*.php
    }
    respond @forbidden "Access denied" 403

Securing wp-config.php #

For a Caddy web server, add the wp-config.php path to the named matcher set described in the previous section. This will prevent access to wp-config.php in the webroot.

        path /wp-config.php
Credits and References

Credits

@Whitestrake @francislavoie for their contributions within the development reference.

References

  1. Hardening WordPress
  2. Development: Using Caddy to harden WordPress
2 Likes

Hi Basil,

change this

        path /wp-admin/includes/*.php
        path /wp-includes/*.php

to this

        path_regexp (?i)/wp-admin/includes/.*\.php
        path_regexp (?i)/wp-includes/.*\.php

otherwise the directives would not block requests in sudirs e.g. /wp-includes/blocks/shortcodes.php. Case insensitivity also does not hurt in my opinion - but they don’t use the NC (no case) flag in their RewriteRules example. So if you wan’t to mimic that just leave out the (?i) in my example.

When I use path_regexp nothing in the @section matches. Caddy simply feeds any file instead.

1 Like

You are right. Docs say There can only be one path_regexp matcher per named matcher. I tested it the wrong way.

This one should do the trick:

    @hardenwp expression `(
        {path}.matches("(?i)/wp-includes/.*\\.php")
        || {path}.matches("(?i)/wp-admin/includes/.*\\.php")
    )`
    respond @hardenwp "Access denied" 403

A snippet example to be imported where needed; includes the whitelisted ms-files.php request.

(hardenwp) {
    @hardenwp expression `(
	!{path}.matches("/wp-includes/ms-files.php$")
        && {path}.matches("(?i)/wp-includes/.*\\.php")
        || {path}.matches("(?i)/wp-admin/includes/.*\\.php")
    )`
    respond @hardenwp "Access denied" 403
}
1 Like

Alternatively, you could write it like this:

path_regexp (?i)/(wp-admin/includes|wp-includes)/.*\.php
1 Like

Would a handle be an option here? I struggle a little with the choices here.

@bernd @Forza @francislavoie Thanks for your contributions. I understand the merits of using regular expressions, but before I make any change, I’d like you to consider the following questions:
For the average Caddyfile user, does the one-line path_regexp expression improve or reduce the clarity of the original two lines that it replaces? If you believe the latter, as I do, does the reduced clarity warrant the saving of one line in the Caddyfile? I’m not so sure, but I’m happy to be guided by your thoughts.

1 Like

I think simple one liners are easier. I would, however, like some simple example on how to combine several matches. Perhaps better to deny all, and only allow specific files and folders?
Allow /includes/theme/*.css and so on?

1 Like

I think there’s a lot that can be done to harden WordPress further. However, the purpose of this wiki article is only to provide Caddy equivalent code for the referenced WordPress article. If the WordPress article is updated to further harden WordPress, the intent is to reflect those changes here.

FWIW, as well as the lines in the OP above, my own matcher includes the three additional lines shown below:

    @forbidden {
        not path /wp-includes/ms-files.php
        path /wp-admin/includes/*.php
        path /wp-includes/*.php
        path /wp-config.php

        path /wp-content/uploads/*.php
        path /.user.ini
        path /wp-content/debug.log
    }

The WordPress support article adopts an opt-out approach rather than an opt-in approach. Which is better? It’s hard to say.

With opt-out it’s harder to cover all cases, for example if some additional files were added that an attacker could use. With opt-in this ft can’t happen as easily. However the downside is of course that updated and such might break as you might now not allow some of their files to be served. Considering the use case of simplicity, opt-out is very.

This will not match any WordPress uploads down the hierarchy that are usually structured via date schemes like

/var/www/liveticker/wp-content/uploads/2021/10/image-150x150.jpeg
/var/www/liveticker/wp-content/uploads/2021/10/malicous-script.php

which is why I tested path_regexp and went on to expression matchers.

Anyway. WordPress will by default not allow to upload PHP files. An attacker could try to circumvent this default setting by registering a callback to the upload_mimes filter (“Filters list of allowed mime types and file extensions.”) or find or exploit a problem in plugins that write content below wp-content/uploads/. Often times plugins need write access to other directories too and you need to protect those (e.g. GravityForms temporary uploads, many of the Caching-Plugins, image resizers, backup plugins or even logs of security plugins).

My personal preference is not to put everything in a onlineliner but rather to split the patterns over multiple lines along with comments so I remember for what reason a pattern exists.

1 Like

You’ve raised a good point regarding the hierarchical nature of the uploads path.

@francislavoie Multiple path matchers are OR’ed together in a matcher set. Is this true also for a matcher set consisting of path and path_regexp matchers?

No I think it will be ANDed, which isn’t too useful. Expression matcher is probably the easiest approach to writing something that makes sense, if not a single path_regexp.

For now anyways. There’s definitely lots of room for improvement with matchers in Caddy but most of the ideas I’ve had are difficult to implement.

1 Like

@bernd You’re right. A named matcher that utilises an expression matcher rather than a path matcher set is necessary for more sophisticated WordPress hardening.