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

1""" 

2Cross Site Request Forgery Middleware. 

3 

4This module provides a middleware that implements protection 

5against request forgeries from other sites. 

6""" 

7 

8import logging 

9import string 

10from collections import defaultdict 

11from urllib.parse import urlparse 

12 

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 

22 

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]") 

26 

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" 

41 

42CSRF_SECRET_LENGTH = 32 

43CSRF_TOKEN_LENGTH = 2 * CSRF_SECRET_LENGTH 

44CSRF_ALLOWED_CHARS = string.ascii_letters + string.digits 

45CSRF_SESSION_KEY = "_csrftoken" 

46 

47 

48def _get_new_csrf_string(): 

49 return get_random_string(CSRF_SECRET_LENGTH, allowed_chars=CSRF_ALLOWED_CHARS) 

50 

51 

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 

62 

63 

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 

75 

76 

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 

87 

88 

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. 

93 

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) 

108 

109 

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) 

116 

117 

118class InvalidTokenFormat(Exception): 

119 def __init__(self, reason): 

120 self.reason = reason 

121 

122 

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) 

134 

135 

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. 

140 

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) 

151 

152 

153class RejectRequest(Exception): 

154 def __init__(self, reason): 

155 self.reason = reason 

156 

157 

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. 

162 

163 This middleware should be used in conjunction with the {% csrf_token %} 

164 template tag. 

165 """ 

166 

167 def __init__(self, get_response): 

168 self.get_response = get_response 

169 

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 

182 

183 response = self.get_response(request) 

184 

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 

195 

196 return response 

197 

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 ] 

204 

205 @cached_property 

206 def allowed_origins_exact(self): 

207 return {origin for origin in settings.CSRF_TRUSTED_ORIGINS if "*" not in origin} 

208 

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 

223 

224 def _reject(self, request, reason): 

225 from .views import CsrfFailureView 

226 

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 

237 

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. 

242 

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) 

253 

254 if csrf_secret is None: 

255 return None 

256 return csrf_secret 

257 

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",)) 

271 

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 ) 

297 

298 def _check_referer(self, request): 

299 referer = request.META.get("HTTP_REFERER") 

300 if referer is None: 

301 raise RejectRequest(REASON_NO_REFERER) 

302 

303 try: 

304 referer = urlparse(referer) 

305 except ValueError: 

306 raise RejectRequest(REASON_MALFORMED_REFERER) 

307 

308 # Make sure we have a valid URL for Referer. 

309 if "" in (referer.scheme, referer.netloc): 

310 raise RejectRequest(REASON_MALFORMED_REFERER) 

311 

312 # Ensure that our Referer is also secure. 

313 if referer.scheme != "https": 

314 raise RejectRequest(REASON_INSECURE_REFERER) 

315 

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}" 

335 

336 if not is_same_domain(referer.netloc, good_referer): 

337 raise RejectRequest(REASON_BAD_REFERER % referer.geturl()) 

338 

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}." 

345 

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}.") 

354 

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) 

360 

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 

372 

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" 

387 

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) 

393 

394 if not _does_token_match(request_csrf_token, csrf_secret): 

395 reason = self._bad_token_message("incorrect", token_source) 

396 raise RejectRequest(reason) 

397 

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 

403 

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 

407 

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 

414 

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) 

444 

445 try: 

446 self._check_token(request) 

447 except RejectRequest as exc: 

448 return self._reject(request, exc.reason) 

449 

450 return None