Coverage for /Users/davegaeddert/Development/dropseed/plain/plain/plain/csrf/middleware.py: 47%
210 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-10-16 22:27 -0500
« prev ^ index » next coverage.py v7.6.1, created at 2024-10-16 22:27 -0500
1"""
2Cross Site Request Forgery Middleware.
4This module provides a middleware that implements protection
5against request forgeries from other sites.
6"""
7import logging
8import string
9from collections import defaultdict
10from urllib.parse import urlparse
12from plain.exceptions import DisallowedHost
13from plain.http import HttpHeaders, UnreadablePostError
14from plain.logs import log_response
15from plain.runtime import settings
16from plain.utils.cache import patch_vary_headers
17from plain.utils.crypto import constant_time_compare, get_random_string
18from plain.utils.functional import cached_property
19from plain.utils.http import is_same_domain
20from plain.utils.regex_helper import _lazy_re_compile
22logger = logging.getLogger("plain.security.csrf")
23# This matches if any character is not in CSRF_ALLOWED_CHARS.
24invalid_token_chars_re = _lazy_re_compile("[^a-zA-Z0-9]")
26REASON_BAD_ORIGIN = "Origin checking failed - %s does not match any trusted origins."
27REASON_NO_REFERER = "Referer checking failed - no Referer."
28REASON_BAD_REFERER = "Referer checking failed - %s does not match any trusted origins."
29REASON_NO_CSRF_COOKIE = "CSRF cookie not set."
30REASON_CSRF_TOKEN_MISSING = "CSRF token missing."
31REASON_MALFORMED_REFERER = "Referer checking failed - Referer is malformed."
32REASON_INSECURE_REFERER = (
33 "Referer checking failed - Referer is insecure while host is secure."
34)
35# The reason strings below are for passing to InvalidTokenFormat. They are
36# phrases without a subject because they can be in reference to either the CSRF
37# cookie or non-cookie token.
38REASON_INCORRECT_LENGTH = "has incorrect length"
39REASON_INVALID_CHARACTERS = "has invalid characters"
41CSRF_SECRET_LENGTH = 32
42CSRF_TOKEN_LENGTH = 2 * CSRF_SECRET_LENGTH
43CSRF_ALLOWED_CHARS = string.ascii_letters + string.digits
44CSRF_SESSION_KEY = "_csrftoken"
47def _get_new_csrf_string():
48 return get_random_string(CSRF_SECRET_LENGTH, allowed_chars=CSRF_ALLOWED_CHARS)
51def _mask_cipher_secret(secret):
52 """
53 Given a secret (assumed to be a string of CSRF_ALLOWED_CHARS), generate a
54 token by adding a mask and applying it to the secret.
55 """
56 mask = _get_new_csrf_string()
57 chars = CSRF_ALLOWED_CHARS
58 pairs = zip((chars.index(x) for x in secret), (chars.index(x) for x in mask))
59 cipher = "".join(chars[(x + y) % len(chars)] for x, y in pairs)
60 return mask + cipher
63def _unmask_cipher_token(token):
64 """
65 Given a token (assumed to be a string of CSRF_ALLOWED_CHARS, of length
66 CSRF_TOKEN_LENGTH, and that its first half is a mask), use it to decrypt
67 the second half to produce the original secret.
68 """
69 mask = token[:CSRF_SECRET_LENGTH]
70 token = token[CSRF_SECRET_LENGTH:]
71 chars = CSRF_ALLOWED_CHARS
72 pairs = zip((chars.index(x) for x in token), (chars.index(x) for x in mask))
73 return "".join(chars[x - y] for x, y in pairs) # Note negative values are ok
76def _add_new_csrf_cookie(request):
77 """Generate a new random CSRF_COOKIE value, and add it to request.META."""
78 csrf_secret = _get_new_csrf_string()
79 request.META.update(
80 {
81 "CSRF_COOKIE": csrf_secret,
82 "CSRF_COOKIE_NEEDS_UPDATE": True,
83 }
84 )
85 return csrf_secret
88def get_token(request):
89 """
90 Return the CSRF token required for a POST form. The token is an
91 alphanumeric value. A new token is created if one is not already set.
93 A side effect of calling this function is to make the csrf_protect
94 decorator and the CsrfViewMiddleware add a CSRF cookie and a 'Vary: Cookie'
95 header to the outgoing response. For this reason, you may need to use this
96 function lazily, as is done by the csrf context processor.
97 """
98 if "CSRF_COOKIE" in request.META:
99 csrf_secret = request.META["CSRF_COOKIE"]
100 # Since the cookie is being used, flag to send the cookie in
101 # process_response() (even if the client already has it) in order to
102 # renew the expiry timer.
103 request.META["CSRF_COOKIE_NEEDS_UPDATE"] = True
104 else:
105 csrf_secret = _add_new_csrf_cookie(request)
106 return _mask_cipher_secret(csrf_secret)
109def rotate_token(request):
110 """
111 Change the CSRF token in use for a request - should be done on login
112 for security purposes.
113 """
114 _add_new_csrf_cookie(request)
117class InvalidTokenFormat(Exception):
118 def __init__(self, reason):
119 self.reason = reason
122def _check_token_format(token):
123 """
124 Raise an InvalidTokenFormat error if the token has an invalid length or
125 characters that aren't allowed. The token argument can be a CSRF cookie
126 secret or non-cookie CSRF token, and either masked or unmasked.
127 """
128 if len(token) not in (CSRF_TOKEN_LENGTH, CSRF_SECRET_LENGTH):
129 raise InvalidTokenFormat(REASON_INCORRECT_LENGTH)
130 # Make sure all characters are in CSRF_ALLOWED_CHARS.
131 if invalid_token_chars_re.search(token):
132 raise InvalidTokenFormat(REASON_INVALID_CHARACTERS)
135def _does_token_match(request_csrf_token, csrf_secret):
136 """
137 Return whether the given CSRF token matches the given CSRF secret, after
138 unmasking the token if necessary.
140 This function assumes that the request_csrf_token argument has been
141 validated to have the correct length (CSRF_SECRET_LENGTH or
142 CSRF_TOKEN_LENGTH characters) and allowed characters, and that if it has
143 length CSRF_TOKEN_LENGTH, it is a masked secret.
144 """
145 # Only unmask tokens that are exactly CSRF_TOKEN_LENGTH characters long.
146 if len(request_csrf_token) == CSRF_TOKEN_LENGTH:
147 request_csrf_token = _unmask_cipher_token(request_csrf_token)
148 assert len(request_csrf_token) == CSRF_SECRET_LENGTH
149 return constant_time_compare(request_csrf_token, csrf_secret)
152class RejectRequest(Exception):
153 def __init__(self, reason):
154 self.reason = reason
157class CsrfViewMiddleware:
158 """
159 Require a present and correct csrfmiddlewaretoken for POST requests that
160 have a CSRF cookie, and set an outgoing CSRF cookie.
162 This middleware should be used in conjunction with the {% csrf_token %}
163 template tag.
164 """
166 def __init__(self, get_response):
167 self.get_response = get_response
169 def __call__(self, request):
170 try:
171 csrf_secret = self._get_secret(request)
172 except InvalidTokenFormat:
173 _add_new_csrf_cookie(request)
174 else:
175 if csrf_secret is not None:
176 # Use the same secret next time. If the secret was originally
177 # masked, this also causes it to be replaced with the unmasked
178 # form, but only in cases where the secret is already getting
179 # saved anyways.
180 request.META["CSRF_COOKIE"] = csrf_secret
182 response = self.get_response(request)
184 if request.META.get("CSRF_COOKIE_NEEDS_UPDATE"):
185 self._set_csrf_cookie(request, response)
186 # Unset the flag to prevent _set_csrf_cookie() from being
187 # unnecessarily called again in process_response() by other
188 # instances of CsrfViewMiddleware. This can happen e.g. when both a
189 # decorator and middleware are used. However,
190 # CSRF_COOKIE_NEEDS_UPDATE is still respected in subsequent calls
191 # e.g. in case rotate_token() is called in process_response() later
192 # by custom middleware but before those subsequent calls.
193 request.META["CSRF_COOKIE_NEEDS_UPDATE"] = False
195 return response
197 @cached_property
198 def csrf_trusted_origins_hosts(self):
199 return [
200 urlparse(origin).netloc.lstrip("*")
201 for origin in settings.CSRF_TRUSTED_ORIGINS
202 ]
204 @cached_property
205 def allowed_origins_exact(self):
206 return {origin for origin in settings.CSRF_TRUSTED_ORIGINS if "*" not in origin}
208 @cached_property
209 def allowed_origin_subdomains(self):
210 """
211 A mapping of allowed schemes to list of allowed netlocs, where all
212 subdomains of the netloc are allowed.
213 """
214 allowed_origin_subdomains = defaultdict(list)
215 for parsed in (
216 urlparse(origin)
217 for origin in settings.CSRF_TRUSTED_ORIGINS
218 if "*" in origin
219 ):
220 allowed_origin_subdomains[parsed.scheme].append(parsed.netloc.lstrip("*"))
221 return allowed_origin_subdomains
223 def _reject(self, request, reason):
224 from .views import CsrfFailureView
226 response = CsrfFailureView.as_view()(request, reason=reason)
227 log_response(
228 "Forbidden (%s): %s",
229 reason,
230 request.path,
231 response=response,
232 request=request,
233 logger=logger,
234 )
235 return response
237 def _get_secret(self, request):
238 """
239 Return the CSRF secret originally associated with the request, or None
240 if it didn't have one.
242 If the CSRF_USE_SESSIONS setting is false, raises InvalidTokenFormat if
243 the request's secret has invalid characters or an invalid length.
244 """
245 try:
246 csrf_secret = request.COOKIES[settings.CSRF_COOKIE_NAME]
247 except KeyError:
248 csrf_secret = None
249 else:
250 # This can raise InvalidTokenFormat.
251 _check_token_format(csrf_secret)
253 if csrf_secret is None:
254 return None
255 return csrf_secret
257 def _set_csrf_cookie(self, request, response):
258 response.set_cookie(
259 settings.CSRF_COOKIE_NAME,
260 request.META["CSRF_COOKIE"],
261 max_age=settings.CSRF_COOKIE_AGE,
262 domain=settings.CSRF_COOKIE_DOMAIN,
263 path=settings.CSRF_COOKIE_PATH,
264 secure=settings.CSRF_COOKIE_SECURE,
265 httponly=settings.CSRF_COOKIE_HTTPONLY,
266 samesite=settings.CSRF_COOKIE_SAMESITE,
267 )
268 # Set the Vary header since content varies with the CSRF cookie.
269 patch_vary_headers(response, ("Cookie",))
271 def _origin_verified(self, request):
272 request_origin = request.META["HTTP_ORIGIN"]
273 try:
274 good_host = request.get_host()
275 except DisallowedHost:
276 pass
277 else:
278 good_origin = "{}://{}".format(
279 "https" if request.is_https() else "http",
280 good_host,
281 )
282 if request_origin == good_origin:
283 return True
284 if request_origin in self.allowed_origins_exact:
285 return True
286 try:
287 parsed_origin = urlparse(request_origin)
288 except ValueError:
289 return False
290 request_scheme = parsed_origin.scheme
291 request_netloc = parsed_origin.netloc
292 return any(
293 is_same_domain(request_netloc, host)
294 for host in self.allowed_origin_subdomains.get(request_scheme, ())
295 )
297 def _check_referer(self, request):
298 referer = request.META.get("HTTP_REFERER")
299 if referer is None:
300 raise RejectRequest(REASON_NO_REFERER)
302 try:
303 referer = urlparse(referer)
304 except ValueError:
305 raise RejectRequest(REASON_MALFORMED_REFERER)
307 # Make sure we have a valid URL for Referer.
308 if "" in (referer.scheme, referer.netloc):
309 raise RejectRequest(REASON_MALFORMED_REFERER)
311 # Ensure that our Referer is also secure.
312 if referer.scheme != "https":
313 raise RejectRequest(REASON_INSECURE_REFERER)
315 if any(
316 is_same_domain(referer.netloc, host)
317 for host in self.csrf_trusted_origins_hosts
318 ):
319 return
320 # Allow matching the configured cookie domain.
321 good_referer = settings.CSRF_COOKIE_DOMAIN
322 if good_referer is None:
323 # If no cookie domain is configured, allow matching the current
324 # host:port exactly if it's permitted by ALLOWED_HOSTS.
325 try:
326 # request.get_host() includes the port.
327 good_referer = request.get_host()
328 except DisallowedHost:
329 raise RejectRequest(REASON_BAD_REFERER % referer.geturl())
330 else:
331 server_port = request.get_port()
332 if server_port not in ("443", "80"):
333 good_referer = f"{good_referer}:{server_port}"
335 if not is_same_domain(referer.netloc, good_referer):
336 raise RejectRequest(REASON_BAD_REFERER % referer.geturl())
338 def _bad_token_message(self, reason, token_source):
339 if token_source != "POST":
340 # Assume it is a settings.CSRF_HEADER_NAME value.
341 header_name = HttpHeaders.parse_header_name(token_source)
342 token_source = f"the {header_name!r} HTTP header"
343 return f"CSRF token from {token_source} {reason}."
345 def _check_token(self, request):
346 # Access csrf_secret via self._get_secret() as rotate_token() may have
347 # been called by an authentication middleware during the
348 # process_request() phase.
349 try:
350 csrf_secret = self._get_secret(request)
351 except InvalidTokenFormat as exc:
352 raise RejectRequest(f"CSRF cookie {exc.reason}.")
354 if csrf_secret is None:
355 # No CSRF cookie. For POST requests, we insist on a CSRF cookie,
356 # and in this way we can avoid all CSRF attacks, including login
357 # CSRF.
358 raise RejectRequest(REASON_NO_CSRF_COOKIE)
360 # Check non-cookie token for match.
361 request_csrf_token = ""
362 if request.method == "POST":
363 try:
364 request_csrf_token = request.POST.get("csrfmiddlewaretoken", "")
365 except UnreadablePostError:
366 # Handle a broken connection before we've completed reading the
367 # POST data. process_view shouldn't raise any exceptions, so
368 # we'll ignore and serve the user a 403 (assuming they're still
369 # listening, which they probably aren't because of the error).
370 pass
372 if request_csrf_token == "":
373 # Fall back to X-CSRFToken, to make things easier for AJAX, and
374 # possible for PUT/DELETE.
375 try:
376 # This can have length CSRF_SECRET_LENGTH or CSRF_TOKEN_LENGTH,
377 # depending on whether the client obtained the token from
378 # the DOM or the cookie (and if the cookie, whether the cookie
379 # was masked or unmasked).
380 request_csrf_token = request.META[settings.CSRF_HEADER_NAME]
381 except KeyError:
382 raise RejectRequest(REASON_CSRF_TOKEN_MISSING)
383 token_source = settings.CSRF_HEADER_NAME
384 else:
385 token_source = "POST"
387 try:
388 _check_token_format(request_csrf_token)
389 except InvalidTokenFormat as exc:
390 reason = self._bad_token_message(exc.reason, token_source)
391 raise RejectRequest(reason)
393 if not _does_token_match(request_csrf_token, csrf_secret):
394 reason = self._bad_token_message("incorrect", token_source)
395 raise RejectRequest(reason)
397 def process_view(self, request, callback, callback_args, callback_kwargs):
398 # Wait until request.META["CSRF_COOKIE"] has been manipulated before
399 # bailing out, so that get_token still works
400 if getattr(callback, "csrf_exempt", False):
401 return None
403 # Assume that anything not defined as 'safe' by RFC 9110 needs protection
404 if request.method in ("GET", "HEAD", "OPTIONS", "TRACE"):
405 return None
407 if getattr(request, "_dont_enforce_csrf_checks", False):
408 # Mechanism to turn off CSRF checks for test suite. It comes after
409 # the creation of CSRF cookies, so that everything else continues
410 # to work exactly the same (e.g. cookies are sent, etc.), but
411 # before any branches that call the _reject method.
412 return None
414 # Reject the request if the Origin header doesn't match an allowed
415 # value.
416 if "HTTP_ORIGIN" in request.META:
417 if not self._origin_verified(request):
418 return self._reject(
419 request, REASON_BAD_ORIGIN % request.META["HTTP_ORIGIN"]
420 )
421 elif request.is_https():
422 # If the Origin header wasn't provided, reject HTTPS requests if
423 # the Referer header doesn't match an allowed value.
424 #
425 # Suppose user visits http://example.com/
426 # An active network attacker (man-in-the-middle, MITM) sends a
427 # POST form that targets https://example.com/detonate-bomb/ and
428 # submits it via JavaScript.
429 #
430 # The attacker will need to provide a CSRF cookie and token, but
431 # that's no problem for a MITM and the session-independent secret
432 # we're using. So the MITM can circumvent the CSRF protection. This
433 # is true for any HTTP connection, but anyone using HTTPS expects
434 # better! For this reason, for https://example.com/ we need
435 # additional protection that treats http://example.com/ as
436 # completely untrusted. Under HTTPS, Barth et al. found that the
437 # Referer header is missing for same-domain requests in only about
438 # 0.2% of cases or less, so we can use strict Referer checking.
439 try:
440 self._check_referer(request)
441 except RejectRequest as exc:
442 return self._reject(request, exc.reason)
444 try:
445 self._check_token(request)
446 except RejectRequest as exc:
447 return self._reject(request, exc.reason)
449 return None