From 555a80dbaf3865be0650f5cd0422ed4ffc62e256 Mon Sep 17 00:00:00 2001 From: Michael Junek Date: Sun, 15 Nov 2026 15:30:38 +1100 Subject: [PATCH] fix(deploy): pass the front proxy's scheme through the web tier nginx set X-Forwarded-Proto to its own scheme, which is always http since it listens without TLS behind a proxy or load balancer. The orchestrator therefore took an HTTPS site to be plain HTTP: OIDC redirect URIs were built as http://, the CSRF check expected the http origin (hence DV_CSRF_TRUSTED_ORIGINS), and the Flower cookie was issued without Secure. nginx now passes on X-Forwarded-Proto: https when the proxy in front sent it, and falls back to its own scheme otherwise. A client that sends the header itself only changes how its own request is seen. The front proxy must send X-Forwarded-Proto: https and pass Host through unchanged; DEPLOYMENT.md says so. --- deploy/orchestrator/orchestrator.env.example | 2 +- docker/nginx.conf.template | 15 ++++++++++--- docs/DEPLOYMENT.md | 11 ++++++++++ orchestrator/accounts/tests/test_auth.py | 22 ++++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/deploy/orchestrator/orchestrator.env.example b/deploy/orchestrator/orchestrator.env.example index 79c619d..1819bf4 100644 --- a/deploy/orchestrator/orchestrator.env.example +++ b/deploy/orchestrator/orchestrator.env.example @@ -7,7 +7,7 @@ DV_SECRET_KEY=change-me DV_DEBUG=false # Hostnames the UI is served on (comma separated) DV_ALLOWED_HOSTS=devicevault.example.com -# Full origins, required when the UI is served over HTTPS by a front proxy +# Full origins the UI is served on. Required when the front proxy does not send X-Forwarded-Proto: https DV_CSRF_TRUSTED_ORIGINS=https://devicevault.example.com DV_TIMEZONE=Australia/Sydney DV_LOG_LEVEL=INFO diff --git a/docker/nginx.conf.template b/docker/nginx.conf.template index 98697d8..611fa21 100644 --- a/docker/nginx.conf.template +++ b/docker/nginx.conf.template @@ -7,6 +7,15 @@ map $http_upgrade $connection_upgrade { '' close; } +# The scheme the browser used. This server speaks plain HTTP behind a TLS proxy or load +# balancer, which says so in X-Forwarded-Proto; without one, it is this server's own scheme. +# The API builds its absolute URLs (OIDC redirects), its CSRF origin check and Secure cookies +# from it. A client that sends the header itself only changes how its own request is seen. +map $http_x_forwarded_proto $dv_forwarded_proto { + default $scheme; + "~*^https(,|$)" https; +} + server { listen 8080; server_name _; @@ -26,7 +35,7 @@ server { proxy_pass $devicevault_api; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $dv_forwarded_proto; proxy_set_header X-Forwarded-Host $http_host; proxy_read_timeout 120s; } @@ -45,7 +54,7 @@ server { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $dv_forwarded_proto; # Flower must not see the session cookie or any API credentials. proxy_set_header Cookie ""; proxy_set_header Authorization ""; @@ -64,7 +73,7 @@ server { proxy_set_header X-Original-URI $request_uri; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $dv_forwarded_proto; proxy_set_header X-Forwarded-Host $http_host; } diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index f5e482a..f685420 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -88,6 +88,17 @@ use and `DV_SESSION_MAX_HOURS` after sign-in. Repeated failed sign-ins lock out username or client address for `DV_LOGIN_LOCKOUT_MIN`; the client address is the last `X-Forwarded-For` hop, which the `web` service adds. +The TLS proxy must tell the `web` service that the browser used HTTPS by sending +`X-Forwarded-Proto: https`, and pass the `Host` header through unchanged (nginx: +`proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $http_host;`; +HAProxy: `http-request set-header X-Forwarded-Proto https if { ssl_fc }`; Traefik, +Caddy and cloud load balancers do both by default). The orchestrator builds its own +URLs from them: the OIDC redirect URI it sends to the identity provider, the origin +its CSRF check expects, and whether the Flower cookie is `Secure`. Without the +header it takes the site to be plain HTTP, so OIDC sign-in is sent back to an +`http://` address, and unsafe requests are refused unless `DV_CSRF_TRUSTED_ORIGINS` +lists the HTTPS origin. + **API tokens** let scripts and monitoring call the API without a session: send `Authorization: Bearer dvt_...`. A token acts as its owner and never beyond their grants, narrowed by its scopes: `api` (the whole API, except managing tokens, diff --git a/orchestrator/accounts/tests/test_auth.py b/orchestrator/accounts/tests/test_auth.py index 756743f..1818ee6 100644 --- a/orchestrator/accounts/tests/test_auth.py +++ b/orchestrator/accounts/tests/test_auth.py @@ -55,6 +55,28 @@ def test_signing_out_ends_the_session(alice): assert AuditLog.objects.filter(action='auth.logout', actor=alice).exists() +def test_behind_an_https_proxy_the_site_is_https(alice): + """What the web tier passes on from the TLS proxy (X-Forwarded-Proto) decides the origin and URLs.""" + from django.test import RequestFactory + + proxied = {'HTTP_X_FORWARDED_PROTO': 'https', 'HTTP_X_FORWARDED_HOST': 'vault.example.com'} + client = APIClient(enforce_csrf_checks=True, **proxied) + client.get('/api/auth/csrf/') + token = client.cookies[settings.CSRF_COOKIE_NAME].value + body = {'username': 'alice', 'password': PASSWORD} + + def post(origin): + return client.post('/api/auth/login/', body, format='json', HTTP_X_CSRFTOKEN=token, HTTP_ORIGIN=origin) + + assert post('http://vault.example.com').status_code == 403 + assert post('https://vault.example.com').status_code == 200 + + request = RequestFactory().get('/', **proxied) + assert request.build_absolute_uri('/api/auth/oidc/entra/callback/') == ( + 'https://vault.example.com/api/auth/oidc/entra/callback/' + ) + + def test_csrf_is_enforced_on_sign_in_and_unsafe_requests(alice): client = APIClient(enforce_csrf_checks=True)