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

1""" 

2Cross Site Request Forgery Middleware. 

3 

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 

11 

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 

21 

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

25 

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" 

40 

41CSRF_SECRET_LENGTH = 32 

42CSRF_TOKEN_LENGTH = 2 * CSRF_SECRET_LENGTH 

43CSRF_ALLOWED_CHARS = string.ascii_letters + string.digits 

44CSRF_SESSION_KEY = "_csrftoken" 

45 

46 

47def _get_new_csrf_string(): 

48 return get_random_string(CSRF_SECRET_LENGTH, allowed_chars=CSRF_ALLOWED_CHARS) 

49 

50 

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 

61 

62 

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 

74 

75 

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 

86 

87 

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. 

92 

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) 

107 

108 

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) 

115 

116 

117class InvalidTokenFormat(Exception): 

118 def __init__(self, reason): 

119 self.reason = reason 

120 

121 

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) 

133 

134 

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. 

139 

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) 

150 

151 

152class RejectRequest(Exception): 

153 def __init__(self, reason): 

154 self.reason = reason 

155 

156 

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. 

161 

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

163 template tag. 

164 """ 

165 

166 def __init__(self, get_response): 

167 self.get_response = get_response 

168 

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 

181 

182 response = self.get_response(request) 

183 

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 

194 

195 return response 

196 

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 ] 

203 

204 @cached_property 

205 def allowed_origins_exact(self): 

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

207 

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 

222 

223 def _reject(self, request, reason): 

224 from .views import CsrfFailureView 

225 

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 

236 

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. 

241 

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) 

252 

253 if csrf_secret is None: 

254 return None 

255 return csrf_secret 

256 

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

270 

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 ) 

296 

297 def _check_referer(self, request): 

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

299 if referer is None: 

300 raise RejectRequest(REASON_NO_REFERER) 

301 

302 try: 

303 referer = urlparse(referer) 

304 except ValueError: 

305 raise RejectRequest(REASON_MALFORMED_REFERER) 

306 

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

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

309 raise RejectRequest(REASON_MALFORMED_REFERER) 

310 

311 # Ensure that our Referer is also secure. 

312 if referer.scheme != "https": 

313 raise RejectRequest(REASON_INSECURE_REFERER) 

314 

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

334 

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

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

337 

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

344 

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

353 

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) 

359 

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 

371 

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" 

386 

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) 

392 

393 if not _does_token_match(request_csrf_token, csrf_secret): 

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

395 raise RejectRequest(reason) 

396 

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 

402 

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 

406 

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 

413 

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) 

443 

444 try: 

445 self._check_token(request) 

446 except RejectRequest as exc: 

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

448 

449 return None