diff --git a/daybook_project/settings.py b/daybook_project/settings.py index 3b396a6..db820ce 100644 --- a/daybook_project/settings.py +++ b/daybook_project/settings.py @@ -9,6 +9,12 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me") DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"} ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()] +# Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.): +# Django otherwise thinks every request is plain HTTP, which breaks CSRF checks +# on login/POST. The proxy must forward X-Forwarded-Proto: https. +CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if origin.strip()] +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", diff --git a/deploy/cloud_agent_deploy.md b/deploy/cloud_agent_deploy.md index f320de1..82d8003 100644 --- a/deploy/cloud_agent_deploy.md +++ b/deploy/cloud_agent_deploy.md @@ -223,6 +223,11 @@ network/VPN), do this before calling the deployment done: - Put a reverse proxy in front with TLS. Bearer tokens go over plaintext HTTP otherwise. - Add the proxy's public hostname to `DJANGO_ALLOWED_HOSTS`. +- Set `DJANGO_CSRF_TRUSTED_ORIGINS=https://` in `deploy/.env`, + **and** make sure the proxy forwards `X-Forwarded-Proto: https` (examples + below). Without both, login fails with "CSRF verification failed" — Django + can't tell the original request was HTTPS once a proxy terminates TLS and + forwards plain HTTP to the container. - Keep `DJANGO_DEBUG=0` (already set above). - Restrict SSH to key-based auth only; disable password login if you have access to do so. @@ -241,6 +246,7 @@ instead. Apache example: ServerName daybook.example.com ProxyPreserveHost On + RequestHeader set X-Forwarded-Proto "https" ProxyPass / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/ SSLEngine on @@ -249,14 +255,18 @@ instead. Apache example: ``` -(`certbot --apache` will provision and wire up the certificate for you.) +`RequestHeader set X-Forwarded-Proto "https"` requires `a2enmod headers`; it's +not optional (see the CSRF note above). (`certbot --apache` will provision and +wire up the certificate for you.) Only reach for a standalone proxy like Caddy if the target genuinely has -nothing on 80/443 yet: +nothing on 80/443 yet — it still needs the same header: ```text daybook.example.com { - reverse_proxy 127.0.0.1:8000 + reverse_proxy 127.0.0.1:8000 { + header_up X-Forwarded-Proto https + } } ``` @@ -284,6 +294,10 @@ Before reporting the deployment complete, confirm all of these: - [ ] Cloned via git (not copied), with a working `git pull`. - [ ] `deploy/.env` has a real, non-placeholder `DJANGO_SECRET_KEY`. - [ ] `DJANGO_ALLOWED_HOSTS` includes the actual hostname/IP being used. +- [ ] If behind a reverse proxy: `DJANGO_CSRF_TRUSTED_ORIGINS` is set to the + public HTTPS origin, and the proxy forwards `X-Forwarded-Proto: https`. + Verify by actually logging in through the proxy, not just curling + `/api/capabilities` — CSRF only bites on POST (login, artifact writes). - [ ] `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy. - [ ] `/api/capabilities` returns `403` unauthenticated, `200` with a token. - [ ] At least one superuser exists with a working personal API token. diff --git a/deploy/env.example b/deploy/env.example index 133e497..5fe9ca3 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -1,6 +1,11 @@ DJANGO_DEBUG=0 DJANGO_SECRET_KEY=replace-with-a-long-random-secret DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-vm-ip,your-domain.example +# Required if Daybook sits behind a TLS-terminating reverse proxy (Apache, +# Nginx, Traefik, a cloud load balancer). Without it, login/POST fails with +# "CSRF verification failed" because Django can't tell the original request +# was HTTPS. Set to the public HTTPS origin(s) users actually visit: +DJANGO_CSRF_TRUSTED_ORIGINS=https://your-domain.example DAYBOOK_DATA_DIR=/data DATABASE_PATH=/data/db.sqlite3 ARTIFACTS_ROOT=/data/artifacts_store diff --git a/docs/deployment.md b/docs/deployment.md index 5db5657..5f69b0f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -159,9 +159,23 @@ If putting Daybook behind Nginx, Caddy, Traefik, or a cloud load balancer: - Proxy to `http://:8000`. - Add the public hostname to `DJANGO_ALLOWED_HOSTS`. - Terminate TLS at the proxy; keep `DJANGO_DEBUG=0`. +- Set `DJANGO_CSRF_TRUSTED_ORIGINS` to the public HTTPS origin, **and** + configure the proxy to forward `X-Forwarded-Proto: https`. Without both, + login and any other POST fails with "CSRF verification failed" — Django + has no way to know the original request was HTTPS once the proxy + terminates TLS and forwards plain HTTP to the container. ```text DJANGO_ALLOWED_HOSTS=daybook.example.com,localhost,127.0.0.1 +DJANGO_CSRF_TRUSTED_ORIGINS=https://daybook.example.com +``` + +Forwarding the proto header, by proxy: + +```text +Nginx: proxy_set_header X-Forwarded-Proto $scheme; +Apache: RequestHeader set X-Forwarded-Proto "https" (requires a2enmod headers) +Caddy: header_up X-Forwarded-Proto https ``` ## Deployment Checklist @@ -169,6 +183,8 @@ DJANGO_ALLOWED_HOSTS=daybook.example.com,localhost,127.0.0.1 - `deploy/.env` exists with a real (non-placeholder) `DJANGO_SECRET_KEY`. - `DJANGO_DEBUG=0`. - `DJANGO_ALLOWED_HOSTS` includes the hostname/IP being used. +- If behind a reverse proxy: `DJANGO_CSRF_TRUSTED_ORIGINS` is set and the + proxy forwards `X-Forwarded-Proto: https` (login will 403 otherwise). - `deploy/data/` exists and persists across `git pull` + rebuild. - `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy. - `curl -fsS http://127.0.0.1:8000/api/capabilities` returns 403 without auth, diff --git a/issues/001-csrf-behind-reverse-proxy.md b/issues/001-csrf-behind-reverse-proxy.md new file mode 100644 index 0000000..ba28e14 --- /dev/null +++ b/issues/001-csrf-behind-reverse-proxy.md @@ -0,0 +1,71 @@ +# 001 — CSRF verification fails behind a TLS-terminating reverse proxy + +**Status:** Fixed + +## Description + +Deploying Daybook behind Apache (TLS-terminating reverse proxy → plain HTTP +to the container) on `cloud-vm-1`, login failed with: + +``` +Forbidden (403) +CSRF verification failed. Request aborted. +``` + +**Root cause:** `daybook_project/settings.py` set `ALLOWED_HOSTS` from an env +var but never set `CSRF_TRUSTED_ORIGINS` or `SECURE_PROXY_SSL_HEADER`. Without +the latter, Django has no way to know the original request was HTTPS — the +proxy terminates TLS and forwards plain HTTP — so it treats every request as +insecure, which breaks CSRF checks on any POST (including login) once a +browser connects over `https://`. + +This isn't specific to `cloud-vm-1` — it'll happen on any reverse-proxied +deployment (Apache, Nginx, Traefik, a cloud load balancer), which is the +explicitly recommended setup in `deploy/cloud_agent_deploy.md` §8. The +runbook doesn't mention CSRF or proxy-awareness anywhere. + +## Fix + +**App (`daybook_project/settings.py`):** + +```python +CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if origin.strip()] +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +``` + +Committed locally as `d458cd3` ("Fix CSRF failure behind a TLS-terminating +reverse proxy") — **needs to be pushed to `main`**, the deploy agent that +made this fix didn't have push access to the Forgejo repo. + +**Deploy env (`deploy/env.example` / runbook):** needs a new var, +`DJANGO_CSRF_TRUSTED_ORIGINS`, set to the public HTTPS origin, e.g.: + +``` +DJANGO_CSRF_TRUSTED_ORIGINS=https://daybook.example.com +``` + +**Proxy config:** `SECURE_PROXY_SSL_HEADER` only works if the proxy actually +sends that header. The Apache example in `cloud_agent_deploy.md` §8 is +missing it — needs: + +```apache +RequestHeader set X-Forwarded-Proto "https" +``` + +inside the `` block (requires `a2enmod headers`). The +equivalent for Nginx is `proxy_set_header X-Forwarded-Proto $scheme;`, worth +adding to `docs/deployment.md`'s reverse-proxy notes too since that doc +mentions Nginx/Traefik as options without this caveat. + +## Resolution + +- [x] Pushed commit `d458cd3` to `main`. +- [x] Added `DJANGO_CSRF_TRUSTED_ORIGINS` to `deploy/env.example` with an + explanatory comment. +- [x] Updated `cloud_agent_deploy.md` §8's Apache example to include + `RequestHeader set X-Forwarded-Proto "https"`, plus a Caddy equivalent + and a checklist item that verifies by actually logging in through the + proxy (not just curling `/api/capabilities`, which is unauthenticated + GET and wouldn't have caught this). +- [x] Updated `docs/deployment.md`'s "Reverse Proxy Notes" and checklist with + the same requirement, plus an Nginx/Apache/Caddy header cheat sheet. diff --git a/issues/README.md b/issues/README.md new file mode 100644 index 0000000..662618b --- /dev/null +++ b/issues/README.md @@ -0,0 +1,5 @@ +# Issues + +One file per issue: `NNN-short-slug.md`, numbered sequentially. Each file +has a title, status (Open / Fixed / By design), description, and resolution +once closed.