diff --git a/AUTHORS b/AUTHORS index f853420f69..e39bdb5cb0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -902,6 +902,7 @@ answer newbie questions, and generally made Django that much better: Russ Webber Ryan Hall Ryan Heard + Ryan Hiebert ryankanno Ryan Kelly Ryan Niemeyer diff --git a/django/middleware/csrf.py b/django/middleware/csrf.py index 5ae1aae5c6..66f02715b1 100644 --- a/django/middleware/csrf.py +++ b/django/middleware/csrf.py @@ -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 diff --git a/django/views/csrf.py b/django/views/csrf.py index adc629e843..38eca1fe30 100644 --- a/django/views/csrf.py +++ b/django/views/csrf.py @@ -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."), diff --git a/django/views/templates/csrf_403.html b/django/views/templates/csrf_403.html index ee81b04bcc..30b521262e 100644 --- a/django/views/templates/csrf_403.html +++ b/django/views/templates/csrf_403.html @@ -43,6 +43,34 @@ {% endif %} + {% if bad_origin %} + {% if forwarded_may_fix %} +

The Origin header does not match + the expected server origin, + but common proxy headers are present in the request + and may include parts of the Origin header.

+
    +
  • X-Forwarded-Proto: + {{ x_forwarded_proto }}
  • +
  • X-Forwarded-Host: + {{ x_forwarded_host }}
  • +
+

If you’re sure that you are only running behind a secure proxy + that always set these headers to avoid spoofing as described in + this warning in the docs, + you may wish to add one or more of the following + settings to permit Django to trust these headers.

+

+    SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
+    USE_X_FORWARDED_HOST = True
+  

+ {% else %} + If the expected server origin looks correct, + you may wish to add the origin to the CSRF_TRUSTED_ORIGINS + setting. + {% endif %} + {% else %} +

In general, this can occur when there is a genuine Cross Site Request Forgery, or when Django’s @@ -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. + {% endif %}

You’re seeing the help section of this page because you have DEBUG = True in your Django settings file. Change that to False, diff --git a/docs/releases/5.1.txt b/docs/releases/5.1.txt index 799f3ee819..1e3fd3e46d 100644 --- a/docs/releases/5.1.txt +++ b/docs/releases/5.1.txt @@ -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 ~~~~~~~~~~~~~~~~~ diff --git a/tests/csrf_tests/tests.py b/tests/csrf_tests/tests.py index 956cff11d9..82e40b62ee 100644 --- a/tests/csrf_tests/tests.py +++ b/tests/csrf_tests/tests.py @@ -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): diff --git a/tests/view_tests/tests/test_csrf.py b/tests/view_tests/tests/test_csrf.py index 2d530cc586..9d3ecfa84d 100644 --- a/tests/view_tests/tests/test_csrf.py +++ b/tests/view_tests/tests/test_csrf.py @@ -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)