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