mirror of
https://github.com/Screenly/Anthias.git
synced 2026-06-10 09:08:09 -04:00
* 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>
250 lines
9.9 KiB
Python
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
|