Corrige deux régressions de /local-workspace :
- le chemin du header restait bloqué sur « Home / <workspace> » quel que
soit le dossier affiché : la route rend désormais breadcrumb_items
(Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
fois : Alpine.data('wsInitData') retournait le même objet singleton, le
2e montage (navigation partielle) levait « Cannot redefine property:
\ » et initTree abandonnait, laissant tout le contenu au state
brut. La factory retourne désormais une enveloppe fraîche par montage
qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
exécution (le 2e montage gardait le folder_id du 1er chargement).
Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
269 lines
11 KiB
Python
269 lines
11 KiB
Python
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import re
|
|
import secrets
|
|
import time
|
|
from collections import defaultdict
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.requests import Request
|
|
from starlette.responses import JSONResponse
|
|
|
|
from app.templating import CSP_NONCE
|
|
|
|
# ── Constants ────────────────────────────────────────────────
|
|
|
|
# Allowed extensions for file uploads
|
|
ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
|
|
# Images
|
|
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
|
|
# Documents
|
|
".pdf", ".md", ".markdown", ".mkd", ".mdown", ".mdwn", ".rmd", ".qmd",
|
|
".txt", ".log", ".csv", ".tsv", ".tab", ".nfo", ".asc",
|
|
".tex", ".latex", ".sty", ".cls", ".dtx", ".bib", ".bbl",
|
|
# Code — kept in sync with app/services/export.py::_TEXTUAL_EXTS
|
|
".py", ".pyw", ".pyi", ".gyp", ".gypi",
|
|
".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx",
|
|
".html", ".htm", ".xhtml", ".xht", ".xml", ".xsd", ".xsl", ".xslt",
|
|
".rss", ".atom", ".plist", ".wsf", ".axml",
|
|
".vue", ".svelte", ".astro",
|
|
".css", ".scss", ".sass", ".less",
|
|
".json", ".json5", ".jsonc", ".geojson", ".webmanifest",
|
|
".har", ".avsc",
|
|
".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
|
|
".properties", ".prop", ".inf", ".reg", ".url",
|
|
".gitconfig", ".gitmodules", ".editorconfig",
|
|
".sql", ".pgsql", ".psql", ".mysql", ".mssql",
|
|
".sh", ".bash", ".zsh", ".ksh", ".mksh", ".yash", ".fish",
|
|
".ps1", ".psm1", ".psd1", ".bat", ".cmd",
|
|
".rb", ".gemspec", ".rake", ".rbw",
|
|
".rs", ".go", ".java", ".jsp", ".kt", ".kts",
|
|
".scala", ".sc", ".groovy", ".gvy", ".gradle",
|
|
".c", ".cc", ".cpp", ".c++", ".cxx", ".hh", ".hxx", ".hcc", ".h", ".hpp",
|
|
".cs", ".csx", ".cake", ".ino", ".pde",
|
|
".php", ".phtml", ".swift", ".r", ".jl", ".lua", ".dart",
|
|
".pl", ".pm", ".pod",
|
|
".hs", ".lhs", ".ex", ".exs", ".eex", ".heex", ".leex",
|
|
".erl", ".hrl", ".escript",
|
|
".clj", ".cljs", ".cljc", ".edn",
|
|
".ml", ".mli", ".fs", ".fsi", ".fsx", ".fsscript",
|
|
".nim", ".nims", ".d", ".di",
|
|
".m", ".mm", ".vb", ".vbs", ".bas", ".pas", ".pp", ".dpr",
|
|
".vhd", ".vhdl", ".v", ".vh", ".sv", ".svh",
|
|
".asm", ".s", ".nasm", ".inc",
|
|
".ada", ".ads", ".adb",
|
|
".f", ".for", ".f90", ".f95", ".f03", ".f08",
|
|
".cob", ".cbl", ".cpy",
|
|
".dockerfile", ".containerfile",
|
|
".tf", ".tfvars", ".hcl",
|
|
".cmake", ".makefile", ".mk", ".mak", ".ninja",
|
|
".gql", ".graphql", ".graphqls", ".proto", ".thrift",
|
|
".vim", ".vimrc", ".gvimrc", ".awk",
|
|
".gcode", ".gco", ".nc", ".ngc",
|
|
".http", ".rest", ".nginx", ".apache", ".htaccess", ".wat",
|
|
".diff", ".patch", ".rej",
|
|
".mod", ".sum", ".lock", ".text",
|
|
".gitignore", ".gitattributes", ".gitkeep", ".dockerignore", ".npmignore",
|
|
# Archives
|
|
".zip", ".tar", ".gz", ".rar", ".7z",
|
|
})
|
|
|
|
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
|
|
|
|
def validate_upload(filename: str, size: int) -> str | None:
|
|
"""Validate upload filename and size. Returns error message or None."""
|
|
if size > MAX_UPLOAD_SIZE:
|
|
return f"File '{filename}' exceeds maximum size of 10 MB"
|
|
ext = _ext(filename)
|
|
if ext and ext not in ALLOWED_EXTENSIONS:
|
|
return f"File extension '{ext}' is not allowed"
|
|
return None
|
|
|
|
|
|
def _ext(filename: str) -> str:
|
|
"""Extract lowercase extension from filename."""
|
|
if "." in filename:
|
|
return "." + filename.rsplit(".", 1)[-1].lower()
|
|
return ""
|
|
|
|
|
|
# ── Content-Security-Policy Middleware ────────────────────────
|
|
|
|
class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
|
"""Sets Content-Security-Policy headers on all HTML responses.
|
|
|
|
A permissive-yet-safe policy for a Notion-style app that needs:
|
|
- inline scripts (Alpine.js, HTMX)
|
|
- inline styles
|
|
- font loading
|
|
- images from various sources
|
|
- media (audio/video)
|
|
- websocket connections for HMR/SSE
|
|
"""
|
|
|
|
CSP_HEADER = "Content-Security-Policy"
|
|
# A20 : `unsafe-inline` sort de script-src (remplacé par un nonce par
|
|
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
|
|
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
|
|
# détaché de script-src (sinon le nonce les désactiverait aussi).
|
|
# A20 TERMINÉ : `unsafe-eval` retiré — Alpine tourne en build CSP
|
|
# (static/js/alpine.csp.min.js, 0 eval) ; htmx allowEval=false.
|
|
# 15 surfaces en csp_preview vert + scan statique 0 (board/gitea/cards
|
|
# = props propres, gitea down empêche un gate dédié).
|
|
CSP_VALUE = (
|
|
"default-src 'self'; "
|
|
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
|
|
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
|
|
"script-src 'self' 'nonce-{nonce}'; "
|
|
"script-src-attr 'unsafe-inline'; "
|
|
# ponytail: aucun @font-face Google (grep négatif) → les deux
|
|
# hôtes fonts étaient morts, supprimés.
|
|
"style-src 'self' 'unsafe-inline'; "
|
|
# ponytail: `https:` reste ouvert — unfurls (YouTube/Vimeo/…) et
|
|
# tuiles OSM sont inénumérables ; plafond assumé, à resserrer si
|
|
# un proxy d'images local arrive.
|
|
"img-src 'self' data: blob: https:; "
|
|
"font-src 'self' data:; "
|
|
# connect-src fermé : plus de `https:` (aucun fetch cross-origin
|
|
# côté front — grep négatif) et websockets scopés à l'hôte de la
|
|
# requête ({host}) → plus de canal d'exfil vers un tiers.
|
|
"connect-src 'self' ws://{host} wss://{host}; "
|
|
"media-src 'self' blob:; "
|
|
"frame-src 'self'; "
|
|
"object-src 'none'; "
|
|
"base-uri 'self'; "
|
|
"form-action 'self'; "
|
|
)
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
nonce = secrets.token_urlsafe(16)
|
|
# Posé AVANT call_next : BaseHTTPMiddleware exécute le aval dans une
|
|
# tâche créée maintenant → le contexte (donc le nonce) y est copié,
|
|
# exactement ce que les templates liront via `csp_nonce()`.
|
|
CSP_NONCE.set(nonce)
|
|
response = await call_next(request)
|
|
# Only set CSP on HTML responses
|
|
content_type = response.headers.get("content-type", "")
|
|
if "text/html" in content_type:
|
|
# Host du navigateur (uvicorn rejette les Host invalides) ;
|
|
# on retire quand même tout caractère hors base URL par sécurité.
|
|
host = re.sub(r"[^0-9A-Za-z.\-:\[\]]", "",
|
|
request.headers.get("host", ""))
|
|
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(
|
|
nonce=nonce, host=host
|
|
)
|
|
return response
|
|
|
|
|
|
# ── Rate Limiting Middleware ──────────────────────────────────
|
|
|
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
"""Simple in-memory sliding-window rate limiter per IP.
|
|
|
|
Default: 100 requests per minute per IP for API routes.
|
|
Non-API routes (HTML pages, static files) are not rate-limited.
|
|
"""
|
|
|
|
# Paths that should be rate-limited
|
|
RATE_LIMITED_PREFIXES: tuple[str, ...] = (
|
|
"/api/", "/board/api/", "/auth/",
|
|
# A33 : préfixes qui manquaient — SCIM (brute force de jetons/IdP),
|
|
# API workspace + collections (les endpoints mutants du legacy).
|
|
"/scim/v2/", "/workspace/", "/db/",
|
|
)
|
|
|
|
# Pages publiques : seul le non-GET est plafonné (brute force de
|
|
# /s/<slug>/auth et spam de /f/<token>) — la lecture reste libre pour les
|
|
# visiteurs d'un site publié qui partagent une IP.
|
|
RATE_LIMITED_NON_GET_PREFIXES: tuple[str, ...] = ("/s/", "/f/")
|
|
|
|
# Paths exempt from rate limiting even under an API prefix
|
|
EXEMPT_PATHS: frozenset[str] = frozenset({
|
|
"/api/health",
|
|
"/api/frontend-error",
|
|
"/api/frontend-errors",
|
|
})
|
|
|
|
def __init__(self, app, max_requests: int | None = None, window_seconds: int = 60):
|
|
super().__init__(app)
|
|
# A33 : None = lire `settings.rate_limit_requests` à chaque requête (la
|
|
# valeur de config n'était jamais lue — 100 codé en dur contre 60 annoncé).
|
|
self.max_requests = max_requests
|
|
self.window_seconds = window_seconds
|
|
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
|
|
self._last_prune = 0.0
|
|
self._max_keys = 5000
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
path = request.url.path
|
|
|
|
# Respect the global rate-limit toggle (disabled in tests/local).
|
|
from app.config import settings
|
|
if not settings.rate_limit_enabled:
|
|
return await call_next(request)
|
|
|
|
# Only rate-limit API routes (+ non-GET sur les pages publiques)
|
|
method = request.method.upper()
|
|
limited = any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES) or (
|
|
method not in ("GET", "HEAD", "OPTIONS")
|
|
and any(path.startswith(p) for p in self.RATE_LIMITED_NON_GET_PREFIXES)
|
|
)
|
|
if not limited:
|
|
return await call_next(request)
|
|
|
|
# Exempt health check and error capture
|
|
if path in self.EXEMPT_PATHS:
|
|
return await call_next(request)
|
|
|
|
limit = self.max_requests or settings.rate_limit_requests
|
|
ip = self._client_key(request)
|
|
now = time.time()
|
|
|
|
# A33 : le store n'était jamais épuré → croissance mémoire par IP.
|
|
if len(self._store) > self._max_keys and now - self._last_prune > self.window_seconds:
|
|
self._prune(now)
|
|
|
|
window_start, count = self._store[ip]
|
|
if now - window_start > self.window_seconds:
|
|
self._store[ip] = (now, 1)
|
|
return await call_next(request)
|
|
|
|
if count >= limit:
|
|
return JSONResponse(
|
|
{"error": "Rate limit exceeded", "detail": f"Max {limit} req/min per IP"},
|
|
status_code=429,
|
|
)
|
|
|
|
self._store[ip] = (window_start, count + 1)
|
|
return await call_next(request)
|
|
|
|
def _client_key(self, request: Request) -> str:
|
|
"""A33 : X-Forwarded-For uniquement derrière un proxy local.
|
|
|
|
Le test porte sur `is_private` (Python ≥ 3.13 : toutes les plages non
|
|
globales, pas seulement RFC1918) — un pair non-global n'est pas un
|
|
internaute, donc le XFF du proxy fait foi.
|
|
ponytail: si un jour plusieurs proxies se chaînent (ou si l'app est
|
|
exposée directement), prendre la dernière adresse non privée de la
|
|
chaîne plutôt que la première.
|
|
"""
|
|
host = request.client.host if request.client else "unknown"
|
|
fwd = request.headers.get("x-forwarded-for", "")
|
|
if fwd:
|
|
try:
|
|
direct = ipaddress.ip_address(host)
|
|
if direct.is_private or direct.is_loopback:
|
|
return fwd.split(",")[0].strip() or host
|
|
except ValueError:
|
|
pass # hôte non-IP (testserver…) → on garde la clé d'origine
|
|
return host
|
|
|
|
def _prune(self, now: float) -> None:
|
|
expired = [k for k, (start, _) in self._store.items() if now - start > self.window_seconds]
|
|
for k in expired:
|
|
del self._store[k]
|
|
self._last_prune = now
|