Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/request.py: 36%

462 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-27 10:50 +0000

1""" 

2 This module implements the WSGI (Web Server Gateway Interface) layer for ViUR. This is the main entry 

3 point for incomming http requests. The main class is the :class:BrowserHandler. Each request will get it's 

4 own instance of that class which then holds the reference to the request and response object. 

5 Additionally, this module defines the RequestValidator interface which provides a very early hook into the 

6 request processing (useful for global ratelimiting, DDoS prevention or access control). 

7""" 

8import datetime 

9import fnmatch 

10import json 

11import logging 

12import os 

13import re 

14import time 

15import traceback 

16import typing as t 

17import unicodedata 

18from abc import ABC, abstractmethod 

19from urllib import parse 

20from urllib.parse import quote, unquote, urljoin, urlparse 

21 

22import webob 

23 

24from viur.core import current, db, errors, session, utils 

25from viur.core.config import conf 

26from viur.core.logging import client as loggingClient, requestLogger, requestLoggingRessource 

27from viur.core.module import Method 

28from viur.core.securityheaders import _build_reporting_endpoints_header, extendCsp 

29from viur.core.tasks import _appengineServiceIPs 

30 

31TEMPLATE_STYLE_KEY = "style" 

32 

33 

34class RequestValidator(ABC): 

35 """ 

36 RequestValidators can be used to validate a request very early on. If the validate method returns a tuple, 

37 the request is aborted. Can be used to block requests from bots. 

38 

39 To register or remove a validator, access it in main.py through 

40 :attr: viur.core.request.Router.requestValidators 

41 """ 

42 # Internal name to trace which validator aborted the request 

43 name = "RequestValidator" 

44 

45 @staticmethod 

46 @abstractmethod 

47 def validate(request: 'BrowseHandler') -> t.Optional[tuple[int, str, str]]: 

48 """ 

49 The function that checks the current request. If the request is valid, simply return None. 

50 If the request should be blocked, it must return a tuple of 

51 - The HTTP status code (as int) 

52 - The Description of that status code (eg "Forbidden") 

53 - The Response Body (can be a simple string or an HTML-Page) 

54 :param request: The Request instance to check 

55 :return: None on success, an Error-Tuple otherwise 

56 """ 

57 raise NotImplementedError() 

58 

59 

60class FetchMetaDataValidator(RequestValidator): 

61 """ 

62 This validator examines the headers "Sec-Fetch-Site", "sec-fetch-mode" and "sec-fetch-dest" as 

63 recommended by https://web.dev/fetch-metadata/ 

64 """ 

65 name = "FetchMetaDataValidator" 

66 

67 @staticmethod 

68 def validate(request: 'BrowseHandler') -> t.Optional[tuple[int, str, str]]: 

69 """ 

70 This validator examines the headers "sec-fetch-site", 

71 "sec-fetch-mode" and "sec-fetch-dest" as recommended 

72 by https://web.dev/fetch-metadata/ 

73 """ 

74 headers = request.request.headers 

75 

76 match headers.get("sec-fetch-site"): 

77 case None | "same-origin" | "same-site" | "none": 77 ↛ 84line 77 didn't jump to line 84 because the pattern on line 77 always matched

78 # Browser didn't send "sec-fetch-site", or the request is 

79 # same-origin, same-site (e.g. subdomain or redirect within the 

80 # same registrable domain) or browser-initiated ("none"). 

81 # These are allowed per the reference policy on 

82 # https://web.dev/fetch-metadata/ 

83 return None 

84 case _: 

85 # Incoming cross-site request: allow only simple top-level 

86 # navigation GET requests, except <object> and <embed>. 

87 if ( 

88 not request.isPostRequest 

89 and headers.get("sec-fetch-mode") == "navigate" 

90 and headers.get('sec-fetch-dest') not in ("object", "embed") 

91 ): 

92 return None 

93 

94 return 403, "Forbidden", "Request rejected due to fetch metadata" 

95 

96 

97class Router: 

98 """ 

99 This class accepts the requests, collect its parameters and routes the request 

100 to its destination function. 

101 The basic control flow is 

102 - Setting up internal variables 

103 - Running the Request validators 

104 - Emitting the headers (especially the security related ones) 

105 - Run the TLS check (ensure it's a secure connection or check if the URL is whitelisted) 

106 - Load or initialize a new session 

107 - Set up i18n (choosing the language etc) 

108 - Run the request preprocessor (if any) 

109 - Run before_request hooks (if any) 

110 - Normalize & sanity check the parameters 

111 - Resolve the exposed function and call it 

112 - Save the session / tear down the request 

113 - Run after_request hooks (if any) 

114 - Return the response generated 

115 

116 

117 :warning: Don't instantiate! Don't subclass! DON'T TOUCH! ;) 

118 """ 

119 

120 # List of requestValidators used to preflight-check an request before it's being dispatched within ViUR 

121 requestValidators = [FetchMetaDataValidator] 

122 

123 before_request_funcs: t.ClassVar[list[t.Callable[[], None]]] = [] 

124 after_request_funcs: t.ClassVar[list[t.Callable[[], None]]] = [] 

125 

126 def __init__(self, environ: dict): 

127 super().__init__() 

128 self.startTime = time.time() 

129 

130 self.request = webob.Request(environ) 

131 self.response = webob.Response() 

132 

133 self.maxLogLevel = logging.DEBUG 

134 self._traceID = \ 

135 self.request.headers.get("X-Cloud-Trace-Context", "").split("/")[0] or utils.string.random() 

136 self.is_deferred = False 

137 self.path = "" 

138 self.path_list = () 

139 

140 self.skey_checked = False # indicates whether @skey-decorator-check has already performed within a request 

141 self.internalRequest = False 

142 self.disableCache = False # Shall this request bypass the caches? 

143 self.pendingTasks = [] 

144 self.args = () 

145 self.kwargs = {} 

146 self.context = {} 

147 self.template_style: str | None = None 

148 self.cors_headers = () 

149 

150 # Check if it's a HTTP-Method we support 

151 self.method = self.request.method.lower() 

152 self.isPostRequest = self.method == "post" 

153 self.isSSLConnection = self.request.host_url.lower().startswith("https://") # We have an encrypted channel 

154 

155 db.current_db_access_log.set(set()) 

156 

157 # Set context variables 

158 current.language.set(conf.i18n.default_language) 

159 current.request.set(self) 

160 current.session.set(session.Session()) 

161 current.request_data.set({}) 

162 

163 for fn in Router.before_request_funcs: 163 ↛ 164line 163 didn't jump to line 164 because the loop on line 163 never started

164 fn() 

165 

166 # Process actual request 

167 self._process() 

168 

169 for fn in Router.after_request_funcs: 169 ↛ 170line 169 didn't jump to line 170 because the loop on line 169 never started

170 fn() 

171 

172 self._cors() 

173 

174 # Unset context variables 

175 current.language.set(None) 

176 current.request_data.set(None) 

177 current.session.set(None) 

178 current.request.set(None) 

179 current.user.set(None) 

180 

181 @property 

182 def isDevServer(self) -> bool: 

183 import warnings 

184 msg = "Use of `isDevServer` is deprecated; Use `conf.instance.is_dev_server` instead!" 

185 warnings.warn(msg, DeprecationWarning, stacklevel=2) 

186 logging.warning(msg) 

187 return conf.instance.is_dev_server 

188 

189 def _select_language(self, path: str) -> str: 

190 """ 

191 Tries to select the best language for the current request. Depending on the value of 

192 conf.i18n.language_method, we'll either try to load it from the session, determine it by the domain 

193 or extract it from the URL. 

194 """ 

195 

196 def get_language_from_header() -> str | None: 

197 if not (accept_language := self.request.headers.get("accept-language")): 197 ↛ 199line 197 didn't jump to line 199 because the condition on line 197 was always true

198 return None 

199 languages = accept_language.split(",") 

200 locale_q_pairs = [] 

201 

202 for language in languages: 

203 if language.split(";")[0] == language: 

204 # no q => q = 1 

205 locale_q_pairs.append((language.strip(), "1")) 

206 else: 

207 try: 

208 locale = language.split(";")[0].strip() 

209 q = language.split(";")[1].split("=")[1] 

210 locale_q_pairs.append((locale, q)) 

211 except IndexError: 

212 continue # skip language 

213 locale_q_pairs.sort(key=lambda pair: pair[1], reverse=True) # sort by Quality values 

214 for locale_q_pair in locale_q_pairs: 

215 if "-" in locale_q_pair[0]: # Check for de-DE 

216 lang = locale_q_pair[0].split("-")[0] 

217 else: 

218 lang = locale_q_pair[0] 

219 if lang in conf.i18n.available_languages + list(conf.i18n.language_alias_map.keys()): 

220 return lang 

221 if lang == "*": # fallback 

222 return conf.i18n.available_languages[0] 

223 return None 

224 

225 if not conf.i18n.available_languages: 225 ↛ 227line 225 didn't jump to line 227 because the condition on line 225 was never true

226 # This project doesn't use the multi-language feature, nothing to do here 

227 return path 

228 if conf.i18n.language_method == "session": 228 ↛ 249line 228 didn't jump to line 249 because the condition on line 228 was always true

229 current_session = current.session.get() 

230 lang = conf.i18n.default_language 

231 # We save the language in the session, if it exists, and try to load it from there 

232 if "lang" in current_session: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true

233 current.language.set(current_session["lang"]) 

234 return path 

235 

236 if header_lang := get_language_from_header(): 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true

237 lang = header_lang 

238 current.language.set(lang) 

239 

240 elif header_lang := self.request.headers.get("X-Appengine-Country"): 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true

241 header_lang = str(header_lang).lower() 

242 if header_lang in conf.i18n.available_languages + list(conf.i18n.language_alias_map.keys()): 

243 lang = header_lang 

244 

245 if current_session.loaded: 245 ↛ 247line 245 didn't jump to line 247 because the condition on line 245 was always true

246 current_session["lang"] = lang 

247 current.language.set(lang) 

248 

249 elif conf.i18n.language_method == "domain": 

250 host = self.request.host_url.lower() 

251 host = host[host.find("://") + 3:].strip(" /") # strip http(s):// 

252 if host.startswith("www."): 

253 host = host[4:] 

254 if lang := conf.i18n.domain_language_mapping.get(host): 

255 current.language.set(lang) 

256 # We have no language configured for this domain, try to read it from the HTTP Header 

257 elif lang := get_language_from_header(): 

258 current.language.set(lang) 

259 

260 elif conf.i18n.language_method == "url": 

261 tmppath = urlparse(path).path 

262 tmppath = [unquote(x) for x in tmppath.lower().strip("/").split("/")] 

263 if ( 

264 len(tmppath) > 0 

265 and tmppath[0] in conf.i18n.available_languages + list(conf.i18n.language_alias_map.keys()) 

266 ): 

267 current.language.set(tmppath[0]) 

268 return path[len(tmppath[0]) + 1:] # Return the path stripped by its language segment 

269 else: # This URL doesnt contain an language prefix, try to read it from session 

270 if header_lang := get_language_from_header(): 

271 current.language.set(header_lang) 

272 elif header_lang := self.request.headers.get("X-Appengine-Country"): 

273 lang = str(header_lang).lower() 

274 if lang in conf.i18n.available_languages or lang in conf.i18n.language_alias_map: 

275 current.language.set(lang) 

276 elif conf.i18n.language_method == "header": 

277 if lang := get_language_from_header(): 

278 current.language.set(lang) 

279 

280 return path 

281 

282 def _process(self): 

283 if self.method not in ("get", "post", "head", "options"): 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true

284 logging.error(f"{self.method=} not supported") 

285 return 

286 

287 if self.request.headers.get("X-AppEngine-TaskName", None) is not None: # Check if we run in the appengine 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true

288 if self.request.environ.get("HTTP_X_APPENGINE_USER_IP") in _appengineServiceIPs: 

289 self.is_deferred = True 

290 elif os.getenv("TASKS_EMULATOR") is not None: 

291 self.is_deferred = True 

292 

293 # Check if we should process or abort the request 

294 for validator, reqValidatorResult in [(x, x.validate(self)) for x in self.requestValidators]: 

295 if reqValidatorResult is not None: 295 ↛ 296line 295 didn't jump to line 296 because the condition on line 295 was never true

296 logging.warning(f"Request rejected by validator {validator.name}") 

297 statusCode, statusStr, statusDescr = reqValidatorResult 

298 self.response.status = f"{statusCode} {statusStr}" 

299 self.response.write(statusDescr) 

300 return 

301 

302 try: 

303 path = self.request.path 

304 except UnicodeDecodeError: # webob can fail with UnicodeDecodeError on broken/invalid URLs 

305 self.response.status = "400 Bad Request" # let's send the client onto a health cure in Bad Request ... 

306 return 

307 

308 # Add CSP headers early (if any) 

309 if conf.security.content_security_policy and conf.security.content_security_policy["_headerCache"]: 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true

310 for k, v in conf.security.content_security_policy["_headerCache"].items(): 

311 self.response.headers[k] = v 

312 # Endpoints referenced by the CSP-directive "report-to" and others 

313 if reporting_endpoints := _build_reporting_endpoints_header(): 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true

314 self.response.headers["Reporting-Endpoints"] = reporting_endpoints 

315 if self.isSSLConnection: # Check for HTST and PKP headers only if we have a secure channel. 315 ↛ 319line 315 didn't jump to line 319 because the condition on line 315 was always true

316 if conf.security.strict_transport_security: 316 ↛ 319line 316 didn't jump to line 319 because the condition on line 316 was always true

317 self.response.headers["Strict-Transport-Security"] = conf.security.strict_transport_security 

318 # Check for X-Security-Headers we shall emit 

319 if conf.security.x_content_type_options: 319 ↛ 321line 319 didn't jump to line 321 because the condition on line 319 was always true

320 self.response.headers["X-Content-Type-Options"] = "nosniff" 

321 if conf.security.x_xss_protection is not None: 321 ↛ 326line 321 didn't jump to line 326 because the condition on line 321 was always true

322 if conf.security.x_xss_protection: 322 ↛ 324line 322 didn't jump to line 324 because the condition on line 322 was always true

323 self.response.headers["X-XSS-Protection"] = "1; mode=block" 

324 elif conf.security.x_xss_protection is False: 

325 self.response.headers["X-XSS-Protection"] = "0" 

326 if conf.security.x_frame_options is not None and isinstance(conf.security.x_frame_options, tuple): 326 ↛ 332line 326 didn't jump to line 332 because the condition on line 326 was always true

327 mode, uri = conf.security.x_frame_options 

328 if mode in ["deny", "sameorigin"]: 328 ↛ 330line 328 didn't jump to line 330 because the condition on line 328 was always true

329 self.response.headers["X-Frame-Options"] = mode 

330 elif mode == "allow-from": 

331 self.response.headers["X-Frame-Options"] = f"allow-from {uri}" 

332 if conf.security.x_permitted_cross_domain_policies is not None: 332 ↛ 334line 332 didn't jump to line 334 because the condition on line 332 was always true

333 self.response.headers["X-Permitted-Cross-Domain-Policies"] = conf.security.x_permitted_cross_domain_policies 

334 if conf.security.referrer_policy: 334 ↛ 336line 334 didn't jump to line 336 because the condition on line 334 was always true

335 self.response.headers["Referrer-Policy"] = conf.security.referrer_policy 

336 if conf.security.permissions_policy.get("_headerCache"): 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true

337 self.response.headers["Permissions-Policy"] = conf.security.permissions_policy["_headerCache"] 

338 if conf.security.enable_coep: 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true

339 self.response.headers["Cross-Origin-Embedder-Policy"] = "require-corp" 

340 if conf.security.enable_coop: 340 ↛ 342line 340 didn't jump to line 342 because the condition on line 340 was always true

341 self.response.headers["Cross-Origin-Opener-Policy"] = conf.security.enable_coop 

342 if conf.security.enable_corp: 342 ↛ 346line 342 didn't jump to line 346 because the condition on line 342 was always true

343 self.response.headers["Cross-Origin-Resource-Policy"] = conf.security.enable_corp 

344 

345 # Ensure that TLS is used if required 

346 if conf.security.force_ssl and not self.isSSLConnection and not conf.instance.is_dev_server: 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true

347 isWhitelisted = False 

348 reqPath = self.request.path 

349 for testUrl in conf.security.no_ssl_check_urls: 

350 if testUrl.endswith("*"): 

351 if reqPath.startswith(testUrl[:-1]): 

352 isWhitelisted = True 

353 break 

354 else: 

355 if testUrl == reqPath: 

356 isWhitelisted = True 

357 break 

358 if not isWhitelisted: # Some URLs need to be whitelisted (as f.e. the Tasks-Queue doesn't call using https) 

359 # Redirect the user to the startpage (using ssl this time) 

360 host = self.request.host_url.lower() 

361 host = host[host.find("://") + 3:].strip(" /") # strip http(s):// 

362 self.response.status = "302 Found" 

363 self.response.headers['Location'] = f"https://{host}/" 

364 return 

365 if path.startswith("/_ah/warmup"): 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true

366 self.response.write("okay") 

367 return 

368 

369 try: 

370 current.session.get().load() 

371 

372 # Load current user into context variable if user module is there. 

373 if user_mod := getattr(conf.main_app.vi, "user", None): 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true

374 current.user.set(user_mod.getCurrentUser()) 

375 

376 path = self._select_language(path)[1:] 

377 # Parse the path before anything can raise, the error handling depends on path_list 

378 self._parse_path(path) 

379 

380 # Check for closed system 

381 if conf.security.closed_system and self.method != "options": 

382 if not current.user.get(): 382 ↛ 386line 382 didn't jump to line 386 because the condition on line 382 was always true

383 if not any(fnmatch.fnmatch(path, pat) for pat in conf.security.closed_system_allowed_paths): 383 ↛ 386line 383 didn't jump to line 386 because the condition on line 383 was always true

384 raise errors.Unauthorized() 

385 

386 if conf.request_preprocessor: 386 ↛ 390line 386 didn't jump to line 390 because the condition on line 386 was always true

387 path = conf.request_preprocessor(path) 

388 self._parse_path(path) 

389 

390 self._route() 

391 

392 except errors.Redirect as e: 

393 if conf.debug.trace_exceptions: 

394 logging.warning("""conf.debug.trace_exceptions is set, won't handle this exception""") 

395 raise 

396 self.response.status = f"{e.status} {e.name}" 

397 url = e.url 

398 url = unquote(url) # decode first 

399 # safe = https://url.spec.whatwg.org/#url-path-segment-string 

400 url = quote(url, encoding="utf-8", safe="!$&'()*+,-./:;=?@_~#") # re-encode all in utf-8 

401 if url.startswith(('.', '/')): 

402 url = str(urljoin(self.request.url, url)) 

403 self.response.headers['Location'] = url 

404 

405 except Exception as e: 

406 if conf.debug.trace_exceptions: 406 ↛ 407line 406 didn't jump to line 407 because the condition on line 406 was never true

407 logging.warning("""conf.debug.trace_exceptions is set, won't handle this exception""") 

408 raise 

409 self.response.body = b"" 

410 if isinstance(e, errors.HTTPException): 410 ↛ 417line 410 didn't jump to line 417 because the condition on line 410 was always true

411 logging.info(f"[{e.status}] {e.name}: {e.descr}", exc_info=conf.debug.trace) 

412 self.response.status = f"{e.status} {e.name}" 

413 # Set machine-readable x-viur-error response header in case there is an exception description. 

414 if e.descr: 414 ↛ 421line 414 didn't jump to line 421 because the condition on line 414 was always true

415 self.response.headers["x-viur-error"] = e.descr.replace("\n", "") 

416 else: 

417 self.response.status = 500 

418 logging.error("ViUR has caught an unhandled exception!") 

419 logging.exception(e) 

420 

421 res = None 

422 if conf.error_handler: 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true

423 try: 

424 res = conf.error_handler(e) 

425 except Exception as newE: 

426 logging.error("viur.error_handler failed!") 

427 logging.exception(newE) 

428 res = None 

429 if not res: 429 ↛ 478line 429 didn't jump to line 478 because the condition on line 429 was always true

430 descr = "The server encountered an unexpected error and is unable to process your request." 

431 

432 if isinstance(e, errors.HTTPException): 432 ↛ 440line 432 didn't jump to line 440 because the condition on line 432 was always true

433 error_info = { 

434 "status": e.status, 

435 "reason": e.name, 

436 "title": str(translate(e.name)), 

437 "descr": e.descr, 

438 } 

439 else: 

440 error_info = { 

441 "status": 500, 

442 "reason": "Internal Server Error", 

443 "title": str(translate("Internal Server Error")), 

444 "descr": descr 

445 } 

446 

447 if conf.instance.is_dev_server: 447 ↛ 448line 447 didn't jump to line 448 because the condition on line 447 was never true

448 error_info["traceback"] = traceback.format_exc() 

449 

450 error_info["logo"] = conf.error_logo 

451 

452 if (len(self.path_list) > 0 and self.path_list[0] in ("vi", "json")) or \ 

453 current.request.get().response.headers["Content-Type"] == "application/json": 

454 current.request.get().response.headers["Content-Type"] = "application/json" 

455 res = json.dumps(error_info) 

456 else: # We render the error in html 

457 # Try to get the template from html/error/ 

458 if filename := conf.main_app.render.getTemplateFileName((f"{error_info['status']}", "error"), 458 ↛ 460line 458 didn't jump to line 460 because the condition on line 458 was never true

459 raise_exception=False): 

460 template = conf.main_app.render.getEnv().get_template(filename) 

461 try: 

462 uses_unsafe_inline = \ 

463 "unsafe-inline" in conf.security.content_security_policy["enforce"]["style-src"] 

464 except (KeyError, TypeError): # Not set 

465 uses_unsafe_inline = False 

466 if uses_unsafe_inline: 

467 logging.info("Using style-src:unsafe-inline, don't create a nonce") 

468 nonce = None 

469 else: 

470 nonce = utils.string.random(16) 

471 extendCsp({"style-src": [f"nonce-{nonce}"]}) 

472 res = template.render(error_info, nonce=nonce) 

473 else: 

474 res = (f'<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">' 

475 f'<title>{error_info["status"]} - {error_info["reason"]}</title>' 

476 f'</head><body><h1>{error_info["status"]} - {error_info["reason"]}</h1>') 

477 

478 self.response.write(res.encode("UTF-8")) 

479 

480 finally: 

481 current.session.get().save() 

482 if conf.instance.is_dev_server and conf.debug.dev_server_cloud_logging: 482 ↛ 484line 482 didn't jump to line 484 because the condition on line 482 was never true

483 # Emit the outer log only on dev_appserver (we'll use the existing request log when live) 

484 SEVERITY = "DEBUG" 

485 if self.maxLogLevel >= 50: 

486 SEVERITY = "CRITICAL" 

487 elif self.maxLogLevel >= 40: 

488 SEVERITY = "ERROR" 

489 elif self.maxLogLevel >= 30: 

490 SEVERITY = "WARNING" 

491 elif self.maxLogLevel >= 20: 

492 SEVERITY = "INFO" 

493 

494 TRACE = "projects/{}/traces/{}".format(loggingClient.project, self._traceID) 

495 

496 REQUEST = { 

497 'requestMethod': self.request.method, 

498 'requestUrl': self.request.url, 

499 'status': self.response.status_code, 

500 'userAgent': self.request.headers.get('USER-AGENT'), 

501 'responseSize': self.response.content_length, 

502 'latency': "%0.3fs" % (time.time() - self.startTime), 

503 'remoteIp': self.request.environ.get("HTTP_X_APPENGINE_USER_IP") 

504 } 

505 requestLogger.log_text( 

506 "", 

507 client=loggingClient, 

508 severity=SEVERITY, 

509 http_request=REQUEST, 

510 trace=TRACE, 

511 resource=requestLoggingRessource, 

512 operation={ 

513 "first": True, 

514 "last": True, 

515 "id": self._traceID 

516 } 

517 ) 

518 

519 if conf.instance.is_dev_server: 519 ↛ 520line 519 didn't jump to line 520 because the condition on line 519 was never true

520 self.is_deferred = True 

521 

522 while self.pendingTasks: 

523 task = self.pendingTasks.pop() 

524 logging.debug(f"Deferred task emulation, executing {task=}") 

525 try: 

526 task() 

527 except Exception: # noqa 

528 logging.exception(f"Deferred Task emulation {task} failed") 

529 

530 def _parse_path(self, path: str) -> None: 

531 """ 

532 Parses the URL path into :attr:`path` and :attr:`path_list` 

533 """ 

534 if path := parse.urlparse(path).path: 534 ↛ 539line 534 didn't jump to line 539 because the condition on line 534 was always true

535 self.path = path 

536 self.path_list = tuple(unicodedata.normalize("NFC", parse.unquote(part)) 

537 for part in path.strip("/").split("/")) 

538 else: 

539 self.path = "" 

540 self.path_list = () 

541 

542 def _route(self) -> None: 

543 """ 

544 Does the actual work of sanitizing the parameter, determine which exposed-function to call 

545 (and with which parameters) 

546 """ 

547 

548 # Prevent Hash-collision attacks 

549 if len(self.request.params) > conf.max_post_params_count: 549 ↛ 550line 549 didn't jump to line 550 because the condition on line 549 was never true

550 raise errors.BadRequest( 

551 f"Too many arguments supplied, exceeding maximum" 

552 f" of {conf.max_post_params_count} allowed arguments per request" 

553 ) 

554 

555 param_filter = conf.param_filter_function 

556 if param_filter and not callable(param_filter): 556 ↛ 557line 556 didn't jump to line 557 because the condition on line 556 was never true

557 raise ValueError(f"""{param_filter=} is not callable""") 

558 

559 for key, value in self.request.params.items(): 559 ↛ 560line 559 didn't jump to line 560 because the loop on line 559 never started

560 try: 

561 key = unicodedata.normalize("NFC", key) 

562 value = unicodedata.normalize("NFC", value) 

563 except UnicodeError: 

564 # We received invalid unicode data (usually happens when 

565 # someone tries to exploit unicode normalisation bugs) 

566 raise errors.BadRequest() 

567 

568 if param_filter and param_filter(key, value): 

569 continue 

570 

571 if key == TEMPLATE_STYLE_KEY: 

572 self.template_style = value 

573 continue 

574 

575 if key in self.kwargs: 

576 if isinstance(self.kwargs[key], list): 

577 self.kwargs[key].append(value) 

578 else: # Convert that key to a list 

579 self.kwargs[key] = [self.kwargs[key], value] 

580 else: 

581 self.kwargs[key] = value 

582 

583 if "self" in self.kwargs or "return" in self.kwargs: # self or return is reserved for bound methods 583 ↛ 584line 583 didn't jump to line 584 because the condition on line 583 was never true

584 raise errors.BadRequest() 

585 

586 caller = conf.main_resolver 

587 idx = 0 # Count how may items from *args we'd have consumed (so the rest can go into *args of the called func 

588 path_found = True 

589 

590 for part in self.path_list: 590 ↛ 618line 590 didn't jump to line 618 because the loop on line 590 didn't complete

591 # TODO: Remove canAccess guards... solve differently. 

592 if "canAccess" in caller and not caller["canAccess"](): 592 ↛ 595line 592 didn't jump to line 595 because the condition on line 592 was never true

593 # We have a canAccess function guarding that object, 

594 # and it returns False... 

595 raise errors.Unauthorized() 

596 

597 idx += 1 

598 

599 if part not in caller: 599 ↛ 602line 599 didn't jump to line 602 because the condition on line 599 was always true

600 part = "index" 

601 

602 if caller := caller.get(part): 602 ↛ 603line 602 didn't jump to line 603 because the condition on line 602 was never true

603 if isinstance(caller, Method): 

604 if part == "index": 

605 idx -= 1 

606 

607 self.args = tuple(self.path_list[idx:]) 

608 break 

609 

610 elif part == "index": 

611 path_found = False 

612 break 

613 

614 else: 

615 path_found = False 

616 break 

617 

618 if not path_found: 618 ↛ 622line 618 didn't jump to line 622 because the condition on line 618 was always true

619 raise errors.NotFound( 

620 f"""The path {utils.string.escape("/".join(self.path_list[:idx]))} could not be found""") 

621 

622 if not isinstance(caller, Method): 

623 # try to find "index" function 

624 if (index := caller.get("index")) and isinstance(index, Method): 

625 caller = index 

626 else: 

627 raise errors.MethodNotAllowed() 

628 

629 # Check for internal exposed 

630 if caller.exposed is False and not self.internalRequest: 

631 raise errors.NotFound() 

632 

633 # Fill the Allow header of the response with the allowed HTTP methods 

634 if self.method == "options": 

635 self.response.headers["Allow"] = ", ".join(sorted(caller.methods)).upper() 

636 

637 # Register caller specific CORS headers 

638 self.cors_headers = [str(header).lower() for header in caller.cors_allow_headers or ()] 

639 

640 # Check for @force_ssl flag 

641 if not self.internalRequest \ 

642 and caller.ssl \ 

643 and not self.request.host_url.lower().startswith("https://") \ 

644 and not conf.instance.is_dev_server: 

645 raise errors.PreconditionFailed("You must use SSL to access this resource!") 

646 

647 # Check for @force_post flag 

648 if not self.isPostRequest and caller.methods == ("POST",): 

649 raise errors.MethodNotAllowed("You must use POST to access this resource!") 

650 

651 # Check if this request should bypass the caches 

652 if self.request.headers.get("X-Viur-Disable-Cache"): 

653 # No cache requested, check if the current user is allowed to do so 

654 if (user := current.user.get()) and "root" in user["access"]: 

655 logging.debug("Caching disabled by X-Viur-Disable-Cache header") 

656 self.disableCache = True 

657 

658 # Destill context as self.context, if available 

659 if context := {k: v for k, v in self.kwargs.items() if k.startswith("@")}: 

660 # Remove context parameters from kwargs 

661 kwargs = {k: v for k, v in self.kwargs.items() if k not in context} 

662 # Remove leading "@" from context parameters 

663 self.context |= {k[1:]: v for k, v in context.items() if len(k) > 1} 

664 else: 

665 kwargs = self.kwargs 

666 

667 if ((self.internalRequest and conf.debug.trace_internal_call_routing) 

668 or conf.debug.trace_external_call_routing): 

669 logging.debug( 

670 f"Calling {caller._func!r} with args={self.args!r}, {kwargs=} within context={self.context!r}" 

671 ) 

672 

673 if self.method == "options": 

674 # OPTIONS request doesn't have a body 

675 del self.response.app_iter 

676 del self.response.content_type 

677 self.response.status = "204 No Content" 

678 return 

679 

680 # Now call the routed method! 

681 res = caller(*self.args, **kwargs) 

682 

683 if self.method == "options": 

684 # OPTIONS request doesn't have a body 

685 del self.response.app_iter 

686 del self.response.content_type 

687 self.response.status = "204 No Content" 

688 return 

689 

690 if not isinstance(res, bytes): # Convert the result to bytes if it is not already! 

691 res = str(res).encode("UTF-8") 

692 self.response.write(res) 

693 

694 def _cors(self) -> None: 

695 """ 

696 Set CORS headers to the HTTP response. 

697 

698 .. seealso:: 

699 

700 Option :attr:`core.config.Security.cors_origins`, etc. 

701 for cors settings. 

702 

703 https://fetch.spec.whatwg.org/#http-cors-protocol 

704 

705 https://enable-cors.org/server.html 

706 

707 https://www.html5rocks.com/static/images/cors_server_flowchart.png 

708 """ 

709 

710 def test_candidates(value: str, *candidates: str | re.Pattern) -> bool: 

711 """Test if the value matches the pattern of any candidate""" 

712 for candidate in candidates: 

713 if isinstance(candidate, re.Pattern): 

714 if candidate.match(value): 

715 return True 

716 elif isinstance(candidate, str): 

717 if candidate.lower() == str(value).lower(): 

718 return True 

719 else: 

720 raise TypeError( 

721 f"Invalid setting {candidate}. " 

722 f"Expected a string or a compiled regex." 

723 ) 

724 return False 

725 

726 origin = current.request.get().request.headers.get("Origin") 

727 if not origin: 727 ↛ 732line 727 didn't jump to line 732 because the condition on line 727 was always true

728 return 

729 

730 # Origin is set --> It's a CORS request 

731 

732 any_origin_allowed = ( 

733 conf.security.cors_origins == "*" 

734 or any(_origin == "*" for _origin in conf.security.cors_origins) 

735 or any(_origin.pattern == r".*" 

736 for _origin in conf.security.cors_origins 

737 if isinstance(_origin, re.Pattern)) 

738 ) 

739 

740 if any_origin_allowed and conf.security.cors_origins_use_wildcard: 

741 if conf.security.cors_allow_credentials: 

742 raise RuntimeError( 

743 "Invalid CORS config: " 

744 "If credentials mode is \"include\", then `Access-Control-Allow-Origin` cannot be `*`. " 

745 "See https://fetch.spec.whatwg.org/#cors-protocol-and-credentials" 

746 ) 

747 self.response.headers["Access-Control-Allow-Origin"] = "*" 

748 

749 elif test_candidates(origin, *conf.security.cors_origins): 

750 self.response.headers["Access-Control-Allow-Origin"] = origin 

751 

752 else: 

753 logging.warning(f"{origin=} not valid (must be one of {conf.security.cors_origins=})") 

754 return 

755 

756 if conf.security.cors_allow_credentials: 

757 self.response.headers["Access-Control-Allow-Credentials"] = "true" 

758 

759 if self.method == "options": 

760 method = (self.request.headers.get("Access-Control-Request-Method") or "").lower() 

761 

762 if method in conf.security.cors_methods: 

763 # It's a CORS-preflight request 

764 # - MUST include Access-Control-Request-Method 

765 # - CAN include Access-Control-Request-Headers 

766 

767 # The response can be cached 

768 if conf.security.cors_max_age is not None: 

769 assert isinstance(conf.security.cors_max_age, datetime.timedelta) 

770 self.response.headers["Access-Control-Max-Age"] = \ 

771 str(int(conf.security.cors_max_age.total_seconds())) 

772 

773 # Allowed methods 

774 self.response.headers["Access-Control-Allow-Methods"] = ", ".join( 

775 sorted(conf.security.cors_methods)).upper() 

776 

777 # Allowed headers 

778 request_headers = self.request.headers.get("Access-Control-Request-Headers") 

779 request_headers = [h.strip().lower() for h in request_headers.split(",")] 

780 if conf.security.cors_allow_headers == "*": 

781 # Every header is allowed 

782 allow_headers = request_headers[:] 

783 else: 

784 # There are generally headers allowed and/or from the caller 

785 allow_headers = [ 

786 header 

787 for header in request_headers 

788 if test_candidates( 

789 header, 

790 *(self.cors_headers or ()), # caller specific 

791 *(conf.security.cors_allow_headers or ()) # generally global 

792 ) 

793 ] 

794 if allow_headers: 

795 self.response.headers["Access-Control-Allow-Headers"] = ", ".join(sorted(allow_headers)) 

796 

797 else: 

798 logging.warning( 

799 f"Access-Control-Request-Method: {method} is NOT a valid method of {conf.security.cors_methods=}. " 

800 f"Don't append CORS-preflight request headers" 

801 ) 

802 

803 def saveSession(self) -> None: 

804 current.session.get().save() 

805 

806 

807def before_request(fn: t.Callable[[], None]) -> t.Callable[[], None]: 

808 """Register a function to be called before each request is processed. 

809 

810 The function is called after context variables are set (``current.request``, 

811 ``current.session``, ``current.request_data`` are available), but a fresh 

812 ``Session`` container has not been loaded yet — call ``current.session.get().load()`` 

813 explicitly if you need session data. ``current.user`` is not set. Exceptions 

814 raised by the hook propagate and abort request processing. No arguments are passed; 

815 use ``current.request.get()`` to access the request. The function must not return a value. 

816 

817 Usage:: 

818 

819 from viur.core import before_request 

820 

821 @before_request 

822 def my_hook(): 

823 ... 

824 """ 

825 Router.before_request_funcs.append(fn) 

826 return fn 

827 

828 

829def after_request(fn: t.Callable[[], None]) -> t.Callable[[], None]: 

830 """Register a function to be called after each request has been processed. 

831 

832 The function is called after :meth:`Router._process` completes — the response 

833 is fully generated, the session is saved, and ``current.user.get()`` is still 

834 available. The call happens before CORS headers are applied. Exceptions raised 

835 by the hook propagate and abort CORS processing. No arguments are passed; 

836 use ``current.request.get().response`` to inspect the response. The function 

837 must not return a value. 

838 

839 Usage:: 

840 

841 from viur.core import after_request 

842 

843 @after_request 

844 def my_hook(): 

845 ... 

846 """ 

847 Router.after_request_funcs.append(fn) 

848 return fn 

849 

850 

851from .i18n import translate # noqa: E402