CSRF fails when RemoteUserMiddleware is used behind reverse proxy without TLS

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 default X-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 three MIDDLEWARE additions mentioned above.
  • Tried to move django.middleware.csrf.CsrfViewMiddleware to be the last in MIDDLEWARE 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

I should also mention that this setup generates a session cookie with the following attributes:

Domain: "localhost"
HostOnly: true
HttpOnly: true
Path: "/"
SameSite: "Lax"
Secure: false

This seems like a correct cookie to me.

This was finally solved by simply removing "django.contrib.auth.middleware.RemoteUserMiddleware" from MIDDLEWARE. Perhaps elementary for experience Django users but, to me, nothing in the docs indicated that the custom header middleware should replace RemoteUserMiddleware.

I think I’ll cook up a PR to add a note about this to How to authenticate using REMOTE_USER | Django documentation | Django .