"""Middlewares HTTP : en-tetes de securite et limite de taille des requetes.""" from __future__ import annotations from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import JSONResponse, Response CONTENT_SECURITY_POLICY = "; ".join( [ "default-src 'self'", # Les styles inline sont utilises par React Flow et par l'export image. "style-src 'self' 'unsafe-inline'", "script-src 'self'", "img-src 'self' data: blob:", "font-src 'self' data:", "connect-src 'self'", # Service worker et manifeste de la PWA. "worker-src 'self'", "manifest-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", ] ) SECURITY_HEADERS = { "Content-Security-Policy": CONTENT_SECURITY_POLICY, "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "no-referrer", "Permissions-Policy": "geolocation=(), microphone=(), camera=(), interest-cohort=()", "Cross-Origin-Opener-Policy": "same-origin", } # Chemins qui ne doivent jamais etre indexes : ils sont derriere un lien non # repertorie, ou ne sont pas des pages. NOINDEX_PREFIXES = ("/s/", "/api/") ROBOTS_TAG = "noindex, nofollow, noarchive" class SecurityHeadersMiddleware(BaseHTTPMiddleware): """En-tetes de securite communs. `Referrer-Policy: no-referrer` est applique a tout le site, pas seulement aux pages de partage : une URL /s/ est une capacite de lecture, et elle ne doit jamais partir dans l'en-tete Referer d'un site externe sur lequel le lecteur cliquerait. """ async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] response: Response = await call_next(request) for header, value in SECURITY_HEADERS.items(): response.headers.setdefault(header, value) path = request.url.path if path.startswith("/assets/"): # Les fichiers produits par Vite portent un hash de contenu dans leur # nom : ils ne changent jamais sous une meme URL. index.html et sw.js, # eux, restent en no-cache (voir web.py) pour qu'une mise a jour de # l'application soit prise en compte au rechargement suivant. response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable") if path.startswith(NOINDEX_PREFIXES): # robots.txt reste une simple convention : c'est le jeton qui # autorise la lecture. Cet en-tete evite seulement qu'un lien # partage se retrouve dans un index public. response.headers.setdefault("X-Robots-Tag", ROBOTS_TAG) return response class MaxBodySizeMiddleware(BaseHTTPMiddleware): """Refuse proprement les corps de requete trop volumineux. On s'appuie sur Content-Length : le client est rejete avant toute lecture complete du corps, et recoit un message clair plutot qu'une erreur brute. """ def __init__(self, app, max_body_size: int) -> None: # type: ignore[no-untyped-def] super().__init__(app) self.max_body_size = max_body_size async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] content_length = request.headers.get("content-length") if content_length is not None: try: size = int(content_length) except ValueError: size = 0 if size > self.max_body_size: limit_mb = self.max_body_size / (1024 * 1024) return JSONResponse( status_code=413, content={ "detail": ( "La carte est trop volumineuse pour etre partagee " f"(limite : {limit_mb:.0f} Mo)." ) }, ) return await call_next(request)