Skip to content

fix(csrf): accept same-host Origin regardless of scheme (#2867)#2868

Merged
vpetersson merged 9 commits into
masterfrom
worktree-bold-owl-jr7x
May 12, 2026
Merged

fix(csrf): accept same-host Origin regardless of scheme (#2867)#2868
vpetersson merged 9 commits into
masterfrom
worktree-bold-owl-jr7x

Conversation

@vpetersson
Copy link
Copy Markdown
Contributor

Issues Fixed

Closes #2867.

Description

Django 4.x+ rejects POSTs where the Origin header's scheme://host doesn't match what uvicorn sees. That 403s every form submit on Anthias when the device sits behind a TLS-terminating proxy (Caddy sidecar, Cloudflare Tunnel, Tailscale Serve, home-router HTTPS rewrite) without FORWARDED_ALLOW_IPS also being set, and after disabling a previously-enabled TLS proxy while the browser's HSTS / HTTPS-First cache still sends https://. Confirmed in dev: Origin checking failed - https://localhost:8000 does not match any trusted origins.

CSRF_TRUSTED_ORIGINS = ['http://*', 'https://*'] looks like the answer but is a Django no-op (only subdomain wildcards like https://*.example.com are honoured).

The fix is a thin CsrfViewMiddleware subclass that defers to the stock check first, then falls back to accepting any Origin whose host equals request.get_host() regardless of scheme. The masked-token check still runs on top, so the relaxation can't bypass CSRF — cross-host Origin and missing / bad tokens still 403.

Checklist

  • I have performed a self-review of my own code.
  • New and existing unit tests pass locally and on CI with my changes.
  • I have done an end-to-end test for Raspberry Pi devices.
  • I have tested my changes for x86 devices.
  • I added a documentation for the changes I have made (when necessary).

🤖 Generated with Claude Code

* Custom SameHostOriginCsrfMiddleware falls back to a host-only
  Origin match when the stock scheme+host check rejects, fixing
  403s behind a TLS-terminating proxy (Caddy sidecar, Cloudflare
  Tunnel, Tailscale Serve) that hands plain HTTP to uvicorn.
* Also covers HSTS / HTTPS-First leftovers after disabling SSL.
* Cross-host Origin and bad/missing tokens still 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vpetersson vpetersson self-assigned this May 12, 2026
@vpetersson vpetersson requested a review from Copilot May 12, 2026 10:41
@vpetersson vpetersson marked this pull request as ready for review May 12, 2026 10:45
@vpetersson vpetersson requested a review from a team as a code owner May 12, 2026 10:45
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses CSRF 403 failures caused by Django’s strict Origin scheme+host validation when Anthias is deployed behind TLS-terminating proxies that don’t propagate (or aren’t configured to trust) the original scheme (issue #2867). It introduces a custom CSRF middleware that preserves Django’s default behavior first, then falls back to allowing same-host Origin values regardless of scheme while still requiring valid CSRF tokens.

Changes:

  • Adds SameHostOriginCsrfMiddleware to relax same-host Origin checks across scheme mismatches.
  • Replaces Django’s default CSRF middleware with the custom middleware in Django settings.
  • Adds regression tests covering same-host scheme drift and ensuring cross-host / missing-token / bad-token requests still fail.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/anthias_server/lib/csrf.py Adds the custom CSRF middleware with a same-host fallback Origin check.
src/anthias_server/django_project/settings.py Registers the custom middleware in MIDDLEWARE and updates CSRF-related comments.
tests/test_csrf.py Adds regression tests for the new middleware behavior and CSRF invariants.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/anthias_server/lib/csrf.py Outdated
Comment thread tests/test_csrf.py
vpetersson and others added 5 commits May 12, 2026 10:49
* Suppress mypy ``misc`` on the ``super()._origin_verified`` call —
  django-stubs doesn't model private hooks — and ``bool(...)`` the
  final comparison for ``no-any-return``.
* NOSONAR the MIDDLEWARE entry: S4502 pattern-matches the bare
  class name, but SameHostOriginCsrfMiddleware extends
  CsrfViewMiddleware so CSRF protection is still wired in.
* Centralise the test Origin literals so S5332 ("use https") fires
  once per constant instead of per call site, with NOSONAR on the
  intentional ``http://`` ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sonar's S4502 anchors on the closing ``]`` of the MIDDLEWARE list,
not on the individual middleware entries — the comment on the
custom-middleware line wasn't suppressing it. Move NOSONAR to the
bracket S4502 actually flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Pull a ``_hostname()`` helper that runs both ``Origin`` and
  ``request.get_host()`` through ``urlsplit().hostname`` so the
  comparison ignores port and IPv6-bracket drift. Common shape
  this fixes: proxy passes ``Host: device.local:443`` upstream
  while the browser's ``Origin`` is ``https://device.local`` with
  the default port elided.
* Regression test ``test_host_with_port_origin_without_port_passes``
  pins the host-with-port + origin-without-port case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ``http://`` prefix is only there so ``urlsplit`` will parse a
bare ``Host`` header value into a hostname; nothing leaves the
process on that scheme. Sonar's S5332 ("use https") doesn't apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Use a protocol-relative ``//host[:port]`` prefix so ``urlsplit``
parses a bare ``Host`` header value as a netloc — no literal
scheme baked into the file, no NOSONAR suppression needed for
S5332. IPv6 and ``user:pw@host`` shapes still hostname-extract
cleanly (verified).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment thread src/anthias_server/lib/csrf.py Outdated
Comment thread tests/test_csrf.py Outdated
Two follow-ups from Copilot review on e9794f4:

* Tighten the fallback so ``Origin: http://device:8080`` posting to
  ``Host: device:8000`` (distinct web origins) still 403s. Resolve
  each side to ``(host, port)`` with default-port substitution for
  the scheme, accept exact-port matches, and only tolerate port
  drift when at least one side is on the scheme's default — that's
  the proxy/HSTS shape the fallback actually exists for.
* Drop the regex-on-rendered-HTML token extraction in the tests.
  Use the ``csrftoken`` cookie value directly as
  ``csrfmiddlewaretoken`` (Django accepts the unmasked secret) so
  the suite is decoupled from template markup.
* Regression test ``test_same_host_distinct_non_default_ports_rejected``
  pins the cross-origin distinct-port case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment thread src/anthias_server/lib/csrf.py Outdated
Comment thread tests/test_csrf.py
Copilot pointed out the previous rule was still too broad: with
``Origin: https://anthias.local`` (port 443) posting to
``Host: anthias.local:8000``, my "either side on a default port"
test accepted it even though 443 and 8000 are distinct web origins.

Tighten to ``{origin_port, target_port} == {80, 443}`` — exactly
the scheme-drift pair the proxy/HSTS case needs, nothing else.
Add ``test_default_port_origin_vs_non_default_host_port_rejected``
to pin the case Copilot flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/anthias_server/lib/csrf.py Outdated
The earlier comment said the final comparison needed ``bool(...)``
to satisfy ``no-any-return``. The body has since been rewritten
around ``_split_origin`` and the explicit ``True``/``False`` /
set-equality returns make that wrapping unnecessary — the line
was just lying. Drop it; the ``type: ignore`` rationale is the
only part still load-bearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vpetersson vpetersson requested a review from Copilot May 12, 2026 11:40
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@vpetersson vpetersson merged commit 1f2c5af into master May 12, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]

2 participants