Compare commits
No commits in common. "f2f2a9c96b120e20b5a5c9483bd959cb36ac7b29" and "f7dd98f6e5f19bb3f94f68417818ef7337231594" have entirely different histories.
f2f2a9c96b
...
f7dd98f6e5
6 changed files with 3 additions and 120 deletions
|
|
@ -9,12 +9,6 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -223,11 +223,6 @@ 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.
|
||||||
|
|
@ -246,7 +241,6 @@ 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
|
||||||
|
|
@ -255,18 +249,14 @@ instead. Apache example:
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
```
|
```
|
||||||
|
|
||||||
`RequestHeader set X-Forwarded-Proto "https"` requires `a2enmod headers`; it's
|
(`certbot --apache` will provision and wire up the certificate for you.)
|
||||||
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 — it still needs the same header:
|
nothing on 80/443 yet:
|
||||||
|
|
||||||
```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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -294,10 +284,6 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -159,23 +159,9 @@ 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
|
||||||
|
|
@ -183,8 +169,6 @@ Caddy: header_up X-Forwarded-Proto https
|
||||||
- `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,
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# 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.
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue