Hi, all!
I am setting up a local development environment for the Weblate localization tool which itself is built on top of Django. To emulate an SAML environment, I’m using Caddy in my Docker Compose stack to act as a reverse proxy providing (mock) authentication information. Authentication is working fine but POST requests are failing the CSRF verification.
After spending the better part of two days debugging this, I decided to ask here for help as it seems I’m getting nowhere by myself. (Although I’m not using Django per se, I am directly editing its middleware and other settings which seems to trigger the problem so I thought this would be the best place to get help.)
My hypothesis is that the RemoteUserMiddleware
in combination with the reverse proxy results in the CsrfViewMiddleware
receiving a wrong host name consequently failing the CSRF verification. However, this is more of a hunch and I have no concrete evidence to support this idea.
The Stack
(The full files are in the end of this post to keep this more readable.)
Docker Compose orchestrates the environment. An excerpt of my compose.yaml
, nothing too fancy here:
services:
weblate:
image: weblate/weblate
proxy:
image: caddy
ports:
- 80:80
There’s probably no problem here as everything works correctly if I don’t use the RemoteUserMiddleware
but I wanted to include this bit to give a better picture of my setup.
Then there’s the Caddyfile
to setup the reverse proxy:
{
debug
}
http://localhost {
reverse_proxy http://weblate:8080 {
header_up X-SHIB-Authenticated-User "test_user"
}
}
This should be pretty standard stuff too. (Note that specifying http://
turns TLS off which is what I want in local dev env.) Caddy does some work on the headers when using reverse_proxy
. Notably, it uses X-Forwarded-Proto
instead of X-Forwarded-Protocol
which I figured is the default in Django. However, I tried both setting the latter header in Caddy and using the former header name in SECURE_PROXY_SSL_HEADER
for Django but neither resolved the problem.
Finally, here’s my settings-override.py
where I enable the remote user authentication:
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.RemoteUserBackend",
"weblate.accounts.auth.WeblateUserBackend",
]
MIDDLEWARE += [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.auth.middleware.RemoteUserMiddleware",
"shibboleth-header-middleware.ShibbolethHeaderMiddleware",
]
The last middleware is to use a custom header name and is done exactly as shown in Django’s docs:
from django.contrib.auth.middleware import RemoteUserMiddleware
class ShibbolethHeaderMiddleware(RemoteUserMiddleware):
header = "HTTP_X_SHIB_AUTHENTICATED_USER"
Using this setup, the user passed in X-SHIB-Authenticated-User
is created and logged in.
The symptoms
POST requests fail with the message “CSRF verification failed. Request aborted.” shown in Weblate UI.
Container log:
weblate-1 | gunicorn stderr | Forbidden (CSRF cookie not set.): /accounts/profile/
weblate-1 | nginx stdout | 172.20.0.4 - - [17/Feb/2025:14:47:30 +0000] "POST /accounts/profile/ HTTP/1.1" 403 6662 "http://localhost/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:136.0) Gecko/20100101 Firefox/136.0"
What I’ve Tried
- Not using the
RemoteUserMiddleware
and manually logging in works. This gives me confidence that the stack is properly set up. - Used
X-Forwarded-Protocol
instead of Caddy’s defaultX-Forwarded-Proto
. - Set
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
. - Set
CSRF_TRUSTED_ORIGINS
to["http://localhost"]
,["http://*"]
, and also["http://localhost", "http://weblate", "http://proxy"]
although my understanding is that internal container domains are not necessary to use here. - Tried all different orders of
AUTHENTICATION_BACKENDS
items and of the threeMIDDLEWARE
additions mentioned above. - Tried to move
django.middleware.csrf.CsrfViewMiddleware
to be the last inMIDDLEWARE
list.
What do you think is going wrong here? Any ideas for further debugging and de-mystifying the situation? If we can solve this and it seems like something that should be documented, I’ll be happy to write it up and open a PR!
Complete Setup
In case you want to spin this stack up locally, here’s the complete setup.
./compose.yaml
services:
cache:
image: valkey/valkey:8.0.2
volumes:
- cache:/data
command: [valkey-server, --save, "60", "1"]
read_only: true
database:
image: postgres:17-alpine
ports:
- 5432:5432
env_file: .env
volumes:
- db:/var/lib/postgresql/data
weblate:
image: weblate/weblate:5.9.2.2
env_file: .env
volumes:
- ./weblate:/app/data
- app-cache:/app/cache
read_only: true
ports:
- 8080:8080 # Exposed for testing without reverse proxy.
tmpfs:
- /run
- /tmp
depends_on:
- cache
- database
- proxy
proxy:
image: caddy:2.9
ports:
- 80:80
cap_add:
- NET_ADMIN
volumes:
- proxy-data:/data
- proxy-config:/config
- ./caddy:/etc/caddy
volumes:
app-cache:
app-data:
cache:
db:
proxy-config:
proxy-data:
./caddy/Caddyfile
{
debug
}
http://localhost {
reverse_proxy http://weblate:8080 {
header_up X-SHIB-Authenticated-User "testboi"
}
}
./weblate/settings-override.py
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.RemoteUserBackend",
"weblate.accounts.auth.WeblateUserBackend",
]
MIDDLEWARE += [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.auth.middleware.RemoteUserMiddleware",
"shibboleth-header-middleware.ShibbolethHeaderMiddleware",
]
./weblate/python/shibboleth-header-middleware.py
from django.contrib.auth.middleware import RemoteUserMiddleware
class ShibbolethHeaderMiddleware(RemoteUserMiddleware):
header = "HTTP_X_SHIB_AUTHENTICATED_USER"
./.env
WEBLATE_DEBUG=1
WEBLATE_REGISTRATION_OPEN=0
WEBLATE_SITE_DOMAIN=localhost
WEBLATE_REQUIRE_LOGIN=1
WEBLATE_ALLOWED_HOSTS=*
WEBLATE_ADMIN_PASSWORD=asd
WEBLATE_REGISTRATION_ALLOW_BACKENDS=
WEBLATE_ENABLE_HTTPS=0
WEBLATE_CORS_ALLOW_ALL_ORIGINS=1
POSTGRES_PASSWORD=postgres
POSTGRES_USER=postgres
POSTGRES_DB=weblate
POSTGRES_HOST=database
POSTGRES_PORT=
REDIS_HOST=cache
REDIS_PORT=6379