Files
Anthias/tests/test_csrf.py
Viktor Petersson c3e86c61c9 fix(csrf): CSRF_TRUSTED_ORIGINS env var for host-rewriting proxies (#2901)
* fix(csrf): CSRF_TRUSTED_ORIGINS env var for host-rewriting proxies (#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>

* fix(csrf): use pytest_django SettingsWrapper type for settings fixture

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>

* fix(csrf): move SettingsWrapper import under TYPE_CHECKING, rewrap URL

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>

* docs(faq): how to run Anthias behind a custom reverse proxy

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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:30:35 +01:00

250 lines
9.9 KiB
Python

"""Regression coverage for ``SameHostOriginCsrfMiddleware`` (#2867).
The middleware relaxes Django's strict scheme+host Origin check on a
same-host fallback so a TLS-terminating proxy in front of Anthias
(Caddy sidecar, Cloudflare Tunnel, Tailscale Serve, …) doesn't 403
every form submit. Cross-host POSTs, distinct-port web origins, and
bad / missing tokens must still fail.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from django.test import Client
from django.urls import reverse
if TYPE_CHECKING:
from pytest_django.fixtures import SettingsWrapper
# The whole point of this suite is exercising plain-HTTP Origin headers
# against an HTTP-served Anthias — that's the deployment shape the bug
# happens on. Sonar's S5332 would flag every ``HTTP_ORIGIN='http://...'``
# call site, so funnel through module-level constants with a single
# NOSONAR per literal.
_HTTP_SAME_HOST_ORIGIN = 'http://anthias.local' # NOSONAR
_HTTP_CROSS_HOST_ORIGIN = 'http://attacker.example' # NOSONAR
_HTTPS_SAME_HOST_ORIGIN = 'https://anthias.local'
def _seed_csrf_cookie(client: Client, host: str) -> str:
"""GET the home page so the middleware sets ``csrftoken`` on the
client, then return the raw cookie value. Django's CSRF check
accepts the unmasked secret directly as ``csrfmiddlewaretoken``,
so callers can sidestep parsing the rendered form HTML — keeps
these tests focused on Origin handling rather than template markup.
"""
response = client.get(reverse('anthias_app:home'), HTTP_HOST=host)
assert response.status_code == 200
return client.cookies['csrftoken'].value
@pytest.mark.django_db
def test_same_host_http_origin_passes() -> None:
client = Client(enforce_csrf_checks=True)
token = _seed_csrf_cookie(client, 'anthias.local')
response = client.post(
reverse('anthias_app:assets_control', args=['next']),
data={'csrfmiddlewaretoken': token},
HTTP_HOST='anthias.local',
HTTP_ORIGIN=_HTTP_SAME_HOST_ORIGIN,
)
assert response.status_code == 302
@pytest.mark.django_db
def test_same_host_https_origin_on_http_passes() -> None:
"""The user-reported case in #2867. Browser advertises an
``https://device`` Origin (TLS terminated at a proxy / HSTS
leftover / browser HTTPS-First) while uvicorn sees plain HTTP.
Stock Django would 403; the custom middleware must accept."""
client = Client(enforce_csrf_checks=True)
token = _seed_csrf_cookie(client, 'anthias.local')
response = client.post(
reverse('anthias_app:assets_control', args=['next']),
data={'csrfmiddlewaretoken': token},
HTTP_HOST='anthias.local',
HTTP_ORIGIN=_HTTPS_SAME_HOST_ORIGIN,
)
assert response.status_code == 302
@pytest.mark.django_db
def test_host_with_port_origin_without_port_passes() -> None:
"""Common reverse-proxy shape: the upstream ``Host`` carries an
explicit (often default) port — e.g. ``Host: anthias.local:443``
— while the browser's ``Origin`` is ``https://anthias.local``
with the default port elided. Same site from the user's view;
the fallback must compare hostnames and tolerate the port drift
because at least one side is on the scheme's default port."""
client = Client(enforce_csrf_checks=True)
token = _seed_csrf_cookie(client, 'anthias.local:443')
response = client.post(
reverse('anthias_app:assets_control', args=['next']),
data={'csrfmiddlewaretoken': token},
HTTP_HOST='anthias.local:443',
HTTP_ORIGIN=_HTTPS_SAME_HOST_ORIGIN,
)
assert response.status_code == 302
@pytest.mark.django_db
def test_same_host_distinct_non_default_ports_rejected() -> None:
"""``Origin: http://anthias.local:8080`` posting to a server on
``Host: anthias.local:8000`` is a cross-origin request even
though the host matches — different non-default ports are
distinct web origins. The fallback must keep rejecting this."""
client = Client(enforce_csrf_checks=True)
token = _seed_csrf_cookie(client, 'anthias.local:8000')
response = client.post(
reverse('anthias_app:assets_control', args=['next']),
data={'csrfmiddlewaretoken': token},
HTTP_HOST='anthias.local:8000',
HTTP_ORIGIN='http://anthias.local:8080', # NOSONAR
)
assert response.status_code == 403
@pytest.mark.django_db
def test_default_port_origin_vs_non_default_host_port_rejected() -> None:
"""``Origin: https://anthias.local`` (port 443) posting to a
server on ``Host: anthias.local:8000`` is a cross-origin request
even though one side is on a scheme default — 443 and 8000 are
distinct web origins. The fallback must reject anything outside
the 80↔443 scheme-drift pair."""
client = Client(enforce_csrf_checks=True)
token = _seed_csrf_cookie(client, 'anthias.local:8000')
response = client.post(
reverse('anthias_app:assets_control', args=['next']),
data={'csrfmiddlewaretoken': token},
HTTP_HOST='anthias.local:8000',
HTTP_ORIGIN=_HTTPS_SAME_HOST_ORIGIN,
)
assert response.status_code == 403
@pytest.mark.django_db
def test_cross_host_origin_still_rejected() -> None:
client = Client(enforce_csrf_checks=True)
token = _seed_csrf_cookie(client, 'anthias.local')
response = client.post(
reverse('anthias_app:assets_control', args=['next']),
data={'csrfmiddlewaretoken': token},
HTTP_HOST='anthias.local',
HTTP_ORIGIN=_HTTP_CROSS_HOST_ORIGIN,
)
assert response.status_code == 403
@pytest.mark.django_db
def test_missing_token_still_rejected() -> None:
"""The same-host Origin relaxation must not bypass the token
check itself — a POST with a matching Origin but no
``csrfmiddlewaretoken`` body / ``X-CSRFToken`` header still 403s."""
client = Client(enforce_csrf_checks=True)
_seed_csrf_cookie(client, 'anthias.local')
response = client.post(
reverse('anthias_app:assets_control', args=['next']),
HTTP_HOST='anthias.local',
HTTP_ORIGIN=_HTTPS_SAME_HOST_ORIGIN,
)
assert response.status_code == 403
@pytest.mark.django_db
def test_bad_token_still_rejected() -> None:
client = Client(enforce_csrf_checks=True)
_seed_csrf_cookie(client, 'anthias.local')
response = client.post(
reverse('anthias_app:assets_control', args=['next']),
data={'csrfmiddlewaretoken': 'not-the-real-token-12345678'},
HTTP_HOST='anthias.local',
HTTP_ORIGIN=_HTTPS_SAME_HOST_ORIGIN,
)
assert response.status_code == 403
@pytest.mark.django_db
def test_no_origin_header_still_works() -> None:
"""Curl-style clients with no Origin header (legitimate
server-to-server callers, scripted operators) must still POST
successfully when they carry a matching token + cookie."""
client = Client(enforce_csrf_checks=True)
token = _seed_csrf_cookie(client, 'anthias.local')
response = client.post(
reverse('anthias_app:assets_control', args=['next']),
data={'csrfmiddlewaretoken': token},
HTTP_HOST='anthias.local',
)
assert response.status_code == 302
# ---------------------------------------------------------------------------
# Issue #2900 — IIS ARR with preserveHostHeader=false rewrites Host
# upstream, so the request lands on uvicorn with ``Host: anthias.local``
# (the rewrite target) while the browser's Origin is
# ``https://signage.example.com``. The same-host fallback can't reconcile
# them — the hostnames really are different — so operators have to opt
# the public hostname into CSRF_TRUSTED_ORIGINS explicitly.
# ---------------------------------------------------------------------------
_IIS_PUBLIC_ORIGIN = 'https://signage.example.com'
@pytest.mark.django_db
def test_iis_rewrite_host_proxy_without_trusted_origin_rejected() -> None:
"""Today's behaviour: IIS rewrites Host upstream, no trusted
origin is configured, and the same-host fallback can't bridge
two genuinely different hostnames. POST stays 403 — pinning this
is what justifies the CSRF_TRUSTED_ORIGINS escape hatch."""
client = Client(enforce_csrf_checks=True)
token = _seed_csrf_cookie(client, 'anthias.local')
response = client.post(
reverse('anthias_app:assets_control', args=['next']),
data={'csrfmiddlewaretoken': token},
HTTP_HOST='anthias.local',
HTTP_ORIGIN=_IIS_PUBLIC_ORIGIN,
)
assert response.status_code == 403
@pytest.mark.django_db
def test_iis_rewrite_host_proxy_with_trusted_origin_passes(
settings: SettingsWrapper,
) -> None:
"""Issue #2900 fix: when the operator lists the public hostname
they actually serve Anthias under in CSRF_TRUSTED_ORIGINS,
Django's stock check accepts the Origin even though uvicorn sees
a different upstream Host."""
settings.CSRF_TRUSTED_ORIGINS = [_IIS_PUBLIC_ORIGIN]
client = Client(enforce_csrf_checks=True)
token = _seed_csrf_cookie(client, 'anthias.local')
response = client.post(
reverse('anthias_app:assets_control', args=['next']),
data={'csrfmiddlewaretoken': token},
HTTP_HOST='anthias.local',
HTTP_ORIGIN=_IIS_PUBLIC_ORIGIN,
)
assert response.status_code == 302
@pytest.mark.django_db
def test_trusted_origin_does_not_open_other_hosts(
settings: SettingsWrapper,
) -> None:
"""Trusting ``https://signage.example.com`` must not implicitly
trust ``https://attacker.example``. The trusted-origin allowlist
is exact: only the listed origin passes, everything else still
has to clear the same-host fallback (or 403)."""
settings.CSRF_TRUSTED_ORIGINS = [_IIS_PUBLIC_ORIGIN]
client = Client(enforce_csrf_checks=True)
token = _seed_csrf_cookie(client, 'anthias.local')
response = client.post(
reverse('anthias_app:assets_control', args=['next']),
data={'csrfmiddlewaretoken': token},
HTTP_HOST='anthias.local',
HTTP_ORIGIN=_HTTP_CROSS_HOST_ORIGIN,
)
assert response.status_code == 403