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
« 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
22import webob
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
31TEMPLATE_STYLE_KEY = "style"
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.
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"
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()
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"
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
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
94 return 403, "Forbidden", "Request rejected due to fetch metadata"
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
117 :warning: Don't instantiate! Don't subclass! DON'T TOUCH! ;)
118 """
120 # List of requestValidators used to preflight-check an request before it's being dispatched within ViUR
121 requestValidators = [FetchMetaDataValidator]
123 before_request_funcs: t.ClassVar[list[t.Callable[[], None]]] = []
124 after_request_funcs: t.ClassVar[list[t.Callable[[], None]]] = []
126 def __init__(self, environ: dict):
127 super().__init__()
128 self.startTime = time.time()
130 self.request = webob.Request(environ)
131 self.response = webob.Response()
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 = ()
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 = ()
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
155 db.current_db_access_log.set(set())
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({})
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()
166 # Process actual request
167 self._process()
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()
172 self._cors()
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)
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
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 """
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 = []
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
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
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)
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
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)
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)
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)
280 return path
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
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
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
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
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
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
369 try:
370 current.session.get().load()
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())
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)
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()
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)
390 self._route()
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
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)
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."
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 }
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()
450 error_info["logo"] = conf.error_logo
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>')
478 self.response.write(res.encode("UTF-8"))
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"
494 TRACE = "projects/{}/traces/{}".format(loggingClient.project, self._traceID)
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 )
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
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")
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 = ()
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 """
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 )
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""")
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()
568 if param_filter and param_filter(key, value):
569 continue
571 if key == TEMPLATE_STYLE_KEY:
572 self.template_style = value
573 continue
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
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()
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
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()
597 idx += 1
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"
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
607 self.args = tuple(self.path_list[idx:])
608 break
610 elif part == "index":
611 path_found = False
612 break
614 else:
615 path_found = False
616 break
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""")
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()
629 # Check for internal exposed
630 if caller.exposed is False and not self.internalRequest:
631 raise errors.NotFound()
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()
637 # Register caller specific CORS headers
638 self.cors_headers = [str(header).lower() for header in caller.cors_allow_headers or ()]
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!")
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!")
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
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
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 )
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
680 # Now call the routed method!
681 res = caller(*self.args, **kwargs)
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
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)
694 def _cors(self) -> None:
695 """
696 Set CORS headers to the HTTP response.
698 .. seealso::
700 Option :attr:`core.config.Security.cors_origins`, etc.
701 for cors settings.
703 https://fetch.spec.whatwg.org/#http-cors-protocol
705 https://enable-cors.org/server.html
707 https://www.html5rocks.com/static/images/cors_server_flowchart.png
708 """
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
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
730 # Origin is set --> It's a CORS request
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 )
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"] = "*"
749 elif test_candidates(origin, *conf.security.cors_origins):
750 self.response.headers["Access-Control-Allow-Origin"] = origin
752 else:
753 logging.warning(f"{origin=} not valid (must be one of {conf.security.cors_origins=})")
754 return
756 if conf.security.cors_allow_credentials:
757 self.response.headers["Access-Control-Allow-Credentials"] = "true"
759 if self.method == "options":
760 method = (self.request.headers.get("Access-Control-Request-Method") or "").lower()
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
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()))
773 # Allowed methods
774 self.response.headers["Access-Control-Allow-Methods"] = ", ".join(
775 sorted(conf.security.cors_methods)).upper()
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))
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 )
803 def saveSession(self) -> None:
804 current.session.get().save()
807def before_request(fn: t.Callable[[], None]) -> t.Callable[[], None]:
808 """Register a function to be called before each request is processed.
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.
817 Usage::
819 from viur.core import before_request
821 @before_request
822 def my_hook():
823 ...
824 """
825 Router.before_request_funcs.append(fn)
826 return fn
829def after_request(fn: t.Callable[[], None]) -> t.Callable[[], None]:
830 """Register a function to be called after each request has been processed.
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.
839 Usage::
841 from viur.core import after_request
843 @after_request
844 def my_hook():
845 ...
846 """
847 Router.after_request_funcs.append(fn)
848 return fn
851from .i18n import translate # noqa: E402