The {scheme} placeholder will output http or https depending on the current connection, and {host} will output xxx.udance.com.au (or whatever the current hostname is, so it doesn’t need to be hardcoded), and * will match the rest, whatever the path/query of the request is.
header and path are basically simplified pattern matchers, and usually faster than their _regexp cousins for simple cases.
That’s the assumption, yes. This is by no means fool-proof. It only helps to block very unsophisticated bots. It’s trivial to just include the Referer header if someone figures out that’s the condition used to block requests.
Send the origin, path, and querystring when performing a same-origin request. For cross-origin requests send the origin (only) when the protocol security level stays same (HTTPS→HTTPS). Don’t send the Referer header to less secure destinations (HTTPS→HTTP).
I think what’s happening is that I’m going from a frontend Caddy RP with TLS termination to an upstream Caddy webserver serving WP PHP files. This happens over HTTP. The Referer header isn’t being transmitted. This might explain why I’m not getting a match on either…
Does this explanation seem plausible? If so, what follows is that I is that I should withdraw any Caddy code submitted for the WP section [Deny Access to No Referrer Requests]
EDIT: Out of curiosity, if I enable mTLS, would Referer then be be transmitted to the upstream server?
Again, your access logs will show whether Caddy received the header. It should not be affected by going through another Caddy instance, since all headers are copied by reverse_proxy.
It might be a directive order issue. Try caddy adapt --pretty and see what order they’re in, to make sure some rewrite didn’t prevent the matcher from working.
As a proof of concept that the matcher is working:
So basically, a plain request to not one of those two paths should return fine. A request to one of those two paths without a Referer header should fail. A request to one of those two paths with a Referer header should pass. (With abort, you’ll see curl: (52) Empty reply from server)
So if it’s not working for you, it must be something else in your config preventing it from getting hit, I guess.
Well, remove respond "Yep" if you have file_server etc, because otherwise it’ll just take precedence over those. php_fastcgi won’t even get reached.
Actually, that’s exactly as expected. If you directly load wp-login.php without having gotten there from a page navigation like clicking a link, then you should get rejected, because in that case Referer wouldn’t be there.
But to be honest, this entire approach to “hardening” is really stupid. It’s very much a hack, and in no way reliable. As you can see, some bot/bad actor could just add the Referer header to get through. It’s basically Security through obscurity - Wikipedia. This doesn’t really solve any problem.
Something odd is happening around Referer, but I can’t put my finger on it. It seems pretty random whether it appears in the access log or not. Furthermore, the behaviour of the matcher doesn’t seem to correlate with what I’m seeing in the access log. It’s confusing.
Extract from the process log after clicking the link from the main page.
{"level":"info","ts":1630386207.4195123,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_addr":"10.1.1.4:65082","proto":"HTTP/1.1","method":"GET","host":"xxx.udance.com.au","uri":"/wp-login.php","headers":{"Accept-Encoding":["gzip, deflate, br"],"Sec-Ch-Ua":["\"Chromium\";v=\"92\", \" Not A;Brand\";v=\"99\", \"Microsoft Edge\";v=\"92\""],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36 Edg/92.0.902.84"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"],"Referer":["https://xxx.udance.com.au/"],"Sec-Fetch-Site":["same-origin"],"X-Forwarded-For":["10.1.1.222"],"X-Forwarded-Proto":["https"],"Accept-Language":["en-US,en;q=0.9"],"Sec-Fetch-Mode":["navigate"],"Sec-Fetch-User":["?1"],"Upgrade-Insecure-Requests":["1"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Dest":["document"]}},"common_log":"10.1.1.4 - - [31/Aug/2021:13:03:27 +0800] \"GET /wp-login.php HTTP/1.1\" 200 4","duration":0.000105318,"size":4,"status":200,"resp_headers":{"Server":["Caddy"],"Content-Type":[]}}
{"level":"debug","ts":1630386207.4482172,"logger":"http.handlers.rewrite","msg":"rewrote request","request":{"remote_addr":"10.1.1.4:65082","proto":"HTTP/1.1","method":"GET","host":"xxx.udance.com.au","uri":"/favicon.ico","headers":{"X-Forwarded-For":["10.1.1.222"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36 Edg/92.0.902.84"],"Accept-Encoding":["gzip, deflate, br"],"Referer":["https://xxx.udance.com.au/wp-login.php"],"Sec-Fetch-Dest":["image"],"Sec-Fetch-Site":["same-origin"],"Sec-Fetch-Mode":["no-cors"],"X-Forwarded-Proto":["https"],"Accept":["image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"],"Accept-Language":["en-US,en;q=0.9"],"Sec-Ch-Ua":["\"Chromium\";v=\"92\", \" Not A;Brand\";v=\"99\", \"Microsoft Edge\";v=\"92\""],"Sec-Ch-Ua-Mobile":["?0"]}},"method":"GET","uri":"index.php"}
{"level":"debug","ts":1630386207.448344,"logger":"http.reverse_proxy.transport.fastcgi","msg":"roundtrip","request":{"remote_addr":"10.1.1.4:65082","proto":"HTTP/1.1","method":"GET","host":"xxx.udance.com.au","uri":"index.php","headers":{"Sec-Fetch-Site":["same-origin"],"X-Forwarded-For":["10.1.1.222, 10.1.1.4"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36 Edg/92.0.902.84"],"Accept-Encoding":["gzip, deflate, br"],"Referer":["https://xxx.udance.com.au/wp-login.php"],"Sec-Fetch-Dest":["image"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Mode":["no-cors"],"X-Forwarded-Proto":["https"],"Accept":["image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"],"Accept-Language":["en-US,en;q=0.9"],"Sec-Ch-Ua":["\"Chromium\";v=\"92\", \" Not A;Brand\";v=\"99\", \"Microsoft Edge\";v=\"92\""]}},"dial":"127.0.0.1:9000","env":{"AUTH_TYPE":"","CONTENT_LENGTH":"","CONTENT_TYPE":"","DOCUMENT_ROOT":"/usr/local/www/wordpress","DOCUMENT_URI":"index.php","GATEWAY_INTERFACE":"CGI/1.1","HTTP_ACCEPT":"image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8","HTTP_ACCEPT_ENCODING":"gzip, deflate, br","HTTP_ACCEPT_LANGUAGE":"en-US,en;q=0.9","HTTP_HOST":"xxx.udance.com.au","HTTP_REFERER":"https://xxx.udance.com.au/wp-login.php","HTTP_SEC_CH_UA":"\"Chromium\";v=\"92\", \" Not A;Brand\";v=\"99\", \"Microsoft Edge\";v=\"92\"","HTTP_SEC_CH_UA_MOBILE":"?0","HTTP_SEC_FETCH_DEST":"image","HTTP_SEC_FETCH_MODE":"no-cors","HTTP_SEC_FETCH_SITE":"same-origin","HTTP_USER_AGENT":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36 Edg/92.0.902.84","HTTP_X_FORWARDED_FOR":"10.1.1.222, 10.1.1.4","HTTP_X_FORWARDED_PROTO":"https","PATH_INFO":"","QUERY_STRING":"","REMOTE_ADDR":"10.1.1.4","REMOTE_HOST":"10.1.1.4","REMOTE_IDENT":"","REMOTE_PORT":"65082","REMOTE_USER":"","REQUEST_METHOD":"GET","REQUEST_SCHEME":"http","REQUEST_URI":"/favicon.ico","SCRIPT_FILENAME":"/usr/local/www/wordpress/index.php","SCRIPT_NAME":"/index.php","SERVER_NAME":"xxx.udance.com.au","SERVER_PROTOCOL":"HTTP/1.1","SERVER_SOFTWARE":"Caddy/v2.4.3"}}
{"level":"debug","ts":1630386207.7043395,"logger":"http.handlers.reverse_proxy","msg":"upstream roundtrip","upstream":"127.0.0.1:9000","request":{"remote_addr":"10.1.1.4:65082","proto":"HTTP/1.1","method":"GET","host":"xxx.udance.com.au","uri":"index.php","headers":{"Sec-Fetch-Mode":["no-cors"],"X-Forwarded-Proto":["https"],"Accept":["image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"],"Accept-Language":["en-US,en;q=0.9"],"Sec-Ch-Ua":["\"Chromium\";v=\"92\", \" Not A;Brand\";v=\"99\", \"Microsoft Edge\";v=\"92\""],"Sec-Ch-Ua-Mobile":["?0"],"X-Forwarded-For":["10.1.1.222, 10.1.1.4"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36 Edg/92.0.902.84"],"Accept-Encoding":["gzip, deflate, br"],"Referer":["https://xxx.udance.com.au/wp-login.php"],"Sec-Fetch-Dest":["image"],"Sec-Fetch-Site":["same-origin"]}},"headers":{"X-Powered-By":["PHP/7.4.21"],"Content-Type":["text/html; charset=UTF-8"],"Link":["<https://xxx.udance.com.au/wp-json/>; rel=\"https://api.w.org/\""],"X-Redirect-By":["WordPress"],"Location":["https://xxx.udance.com.au/wp-includes/images/w-logo-blue-white-bg.png"],"Status":["302 Found"]},"status":302}
{"level":"info","ts":1630386207.704579,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_addr":"10.1.1.4:65082","proto":"HTTP/1.1","method":"GET","host":"xxx.udance.com.au","uri":"/favicon.ico","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36 Edg/92.0.902.84"],"Accept-Encoding":["gzip, deflate, br"],"Referer":["https://xxx.udance.com.au/wp-login.php"],"Sec-Fetch-Dest":["image"],"Sec-Fetch-Site":["same-origin"],"X-Forwarded-For":["10.1.1.222"],"Accept":["image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"],"Accept-Language":["en-US,en;q=0.9"],"Sec-Ch-Ua":["\"Chromium\";v=\"92\", \" Not A;Brand\";v=\"99\", \"Microsoft Edge\";v=\"92\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Mode":["no-cors"],"X-Forwarded-Proto":["https"]}},"common_log":"10.1.1.4 - - [31/Aug/2021:13:03:27 +0800] \"GET /favicon.ico HTTP/1.1\" 302 0","duration":0.256450471,"size":0,"status":302,"resp_headers":{"X-Powered-By":["PHP/7.4.21"],"Content-Type":["text/html; charset=UTF-8"],"Link":["<https://xxx.udance.com.au/wp-json/>; rel=\"https://api.w.org/\""],"X-Redirect-By":["WordPress"],"Location":["https://xxx.udance.com.au/wp-includes/images/w-logo-blue-white-bg.png"],"Server":["Caddy"],"Status":["302 Found"]}}
It’s literally whether your browser arrived to that page via a page transition or not. That’s it. If you refresh in your browser, then it will not have that header, because the refresh is a fresh page load without the page transition.
Looking at the Apache config for this, looks like they only block POST requests, and not GET requests to that page. Doesn’t seem like the nginx makes that distinction, for some reason. So you could add a method POST matcher in there.
Also you could respond with respond "NOPE: {scheme}://{host}*" just to see what the placeholders spit out, but I’m pretty sure that should be correct.
I don’t fully comprehend the purpose of this. A quick search suggests GET is idempotent (same result each time) What is the difference between POST and GET? [duplicate], while PUT is not. Is that the reason for adding this in?
Oh interesting, well that’s the issue. The scheme doesn’t match. http but the header is https. Because you have that proxy hop in between which is HTTP between them. So uh, I guess don’t use {scheme} and just hard-code https, or use {header.X-Forwarded-Proto} lol but might as well just hardcode it.
The GET request will be the actual page load where you receive the HTML in the browser. The POST is the actual form submit action on that page when you click “Log in”, sending the user/pass to the app. The “important part” is to prevent bots from POSTing, because that’s the “heavy” operation, the part that actually makes the app do password verification. Blocking the GET is not really useful because then it will just block legitimate users from loading the login page if they directly go to it. The important part is to block POST requests from clients who were not actually on the login page when sending it (e.g. bots… but again, in no way fool-proof)
This works whether I navigate from a site link or access the login page directly.
To test for an offsite referrer (i.e. referrer is not https://xxx.udance.com.au*), I temporarily remove the not from the matcher.
Questions:
Using header, I can set, add and delete headers. Rather than relying on logs, Is there a way to display headers using respond?
If I leave out method POST, the result is the same. As long as the Referer originates from the site, does it matter if method is POST or GET? Another way of looking at this is…wouldn’t you also want to block GET requests from bots? Maybe this is why the NginX example didn’t make that distinction.
If the WP site was not behind a RP, would {scheme} have worked?
Assuming method POST is redundant, this is the replacement text I’m proposing for the WP support doc to deny access to no referrer requests: It should work whether or not the WP site is behind a RP. Please review.
For Caddy, deny access to logins and comments if an off-site Referer attempts to submit content.
# Stop spam attack logins and comments
@protected path_regexp (wp-comments-posts|wp-login)\.php$
handle @protected {
@no-referer {
not header Referer https://{host}*
method POST
}
abort @no-referer
}
Using abort for blocking bots is more efficient because Caddy will just drop the connection immediately instead of sending back a response.
The text above will replace what was previously submitted:
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.
It’s expected that you never get blocked in the browser. The point of this is to block bots but not to block legitimate browser usage.
It absolutely does matter if the it’s a GET or POST, because GETs are requests to load the HTML of the page, and POSTs are the form submissions.
It’s not useful to block GETs because then it would block legitimate users trying to access the login page directly or refreshing once they land on the page.
The part that this is intended to block is bots brute forcing the login form by making repeated requests over and over. There’s lots of bots just sending admin/admin and other common user/pass combinations to /wp-login.php on all kinds of websites until they get a successful login response so they can hijack your site.
(Like I’ve said before, bots would be stupid to not set the Referer header so I really doubt this would have any actual benefit, more important to just ensure strong password usage, consider using 2FA for WordPress logins since bots will never be able to brute force that)
A better test case is this:
# Should fail (I seem to get a 502 from Cloudflare, probably because "abort")
$ curl -v -d "log=admin&pwd=admin&wp-submit=Log+In" https://xxx.udance.com.au/wp-login.php
# Should get through due to Referer header
$ curl -v -d "log=admin&pwd=admin&wp-submit=Log+In" -H "Referer: https://xxx.udance.com.au" https://xxx.udance.com.au/wp-login.php
# Should also get through (GET request with no Referer, is OK)
$ curl -v https://xxx.udance.com.au/wp-login.php
And all requests in the browser should work fine without getting blocked.
To explain, -d is “data”, basically asks curl to send a POST request with this data. It looks like a query string, but it gets sent as Content-Type: application/x-www-form-urlencoded which is how HTML forms are submitted.
Yeah, it should. It’s just that to the perspective backend Caddy, the request that came in was over HTTP so that’s working “as intended”, it’s just that what we were trying to use it for required the “real” scheme coming from the client – which is in the X-Forwarded-Proto header, but it’s not worth faffing with that in this situation, because if you’re using Caddy, it’s like 99.9% likely you’re using HTTPS for the actual client requests anyways.