1
0
mirror of https://github.com/django/django.git synced 2024-12-22 17:16:24 +00:00

Fixed #35328 - Improved debug messaging behind proxies.

This commit is contained in:
Ryan Hiebert 2024-03-25 16:00:42 +00:00
parent d36ad43f61
commit 1ad20e8bc0
7 changed files with 231 additions and 31 deletions

View File

@ -902,6 +902,7 @@ answer newbie questions, and generally made Django that much better:
Russ Webber
Ryan Hall <ryanhall989@gmail.com>
Ryan Heard <ryanwheard@gmail.com>
Ryan Hiebert <ryan@ryanhiebert.com>
ryankanno
Ryan Kelly <ryan@rfk.id.au>
Ryan Niemeyer <https://profiles.google.com/ryan.niemeyer/about>

View File

@ -26,7 +26,10 @@ logger = logging.getLogger("django.security.csrf")
# This matches if any character is not in CSRF_ALLOWED_CHARS.
invalid_token_chars_re = _lazy_re_compile("[^a-zA-Z0-9]")
REASON_BAD_ORIGIN = "Origin checking failed - %s does not match any trusted origins."
REASON_DISALLOWED_HOST = "Host is not allowed."
REASON_BAD_ORIGIN = (
"Origin checking failed - %r does not match %r or any other trusted origins."
)
REASON_NO_REFERER = "Referer checking failed - no Referer."
REASON_BAD_REFERER = "Referer checking failed - %s does not match any trusted origins."
REASON_NO_CSRF_COOKIE = "CSRF cookie not set."
@ -268,19 +271,10 @@ class CsrfViewMiddleware(MiddlewareMixin):
# Set the Vary header since content varies with the CSRF cookie.
patch_vary_headers(response, ("Cookie",))
def _origin_verified(self, request):
def _origin_verified(self, request, host_origin):
request_origin = request.META["HTTP_ORIGIN"]
try:
good_host = request.get_host()
except DisallowedHost:
pass
else:
good_origin = "%s://%s" % (
"https" if request.is_secure() else "http",
good_host,
)
if request_origin == good_origin:
return True
if request_origin == host_origin:
return True
if request_origin in self.allowed_origins_exact:
return True
try:
@ -434,9 +428,17 @@ class CsrfViewMiddleware(MiddlewareMixin):
# Reject the request if the Origin header doesn't match an allowed
# value.
if "HTTP_ORIGIN" in request.META:
if not self._origin_verified(request):
try:
host_origin = "%s://%s" % (
"https" if request.is_secure() else "http",
request.get_host(),
)
except DisallowedHost:
return self._reject(request, REASON_DISALLOWED_HOST)
if not self._origin_verified(request, host_origin):
return self._reject(
request, REASON_BAD_ORIGIN % request.META["HTTP_ORIGIN"]
request,
REASON_BAD_ORIGIN % (request.META["HTTP_ORIGIN"], host_origin),
)
elif request.is_secure():
# If the Origin header wasn't provided, reject HTTPS requests if

View File

@ -61,6 +61,16 @@ def csrf_failure(request, reason="", template_name=CSRF_FAILURE_TEMPLATE_NAME):
"re-enable them, at least for this site, or for “same-origin” "
"requests."
),
"bad_origin": reason.startswith("Origin checking failed"),
"forwarded_may_fix": (
request.headers.get("X-Forwarded-Proto", "") == "https"
and not request.is_secure()
or request.headers.get("Origin", "").endswith(
f'://{request.headers.get("X-Forwarded-Host", "")}'
)
),
"x_forwarded_proto": request.headers.get("X-Forwarded-Proto"),
"x_forwarded_host": request.headers.get("X-Forwarded-Host"),
"DEBUG": settings.DEBUG,
"docs_version": get_docs_version(),
"more": _("More information is available with DEBUG=True."),

View File

@ -43,6 +43,34 @@
</pre>
{% endif %}
{% if bad_origin %}
{% if forwarded_may_fix %}
<p>The <code>Origin</code> header does not match
the expected server origin,
but common proxy headers are present in the request
and may include parts of the <code>Origin</code> header.</p>
<ul>
<li><strong><code>X-Forwarded-Proto</code></strong>:
<code>{{ x_forwarded_proto }}</code></li>
<li><strong><code>X-Forwarded-Host</code></strong>:
<code>{{ x_forwarded_host }}</code></li>
</ul>
<p>If youre sure that you are only running behind a secure proxy
that always set these headers to avoid spoofing as described in
<a href="https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#secure-proxy-ssl-header">this warning in the docs</a>,
you may wish to add one or more of the following
settings to permit Django to trust these headers.</p>
<p><pre>
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
</pre></p>
{% else %}
If the expected server origin looks correct,
you may wish to add the origin to the <code>CSRF_TRUSTED_ORIGINS</code>
setting.
{% endif %}
{% else %}
<p>In general, this can occur when there is a genuine Cross Site Request Forgery, or when
<a
href="https://docs.djangoproject.com/en/{{ docs_version }}/ref/csrf/">Djangos
@ -68,6 +96,7 @@
tab or hitting the back button after a login, you may need to reload the
page with the form, because the token is rotated after a login.</li>
</ul>
{% endif %}
<p>Youre seeing the help section of this page because you have <code>DEBUG =
True</code> in your Django settings file. Change that to <code>False</code>,

View File

@ -198,6 +198,42 @@ Minor features
session engines now provide async API. The new asynchronous methods all have
``a`` prefixed names, e.g. ``aget()``, ``akeys()``, or ``acycle_key()``.
:mod:`django.contrib.sitemaps`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* ...
:mod:`django.contrib.sites`
~~~~~~~~~~~~~~~~~~~~~~~~~~~
* ...
:mod:`django.contrib.staticfiles`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* ...
:mod:`django.contrib.syndication`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* ...
Asynchronous views
~~~~~~~~~~~~~~~~~~
* ...
Cache
~~~~~
* ...
CSRF
~~~~
* The error messaging is improved when ``Origin`` checking fails,
including better hints when likely proxy headers are detected.
Database backends
~~~~~~~~~~~~~~~~~

View File

@ -9,7 +9,6 @@ from django.middleware.csrf import (
CSRF_SECRET_LENGTH,
CSRF_SESSION_KEY,
CSRF_TOKEN_LENGTH,
REASON_BAD_ORIGIN,
REASON_CSRF_TOKEN_MISSING,
REASON_NO_CSRF_COOKIE,
CsrfViewMiddleware,
@ -891,6 +890,20 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
with self.assertRaises(OSError):
mw.process_view(req, post_form_view, (), {})
@override_settings(ALLOWED_HOSTS=["www.example.com"])
def test_good_origin_disallowed_host(self):
"""A request with a disallowed host is rejected."""
req = self._get_POST_request_with_token()
req.META["HTTP_HOST"] = "www.disallowed.com"
req.META["HTTP_ORIGIN"] = "https://www.disallowed.com"
mw = CsrfViewMiddleware(post_form_view)
self._check_referer_rejects(mw, req)
with self.assertLogs("django.security.csrf", "WARNING") as cm:
response = mw.process_view(req, post_form_view, (), {})
self.assertEqual(response.status_code, 403)
msg = "Host is not allowed."
self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
@override_settings(ALLOWED_HOSTS=["www.example.com"])
def test_bad_origin_bad_domain(self):
"""A request with a bad origin is rejected."""
@ -899,11 +912,15 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
req.META["HTTP_ORIGIN"] = "https://www.evil.org"
mw = CsrfViewMiddleware(post_form_view)
self._check_referer_rejects(mw, req)
self.assertIs(mw._origin_verified(req), False)
self.assertIs(mw._origin_verified(req, "http://www.example.com"), False)
with self.assertLogs("django.security.csrf", "WARNING") as cm:
response = mw.process_view(req, post_form_view, (), {})
self.assertEqual(response.status_code, 403)
msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"]
msg = (
"Origin checking failed - 'https://www.evil.org' "
"does not match 'http://www.example.com' "
"or any other trusted origins."
)
self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
@override_settings(ALLOWED_HOSTS=["www.example.com"])
@ -914,11 +931,15 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
req.META["HTTP_ORIGIN"] = "null"
mw = CsrfViewMiddleware(post_form_view)
self._check_referer_rejects(mw, req)
self.assertIs(mw._origin_verified(req), False)
self.assertIs(mw._origin_verified(req, "http://www.example.com"), False)
with self.assertLogs("django.security.csrf", "WARNING") as cm:
response = mw.process_view(req, post_form_view, (), {})
self.assertEqual(response.status_code, 403)
msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"]
msg = (
"Origin checking failed - 'null' "
"does not match 'http://www.example.com' "
"or any other trusted origins."
)
self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
@override_settings(ALLOWED_HOSTS=["www.example.com"])
@ -930,11 +951,15 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
req.META["HTTP_ORIGIN"] = "http://example.com"
mw = CsrfViewMiddleware(post_form_view)
self._check_referer_rejects(mw, req)
self.assertIs(mw._origin_verified(req), False)
self.assertIs(mw._origin_verified(req, "https://www.example.com"), False)
with self.assertLogs("django.security.csrf", "WARNING") as cm:
response = mw.process_view(req, post_form_view, (), {})
self.assertEqual(response.status_code, 403)
msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"]
msg = (
"Origin checking failed - 'http://example.com' "
"does not match 'https://www.example.com' "
"or any other trusted origins."
)
self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
@override_settings(
@ -957,11 +982,15 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
req.META["HTTP_ORIGIN"] = "http://foo.example.com"
mw = CsrfViewMiddleware(post_form_view)
self._check_referer_rejects(mw, req)
self.assertIs(mw._origin_verified(req), False)
self.assertIs(mw._origin_verified(req, "https://www.example.com"), False)
with self.assertLogs("django.security.csrf", "WARNING") as cm:
response = mw.process_view(req, post_form_view, (), {})
self.assertEqual(response.status_code, 403)
msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"]
msg = (
"Origin checking failed - 'http://foo.example.com' "
"does not match 'https://www.example.com' "
"or any other trusted origins."
)
self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
self.assertEqual(mw.allowed_origins_exact, {"http://no-match.com"})
self.assertEqual(
@ -983,11 +1012,15 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
req.META["HTTP_ORIGIN"] = "https://["
mw = CsrfViewMiddleware(post_form_view)
self._check_referer_rejects(mw, req)
self.assertIs(mw._origin_verified(req), False)
self.assertIs(mw._origin_verified(req, "http://www.example.com"), False)
with self.assertLogs("django.security.csrf", "WARNING") as cm:
response = mw.process_view(req, post_form_view, (), {})
self.assertEqual(response.status_code, 403)
msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"]
msg = (
"Origin checking failed - 'https://[' "
"does not match 'http://www.example.com' "
"or any other trusted origins."
)
self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
@override_settings(ALLOWED_HOSTS=["www.example.com"])
@ -997,7 +1030,7 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
req.META["HTTP_HOST"] = "www.example.com"
req.META["HTTP_ORIGIN"] = "http://www.example.com"
mw = CsrfViewMiddleware(post_form_view)
self.assertIs(mw._origin_verified(req), True)
self.assertIs(mw._origin_verified(req, "http://www.example.com"), True)
response = mw.process_view(req, post_form_view, (), {})
self.assertIsNone(response)
@ -1009,7 +1042,7 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
req.META["HTTP_HOST"] = "www.example.com"
req.META["HTTP_ORIGIN"] = "https://www.example.com"
mw = CsrfViewMiddleware(post_form_view)
self.assertIs(mw._origin_verified(req), True)
self.assertIs(mw._origin_verified(req, "https://www.example.com"), True)
response = mw.process_view(req, post_form_view, (), {})
self.assertIsNone(response)
@ -1027,7 +1060,7 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
req.META["HTTP_HOST"] = "www.example.com"
req.META["HTTP_ORIGIN"] = "https://dashboard.example.com"
mw = CsrfViewMiddleware(post_form_view)
self.assertIs(mw._origin_verified(req), True)
self.assertIs(mw._origin_verified(req, "https://www.example.com"), True)
resp = mw.process_view(req, post_form_view, (), {})
self.assertIsNone(resp)
self.assertEqual(mw.allowed_origins_exact, {"https://dashboard.example.com"})
@ -1047,12 +1080,101 @@ class CsrfViewMiddlewareTestMixin(CsrfFunctionTestMixin):
req.META["HTTP_HOST"] = "www.example.com"
req.META["HTTP_ORIGIN"] = "https://foo.example.com"
mw = CsrfViewMiddleware(post_form_view)
self.assertIs(mw._origin_verified(req), True)
self.assertIs(mw._origin_verified(req, "https://www.example.com"), True)
response = mw.process_view(req, post_form_view, (), {})
self.assertIsNone(response)
self.assertEqual(mw.allowed_origins_exact, set())
self.assertEqual(mw.allowed_origin_subdomains, {"https": [".example.com"]})
@override_settings(ALLOWED_HOSTS=["www.example.com", "localhost"], DEBUG=True)
def test_bad_origin_x_forwarded_host_with_port(self):
"""Give a helpful message if we see a likely X-Forwarded-Host header."""
req = self._get_POST_request_with_token()
req.META["HTTP_HOST"] = "localhost:8000"
req.META["HTTP_ORIGIN"] = "http://www.example.com:8080"
req.META["HTTP_X_FORWARDED_HOST"] = "www.example.com:8080"
mw = CsrfViewMiddleware(post_form_view)
self._check_referer_rejects(mw, req)
self.assertIs(mw._origin_verified(req, "http://www.example.com:8000"), False)
with self.assertLogs("django.security.csrf", "WARNING") as cm:
response = mw.process_view(req, post_form_view, (), {})
self.assertEqual(response.status_code, 403)
msg = (
"Origin checking failed - 'http://www.example.com:8080' "
"does not match 'http://localhost:8000' "
"or any other trusted origins."
)
self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
self.assertContains(response, "X-Forwarded-Host", status_code=403)
@override_settings(ALLOWED_HOSTS=["www.example.com", "localhost"], DEBUG=True)
def test_bad_origin_x_forwarded_host_no_port(self):
"""Give a helpful message if we see a likely X-Forwarded-Host header."""
req = self._get_POST_request_with_token()
req.META["HTTP_HOST"] = "localhost"
req.META["HTTP_ORIGIN"] = "http://www.example.com"
req.META["HTTP_X_FORWARDED_HOST"] = "www.example.com"
mw = CsrfViewMiddleware(post_form_view)
self._check_referer_rejects(mw, req)
self.assertIs(mw._origin_verified(req, "http://localhost"), False)
with self.assertLogs("django.security.csrf", "WARNING") as cm:
response = mw.process_view(req, post_form_view, (), {})
self.assertEqual(response.status_code, 403)
msg = (
"Origin checking failed - 'http://www.example.com' "
"does not match 'http://localhost' "
"or any other trusted origins."
)
self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
self.assertContains(response, "X-Forwarded-Host", status_code=403)
@override_settings(ALLOWED_HOSTS=["www.example.com"], DEBUG=True)
def test_bad_origin_x_forwarded_proto(self):
"""Give a helpful message if we see a likely X-Forwarded-Proto header."""
req = self._get_POST_request_with_token()
req.META["HTTP_HOST"] = "www.example.com"
req.META["HTTP_ORIGIN"] = "https://www.example.com"
req.META["HTTP_X_FORWARDED_PROTO"] = "https"
mw = CsrfViewMiddleware(post_form_view)
self._check_referer_rejects(mw, req)
self.assertIs(mw._origin_verified(req, "http://www.example.com"), False)
with self.assertLogs("django.security.csrf", "WARNING") as cm:
response = mw.process_view(req, post_form_view, (), {})
self.assertEqual(response.status_code, 403)
msg = (
"Origin checking failed - 'https://www.example.com' "
"does not match 'http://www.example.com' "
"or any other trusted origins."
)
self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
self.assertContains(response, "X-Forwarded-Proto", status_code=403)
@override_settings(ALLOWED_HOSTS=["www.example.com"], DEBUG=True)
def test_no_likely_proxy_headers(self):
"""Show a message about CSRF_TRUSTED_ORIGINS if no proxy headers."""
req = self._get_POST_request_with_token()
req.META["HTTP_HOST"] = "www.example.com"
req.META["HTTP_ORIGIN"] = "https://www.example.com"
mw = CsrfViewMiddleware(post_form_view)
self._check_referer_rejects(mw, req)
self.assertIs(mw._origin_verified(req, "http://www.example.com"), False)
with self.assertLogs("django.security.csrf", "WARNING") as cm:
response = mw.process_view(req, post_form_view, (), {})
self.assertEqual(response.status_code, 403)
msg = (
"Origin checking failed - 'https://www.example.com' "
"does not match 'http://www.example.com' "
"or any other trusted origins."
)
self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg)
self.assertContains(
response, "If the expected server origin looks correct", status_code=403
)
self.assertContains(
response, "you may wish to add the origin to", status_code=403
)
self.assertContains(response, "CSRF_TRUSTED_ORIGINS", status_code=403)
class CsrfViewMiddlewareTests(CsrfViewMiddlewareTestMixin, SimpleTestCase):
def _set_csrf_cookie(self, req, cookie):

View File

@ -130,7 +130,7 @@ class CsrfViewTests(SimpleTestCase):
from django.views.csrf import Path
with mock.patch.object(Path, "open") as m:
csrf_failure(mock.MagicMock(), mock.Mock())
csrf_failure(mock.MagicMock(), "Example reason")
m.assert_called_once_with(encoding="utf-8")
@override_settings(DEBUG=True)