From eed88528971b935a335fafa03f2596b1e18df00b Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Fri, 10 Jul 2026 13:12:17 -0500 Subject: [PATCH] Fix logout 403 by setting CSRF trusted origins (#18) Django 5 rejects HTTPS POSTs without CSRF_TRUSTED_ORIGINS; derive them from ALLOWED_HOSTS and trust the reverse-proxy TLS headers in prod/beta. --- .env.example | 2 + .env.prod.example | 2 + company_site/company_site/settings/base.py | 28 ++++++++++++ company_site/company_site/settings/beta.py | 6 +++ company_site/company_site/settings/prod.py | 6 +++ company_site/public/tests.py | 50 ++++++++++++++++++++++ 6 files changed, 94 insertions(+) diff --git a/.env.example b/.env.example index 939f1e8..0da3040 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,8 @@ DJANGO_ENV=dev DJANGO_DEBUG=true DJANGO_SECRET_KEY=change-me-for-local-development DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 +# Optional; when unset, http:// origins are derived for local hosts. +# DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 # Database (docker-compose sets DATABASE_URL for the web service) DATABASE_URL=postgres://company_site:company_site@db:5432/company_site diff --git a/.env.prod.example b/.env.prod.example index 9f7e4f5..ad4208f 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -7,6 +7,8 @@ DJANGO_ENV=prod DJANGO_DEBUG=false DJANGO_SECRET_KEY=replace-with-a-long-random-secret DJANGO_ALLOWED_HOSTS=aimloperations.com,www.aimloperations.com +# Optional override; when unset, https:// origins are derived from DJANGO_ALLOWED_HOSTS. +# DJANGO_CSRF_TRUSTED_ORIGINS=https://aimloperations.com,https://www.aimloperations.com # Logging (optional override; defaults: dev=DEBUG, beta=INFO, prod=WARNING) # DJANGO_LOG_LEVEL=WARNING diff --git a/company_site/company_site/settings/base.py b/company_site/company_site/settings/base.py index ba7ac65..cbd095a 100644 --- a/company_site/company_site/settings/base.py +++ b/company_site/company_site/settings/base.py @@ -82,6 +82,34 @@ WEBMCP_ENABLED = env_bool("WEBMCP_ENABLED", False) allowed_hosts = env_list("DJANGO_ALLOWED_HOSTS", "*") ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"] + +def build_csrf_trusted_origins( + allowed_hosts: list[str], explicit: list[str] | None = None +) -> list[str]: + """Build CSRF_TRUSTED_ORIGINS for Django 4+ Origin checks on HTTPS POSTs. + + Prefer DJANGO_CSRF_TRUSTED_ORIGINS when set. Otherwise derive from ALLOWED_HOSTS: + https for public hosts, http for local loopback hosts. + """ + if explicit: + return explicit + + local_hosts = {"localhost", "127.0.0.1", "0.0.0.0"} + origins: list[str] = [] + for host in allowed_hosts: + if not host or host == "*" or host.startswith("."): + continue + hostname = host.split(":")[0] + scheme = "http" if hostname in local_hosts else "https" + origins.append(f"{scheme}://{host}") + return origins + + +CSRF_TRUSTED_ORIGINS = build_csrf_trusted_origins( + ALLOWED_HOSTS, + env_list("DJANGO_CSRF_TRUSTED_ORIGINS"), +) + INSTALLED_APPS = [ "public.apps.PublicConfig", "financial.apps.FinancialConfig", diff --git a/company_site/company_site/settings/beta.py b/company_site/company_site/settings/beta.py index 2d11e94..aac88bf 100644 --- a/company_site/company_site/settings/beta.py +++ b/company_site/company_site/settings/beta.py @@ -11,4 +11,10 @@ if DEBUG: warnings.warn("DEBUG is enabled in beta environment.", stacklevel=1) +# Same reverse-proxy assumptions as production when TLS is terminated upstream. +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +USE_X_FORWARDED_HOST = True +SESSION_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SECURE = not DEBUG + LOGGING = build_logging_config(logging_level_for_env("beta"), "beta") diff --git a/company_site/company_site/settings/prod.py b/company_site/company_site/settings/prod.py index 500943e..0bdf3df 100644 --- a/company_site/company_site/settings/prod.py +++ b/company_site/company_site/settings/prod.py @@ -9,4 +9,10 @@ TIANJI_ENABLED = env_bool("TIANJI_ENABLED", True) # noqa: F405 if not env("DJANGO_SECRET_KEY"): # noqa: F405 raise ValueError("DJANGO_SECRET_KEY must be set in production.") +# App sits behind a reverse proxy that terminates TLS (docker :8000). +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +USE_X_FORWARDED_HOST = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + LOGGING = build_logging_config(logging_level_for_env("prod"), "prod") diff --git a/company_site/public/tests.py b/company_site/public/tests.py index e8368a6..014c93f 100644 --- a/company_site/public/tests.py +++ b/company_site/public/tests.py @@ -4,10 +4,60 @@ from django.contrib.auth.models import User from django.test import Client, TestCase, override_settings from django.urls import reverse +from company_site.settings.base import build_csrf_trusted_origins + from .models import Contact, EmailMessage from .seo import SERVICE_URL_NAMES, get_service_entries +class CsrfTrustedOriginsTests(TestCase): + def test_derives_https_origins_from_public_hosts(self): + origins = build_csrf_trusted_origins( + ["aimloperations.com", "www.aimloperations.com"] + ) + + self.assertEqual( + origins, + [ + "https://aimloperations.com", + "https://www.aimloperations.com", + ], + ) + + def test_derives_http_origins_for_local_hosts(self): + origins = build_csrf_trusted_origins(["localhost", "127.0.0.1"]) + + self.assertEqual(origins, ["http://localhost", "http://127.0.0.1"]) + + def test_explicit_origins_win(self): + origins = build_csrf_trusted_origins( + ["aimloperations.com"], + ["https://custom.example"], + ) + + self.assertEqual(origins, ["https://custom.example"]) + + +class LogoutCsrfTests(TestCase): + def setUp(self): + self.client = Client(enforce_csrf_checks=True) + self.user = User.objects.create_user(username="logout_user", password="pass") + + def test_logout_post_with_csrf_succeeds(self): + self.client.login(username="logout_user", password="pass") + self.client.get("/") + csrf = self.client.cookies["csrftoken"].value + + response = self.client.post( + reverse("logout"), + {"csrfmiddlewaretoken": csrf}, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") + self.assertNotIn("_auth_user_id", self.client.session) + + class PreviewEmailAuthTests(TestCase): def setUp(self): self.client = Client() -- 2.54.0