Compare commits

..

2 commits

Author SHA1 Message Date
f2f2a9c96b Close out issue 001: document CSRF/reverse-proxy requirement
The app-side fix (CSRF_TRUSTED_ORIGINS, SECURE_PROXY_SSL_HEADER in
settings.py) was already committed as d458cd3 after the first Cloud-VM-1
deployment hit "CSRF verification failed" on login behind Apache. This
finishes the rest of that issue's checklist: documents
DJANGO_CSRF_TRUSTED_ORIGINS in deploy/env.example, adds the required
X-Forwarded-Proto header to the Apache/Caddy examples in
cloud_agent_deploy.md (plus a checklist item that verifies via an actual
login, not just an unauthenticated capabilities curl), and adds the same
proxy-agnostic guidance to docs/deployment.md.

Also introduces issues/, a tracked one-file-per-issue log for the project
going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 12:28:41 +01:00
d458cd3673 Fix CSRF failure behind a TLS-terminating reverse proxy
Django had no CSRF_TRUSTED_ORIGINS and no SECURE_PROXY_SSL_HEADER, so
it always treated requests as plain HTTP. Login over HTTPS behind
Apache/Nginx failed CSRF verification as a result.
2026-09-02 12:17:16 +01:00
6 changed files with 120 additions and 3 deletions

View file

@ -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"} 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()] 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 = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",

View file

@ -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 - Put a reverse proxy in front with TLS. Bearer tokens go over plaintext HTTP
otherwise. otherwise.
- Add the proxy's public hostname to `DJANGO_ALLOWED_HOSTS`. - Add the proxy's public hostname to `DJANGO_ALLOWED_HOSTS`.
- Set `DJANGO_CSRF_TRUSTED_ORIGINS=https://<public-hostname>` 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). - Keep `DJANGO_DEBUG=0` (already set above).
- Restrict SSH to key-based auth only; disable password login if you have - Restrict SSH to key-based auth only; disable password login if you have
access to do so. access to do so.
@ -241,6 +246,7 @@ instead. Apache example:
<VirtualHost *:443> <VirtualHost *:443>
ServerName daybook.example.com ServerName daybook.example.com
ProxyPreserveHost On ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
ProxyPass / http://127.0.0.1:8000/ ProxyPass / http://127.0.0.1:8000/
ProxyPassReverse / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/
SSLEngine on SSLEngine on
@ -249,14 +255,18 @@ instead. Apache example:
</VirtualHost> </VirtualHost>
``` ```
(`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 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 ```text
daybook.example.com { 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`. - [ ] Cloned via git (not copied), with a working `git pull`.
- [ ] `deploy/.env` has a real, non-placeholder `DJANGO_SECRET_KEY`. - [ ] `deploy/.env` has a real, non-placeholder `DJANGO_SECRET_KEY`.
- [ ] `DJANGO_ALLOWED_HOSTS` includes the actual hostname/IP being used. - [ ] `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. - [ ] `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy.
- [ ] `/api/capabilities` returns `403` unauthenticated, `200` with a token. - [ ] `/api/capabilities` returns `403` unauthenticated, `200` with a token.
- [ ] At least one superuser exists with a working personal API token. - [ ] At least one superuser exists with a working personal API token.

View file

@ -1,6 +1,11 @@
DJANGO_DEBUG=0 DJANGO_DEBUG=0
DJANGO_SECRET_KEY=replace-with-a-long-random-secret DJANGO_SECRET_KEY=replace-with-a-long-random-secret
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-vm-ip,your-domain.example 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 DAYBOOK_DATA_DIR=/data
DATABASE_PATH=/data/db.sqlite3 DATABASE_PATH=/data/db.sqlite3
ARTIFACTS_ROOT=/data/artifacts_store ARTIFACTS_ROOT=/data/artifacts_store

View file

@ -159,9 +159,23 @@ If putting Daybook behind Nginx, Caddy, Traefik, or a cloud load balancer:
- Proxy to `http://<vm-ip>:8000`. - Proxy to `http://<vm-ip>:8000`.
- Add the public hostname to `DJANGO_ALLOWED_HOSTS`. - Add the public hostname to `DJANGO_ALLOWED_HOSTS`.
- Terminate TLS at the proxy; keep `DJANGO_DEBUG=0`. - 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 ```text
DJANGO_ALLOWED_HOSTS=daybook.example.com,localhost,127.0.0.1 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 ## 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`. - `deploy/.env` exists with a real (non-placeholder) `DJANGO_SECRET_KEY`.
- `DJANGO_DEBUG=0`. - `DJANGO_DEBUG=0`.
- `DJANGO_ALLOWED_HOSTS` includes the hostname/IP being used. - `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. - `deploy/data/` exists and persists across `git pull` + rebuild.
- `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy. - `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, - `curl -fsS http://127.0.0.1:8000/api/capabilities` returns 403 without auth,

View file

@ -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 `<VirtualHost *:443>` 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.

5
issues/README.md Normal file
View file

@ -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.