This is a quick note to my future self, on how I recently got Caddy and Django to play nicely after an upgrade from Django 3.2 to a something newer, like 5.0.
The problem
After updating to Django 4.0, every request that sent data like a POST (the kind you use to login) was no longer working.
It turns out the error in the logs looked like this:
CSRF verification failed. Request aborted.
Reason given for failure:
Origin checking failed - https://<mydomain.com> does not match any trusted origins.
The thing is I was serving this the site over HTTPs, and using Caddy – what had changed?
It turned out to be a new setting from Django 4.0 onwards, where Django checks that in the header of a request the “origin” (i.e. the domain where the current HTTP request came from) is the same as the request it is processing.
This is normally helpful, but if you use a reverse proxy like Caddy to handle serving a site over HTTPS, any requests by default are forwarded to the Django server over HTTP.
Django sees the mismatch, and assumes that the request is not secure, which means any POST requests will be blocked with a 403 Forbidden error.
The first solution
After a few failed attempts to fix it, I had some success with a helpful new setting called CSRF_TRUSTED_ORIGINS
, which basically tells Django to automatically trust any requests coming from a list of given origins, like https://mydomain.com
. The thing is, I to make this work, would either have to hardcode every possible domain in there, generate the value dynamically somehow, or (more likely) have a overly permissive pattern like https://*.mydomain.com
as my value.
This would treat ANY request sent from any other subdomain as a trusted one.
This is fine until a subdomain gets hacked, at which point, because you’re automatically trusting all requests from the hacked domain – something to avoid if possible.
The second, better solution
In the end, I ended up using a different approach. There is another handy setting in Django called SECURE_PROXY_SSL_HEADER
, which basically lets you define a HTTP header to look for in HTTP requests. If the header is seen, then Django can than assume the request is being proxied by a server that was initially accepting a request over a secure connection like HTTPS.
Caddy, fortunately does set a header like this that we can look for when reverse proxying to a server like Django. From the docs:
By default, Caddy passes thru incoming headers—including
Host
—to the backend without modifications, with three exceptions:
- It sets or augments the
X-Forwarded-For
header field.- It sets the
X-Forwarded-Proto
header field.- It sets the
X-Forwarded-Host
header field.For these
X-Forwarded-*
headers, by default, the proxy will ignore their values from incoming requests, to prevent spoofing.
The bit at the bottom is important – this tells us that Caddy already takes steps to mitigate against someone sneakily sending along “secure” header on an insecure request sent over regular old HTTP.
The final one liner to add a file containing various Django settings for production was this:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
A quick thank you to others before me
Figuring this would have eaten up hours of my time if I hadn’t seen:
- Carlton Gibson’s post on his own blog exploring switch that came with Django 4.0
- a forum post from hbfMartinus in the Caddy Forums,
- a well written StackOverflow issue which was similar enough to my own problem to lead me to the fix.
- And of course, Django’s stellar documentation, initially introducing the change.
Anyway, hopefully this post might help another soul wrestling with this problem in future – I probably wouldn’t have written this post had others not written theirs first, and even then, receiving a few personal emails thanking me before for writing about how to set up Caddy and Django before was what gave me the push to sink the time into writing it up.
Here’s hoping this is similarly useful to others and my future self again.