fix(csrf): CSRF_TRUSTED_ORIGINS env var for host-rewriting proxies#2901
Merged
Conversation
…2900) IIS ARR with the default ``preserveHostHeader=false`` rewrites the upstream ``Host`` before forwarding to anthias-server, so the browser's ``Origin: https://signage.example.com`` and uvicorn's ``Host: anthias.localdomain`` are genuinely different hostnames. The existing ``SameHostOriginCsrfMiddleware`` fallback only tolerates scheme drift on the *same* host, not different hosts — uploads (and every other unsafe POST) 403 with ``Origin checking failed``. The pre-rebrand React+DRF stack didn't hit this because uploads went through DRF's Basic-auth API where ``SessionAuthentication``'s CSRF enforcement didn't apply. The new Django+HTMX upload runs through ``CsrfViewMiddleware`` directly, which is why this regression surfaced after the migration to Django templates. Fix: expose Django's first-class ``CSRF_TRUSTED_ORIGINS`` setting as a comma-separated env var. Operators behind a host-rewriting reverse proxy list the public origin they actually serve under (e.g. ``CSRF_TRUSTED_ORIGINS=https://signage.example.com``); Django's stock ``_origin_verified`` then accepts requests from that origin. Default is empty, so the same-host fallback continues to cover plain LAN / Caddy-sidecar deployments where the proxy preserves Host upstream — no behaviour change for existing setups. The earlier "intentionally not set" comment was about the wildcard limitation (Django only honours subdomain wildcards) and didn't rule out specific hostnames; updated to reflect what's now supported. Regression coverage in ``tests/test_csrf.py``: * ``test_iis_rewrite_host_proxy_without_trusted_origin_rejected`` pins the 403 that justifies the new knob existing. * ``test_iis_rewrite_host_proxy_with_trusted_origin_passes`` pins the fix — listing the public origin makes the POST succeed. * ``test_trusted_origin_does_not_open_other_hosts`` pins that the allowlist stays exact (``signage.example.com`` doesn't open ``attacker.example``). No new proxy-header trust added; no change to ``request.get_host()``, ``is_secure()``, or ``build_absolute_uri()``. The operator opts in explicitly per-deployment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an operator-configurable escape hatch for CSRF Origin validation when running Anthias behind host-rewriting reverse proxies (e.g., IIS ARR with preserveHostHeader=false), by exposing Django’s CSRF_TRUSTED_ORIGINS as a comma-separated environment variable and pinning the behavior with regression tests.
Changes:
- Add
CSRF_TRUSTED_ORIGINSparsing from a newCSRF_TRUSTED_ORIGINSenv var in Django settings (default empty). - Add regression tests covering: rejection without trusted origin, acceptance with trusted origin, and exact allowlist behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/anthias_server/django_project/settings.py |
Introduces env-driven CSRF_TRUSTED_ORIGINS to support host-rewriting proxy deployments. |
tests/test_csrf.py |
Adds Issue #2900 regression tests validating trusted-origin behavior and non-bypass for other hosts. |
Comments suppressed due to low confidence (1)
tests/test_csrf.py:230
- Same issue here: the
settingsfixture is typed aspytest.FixtureRequest, which doesn't defineCSRF_TRUSTED_ORIGINS. This will fail strict mypy checks when the attribute is assigned; use the correct pytest-django settings wrapper type (orAny) instead.
def test_trusted_origin_does_not_open_other_hosts(
settings: pytest.FixtureRequest,
) -> None:
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Copilot review: ``settings: pytest.FixtureRequest`` was the wrong annotation — the pytest-django ``settings`` fixture yields a ``pytest_django.fixtures.SettingsWrapper``, not a ``FixtureRequest``. The mismatch would surface as ``attr-defined`` errors under strict mypy when assigning ``settings.CSRF_TRUSTED_ORIGINS``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two Copilot review nits from the previous push: * ``from __future__ import annotations`` makes the ``SettingsWrapper`` import a type-only reference at runtime. Even though current Ruff tracks that as a used import, parking it under ``TYPE_CHECKING`` is the idiomatic shape and stays robust against stricter lints landing later. * The example origin in the ``CSRF_TRUSTED_ORIGINS`` settings comment was wrapped mid-hostname (``https://signage.`` / ``example.com``), which is easy to misread or copy wrong. Reflow the paragraph so the full URL stays on a single line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tasks
Operators putting Anthias behind nginx / Apache / IIS / Traefik hit the same CSRF rejection that motivated #2901 the moment their proxy rewrites the upstream Host (the default for nginx, Apache mod_proxy, and IIS ARR). The fix landed as the CSRF_TRUSTED_ORIGINS env var, but nothing on the website surfaces it — the only docs were the settings.py comment and the PR description. Add an "Operations" FAQ entry that: * names the symptom — POSTs 403 with ``Origin checking failed``; * lists the Host-preservation directive for the five reverse proxies operators actually deploy (nginx, Apache, IIS ARR, Caddy, Traefik) — preferred path, since it costs one line of proxy config and Anthias's same-host fallback then handles scheme drift automatically; * documents ``CSRF_TRUSTED_ORIGINS=https://signage.example.com`` as the escape hatch for operators who can't touch the proxy config; * notes that the bundled ``./bin/enable_ssl.sh`` Caddy sidecar already does the right thing so the FAQ entry only matters for third-party proxy setups. No code change — website data only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Issues Fixed
Fixes #2900.
Description
IIS ARR with the default
preserveHostHeader=falserewritesHostupstream before forwarding to anthias-server, so the browser'sOrigin: https://signage.example.comand uvicorn'sHost: anthias.localdomainare genuinely different hostnames.SameHostOriginCsrfMiddleware's fallback only tolerates scheme drift on the same host, not different hosts, so every unsafe POST (uploads, asset controls, etc.) 403s withOrigin checking failed.The pre-rebrand React+DRF stack didn't hit this because uploads went through DRF's Basic-auth API; the new Django+HTMX upload runs through
CsrfViewMiddlewaredirectly.Fix: expose Django's first-class
CSRF_TRUSTED_ORIGINSas a comma-separated env var. Operators behind a host-rewriting reverse proxy list the public origin they actually serve under:Default is empty — no behaviour change for plain LAN / Caddy-sidecar deployments where the proxy preserves Host upstream. The same-host fallback continues to cover those.
No new proxy-header trust added; no change to
request.get_host(),is_secure(), orbuild_absolute_uri(). Operator opts in explicitly per-deployment.Three regression tests pin the behaviour: 403 without the knob, 302 with it, and the allowlist stays exact (one trusted origin doesn't open others).
Checklist
🤖 Generated with Claude Code