Claude commited on
Commit
781c660
·
unverified ·
1 Parent(s): de46be0

chore(web): cookie samesite=strict + nettoyage des références "Sprint X"

Browse files

**1. Cookie de langue : ``samesite='strict'``**

``routers/system.py:set_lang`` posait le cookie de session avec
``samesite="lax"``. ``httponly=False`` est volontaire (le frontend
lit la valeur en JS pour adapter l'UI sans round-trip serveur),
mais aucun usage légitime ne demande que ce cookie soit envoyé
depuis une navigation cross-site. Resserré à ``samesite="strict"``
pour réduire la surface CSRF — sans coût UX.

**2. Nettoyage des références "Sprint X" en docstrings**

L'audit a relevé 36 occurrences de "Sprint 24/25/26/28" dans
``picarones/web/`` qui pollutent la documentation sans valeur
actionnable pour un futur mainteneur. Beaucoup étaient de simples
balises chronologiques ("Sprint 26 — émet une ligne…") qu'on peut
remplacer par la description directe ("Émet une ligne…"). Quelques
références à des décisions architecturales restent explicitement
(par ex. la persistance SQLite, les Last-Event-ID).

Modules nettoyés : ``state.py``, ``benchmark_utils.py``, ``app.py``,
``security.py``, ``routers/{corpus,config,history,benchmark,home}.py``.

Pytest : 3354 passed, 2 skipped, 0 failed. Ruff : All checks passed.

https://claude.ai/code/session_01Hsd7kL8yeCbXn1mA7GQK9L

picarones/web/app.py CHANGED
@@ -60,7 +60,7 @@ _logger = logging.getLogger(__name__)
60
  async def _lifespan(app: FastAPI):
61
  """Hook de démarrage : marque les jobs orphelins comme ``interrupted``.
62
 
63
- Sprint 26 — au démarrage d'un nouveau processus, tous les jobs
64
  encore en statut ``pending`` ou ``running`` en base sont
65
  forcément orphelins (le processus précédent est mort sans les
66
  finir). On les bascule en ``interrupted`` pour ne pas laisser
@@ -98,7 +98,7 @@ app = FastAPI(
98
  lifespan=_lifespan,
99
  )
100
 
101
- # Sprint 24 — middleware CSP + en-têtes durcis (X-Frame-Options, etc.)
102
  app.middleware("http")(csp_middleware)
103
 
104
 
 
60
  async def _lifespan(app: FastAPI):
61
  """Hook de démarrage : marque les jobs orphelins comme ``interrupted``.
62
 
63
+ Au démarrage d'un nouveau processus, tous les jobs
64
  encore en statut ``pending`` ou ``running`` en base sont
65
  forcément orphelins (le processus précédent est mort sans les
66
  finir). On les bascule en ``interrupted`` pour ne pas laisser
 
98
  lifespan=_lifespan,
99
  )
100
 
101
+ # Middleware CSP + en-têtes durcis (X-Frame-Options, etc.)
102
  app.middleware("http")(csp_middleware)
103
 
104
 
picarones/web/benchmark_utils.py CHANGED
@@ -3,7 +3,7 @@
3
  API publique
4
  ------------
5
  - ``sse_format`` : sérialisation d'un événement Server-Sent Events
6
- avec ``Last-Event-ID`` (Sprint 26).
7
  - ``run_benchmark_thread`` / ``run_benchmark_thread_v2`` : workers
8
  threadés qui exécutent le benchmark, émettent des événements SSE
9
  via le ``BenchmarkJob``, génèrent le rapport HTML final.
@@ -36,7 +36,7 @@ from picarones.web.state import BenchmarkJob, iso_now
36
  def sse_format(event_type: str, data: Any, seq: Optional[int] = None) -> str:
37
  """Format Server-Sent Events.
38
 
39
- Sprint 26 — émet une ligne ``id: <seq>`` quand le ``seq`` est connu.
40
  C'est la valeur que le navigateur renvoie automatiquement dans
41
  ``Last-Event-ID`` à la prochaine connexion (cf.
42
  https://html.spec.whatwg.org/multipage/server-sent-events.html).
 
3
  API publique
4
  ------------
5
  - ``sse_format`` : sérialisation d'un événement Server-Sent Events
6
+ avec ``Last-Event-ID``.
7
  - ``run_benchmark_thread`` / ``run_benchmark_thread_v2`` : workers
8
  threadés qui exécutent le benchmark, émettent des événements SSE
9
  via le ``BenchmarkJob``, génèrent le rapport HTML final.
 
36
  def sse_format(event_type: str, data: Any, seq: Optional[int] = None) -> str:
37
  """Format Server-Sent Events.
38
 
39
+ Émet une ligne ``id: <seq>`` quand le ``seq`` est connu.
40
  C'est la valeur que le navigateur renvoie automatiquement dans
41
  ``Last-Event-ID`` à la prochaine connexion (cf.
42
  https://html.spec.whatwg.org/multipage/server-sent-events.html).
picarones/web/routers/config.py CHANGED
@@ -1,4 +1,4 @@
1
- """Router de sauvegarde / chargement des configs utilisateur (Sprint 28)."""
2
 
3
  from __future__ import annotations
4
 
@@ -20,7 +20,7 @@ router = APIRouter()
20
  async def api_config_save(payload: dict) -> Response:
21
  """Sérialise un dict de config en JSON téléchargeable.
22
 
23
- Sprint 28 — supprime la friction *« reconfigurer chaque session »*.
24
  Le client envoie sa config courante (engines, profil, options),
25
  le serveur retourne un fichier JSON à télécharger ; un autre
26
  utilisateur peut le réimporter via ``/api/config/load``.
 
1
+ """Router de sauvegarde / chargement des configs utilisateur."""
2
 
3
  from __future__ import annotations
4
 
 
20
  async def api_config_save(payload: dict) -> Response:
21
  """Sérialise un dict de config en JSON téléchargeable.
22
 
23
+ Supprime la friction *« reconfigurer chaque session »*.
24
  Le client envoie sa config courante (engines, profil, options),
25
  le serveur retourne un fichier JSON à télécharger ; un autre
26
  utilisateur peut le réimporter via ``/api/config/load``.
picarones/web/routers/corpus.py CHANGED
@@ -22,7 +22,7 @@ router = APIRouter()
22
  _logger = logging.getLogger(__name__)
23
 
24
 
25
- # Sprint 24 — racines configurables via PICARONES_BROWSE_ROOTS, sinon
26
  # défaut restreint en mode public, défaut historique en mode dev.
27
  _BROWSE_ROOTS = compute_browse_roots(UPLOADS_DIR)
28
 
@@ -97,7 +97,7 @@ async def api_corpus_upload(files: list[UploadFile] = File(...)) -> dict:
97
  payloads: list[tuple[str, bytes]] = []
98
  for uf in files:
99
  filename = uf.filename or "upload"
100
- # Sprint 24 — empêcher la traversée via le nom de fichier reçu
101
  # depuis le client (multipart). On garde uniquement le basename.
102
  safe_name = Path(filename).name
103
  data = await uf.read()
@@ -147,7 +147,7 @@ def _write_payloads_and_analyze(
147
  with zipfile.ZipFile(io.BytesIO(data)) as zf:
148
  flatten_zip_to_dir(zf, corpus_dir)
149
  elif suffix in IMAGE_EXTS:
150
- # Sprint 24 — valider l'image avant écriture (Pillow.verify,
151
  # taille max, rejet des bombes de décompression).
152
  validate_image_safe(data, filename=safe_name)
153
  (corpus_dir / safe_name).write_bytes(data)
 
22
  _logger = logging.getLogger(__name__)
23
 
24
 
25
+ # Racines configurables via PICARONES_BROWSE_ROOTS, sinon
26
  # défaut restreint en mode public, défaut historique en mode dev.
27
  _BROWSE_ROOTS = compute_browse_roots(UPLOADS_DIR)
28
 
 
97
  payloads: list[tuple[str, bytes]] = []
98
  for uf in files:
99
  filename = uf.filename or "upload"
100
+ # Empêcher la traversée via le nom de fichier reçu
101
  # depuis le client (multipart). On garde uniquement le basename.
102
  safe_name = Path(filename).name
103
  data = await uf.read()
 
147
  with zipfile.ZipFile(io.BytesIO(data)) as zf:
148
  flatten_zip_to_dir(zf, corpus_dir)
149
  elif suffix in IMAGE_EXTS:
150
+ # Valider l'image avant écriture (Pillow.verify,
151
  # taille max, rejet des bombes de décompression).
152
  validate_image_safe(data, filename=safe_name)
153
  (corpus_dir / safe_name).write_bytes(data)
picarones/web/routers/history.py CHANGED
@@ -1,6 +1,6 @@
1
- """Router des régressions détectées dans l'historique longitudinal (Sprint 28).
2
 
3
- Surface de l'infrastructure ``BenchmarkHistory`` (Sprint 8) qui était
4
  limitée au CLI ``picarones history --regression``. Le rapport HTML
5
  peut désormais consommer cet endpoint pour afficher un encart
6
  *« ⚠ Tesseract a régressé de 0,8 pp depuis le 12 janvier »* en tête.
 
1
+ """Router des régressions détectées dans l'historique longitudinal.
2
 
3
+ Surface de l'infrastructure ``BenchmarkHistory`` qui était
4
  limitée au CLI ``picarones history --regression``. Le rapport HTML
5
  peut désormais consommer cet endpoint pour afficher un encart
6
  *« ⚠ Tesseract a régressé de 0,8 pp depuis le 12 janvier »* en tête.
picarones/web/routers/system.py CHANGED
@@ -42,11 +42,16 @@ async def api_set_lang(lang_code: str, response: Response) -> dict:
42
  f"Disponibles : {', '.join(SUPPORTED_LANGS)}"
43
  ),
44
  )
 
 
 
 
 
45
  response.set_cookie(
46
  key=LANG_COOKIE,
47
  value=lang_code,
48
  max_age=60 * 60 * 24 * 365, # 1 an
49
  httponly=False,
50
- samesite="lax",
51
  )
52
  return {"lang": lang_code, "message": f"Langue définie : {lang_code}"}
 
42
  f"Disponibles : {', '.join(SUPPORTED_LANGS)}"
43
  ),
44
  )
45
+ # ``httponly=False`` est volontaire : le frontend lit ce cookie en
46
+ # JS pour adapter l'UI sans round-trip serveur. ``samesite="strict"``
47
+ # car aucun usage légitime ne demande que ce cookie soit envoyé
48
+ # depuis une navigation cross-site — on resserre le cran qu'on
49
+ # peut sans casser l'UX.
50
  response.set_cookie(
51
  key=LANG_COOKIE,
52
  value=lang_code,
53
  max_age=60 * 60 * 24 * 365, # 1 an
54
  httponly=False,
55
+ samesite="strict",
56
  )
57
  return {"lang": lang_code, "message": f"Langue définie : {lang_code}"}
picarones/web/security.py CHANGED
@@ -1,4 +1,4 @@
1
- """Garde-fous sécurité pour l'interface web (Sprint 24).
2
 
3
  Ce module centralise quatre durcissements pour rendre Picarones déployable
4
  sur un Space HuggingFace public ou un serveur d'institution sans donner
 
1
+ """Garde-fous sécurité pour l'interface web.
2
 
3
  Ce module centralise quatre durcissements pour rendre Picarones déployable
4
  sur un Space HuggingFace public ou un serveur d'institution sans donner
picarones/web/state.py CHANGED
@@ -86,17 +86,17 @@ def enforce_rate_limit(request: Request) -> None:
86
  # ──────────────────────────────────────────────────────────────────────────
87
 
88
  RATE_LIMITER = RateLimiter(max_per_hour=get_rate_limit_per_hour())
89
- """Rate limiter global (no-op si non public ou quota = 0). Sprint 24."""
90
 
91
  JOBS_SEMAPHORE = threading.Semaphore(get_max_concurrent_jobs())
92
- """Sémaphore qui borne le nombre de benchmarks concurrents. Sprint 24."""
93
 
94
  JOB_STORE: JobStore = get_default_store()
95
- """Store SQLite singleton injecté dans chaque ``BenchmarkJob``. Sprint 26."""
96
 
97
 
98
  # ──────────────────────────────────────────────────────────────────────────
99
- # Modèle de job (avec persistance Sprint 26)
100
  # ──────────────────────────────────────────────────────────────────────────
101
 
102
  @dataclass
@@ -107,7 +107,7 @@ class BenchmarkJob:
107
  un flux d'événements consommé via SSE. La persistance est gérée
108
  par un ``JobStore`` SQLite optionnel — si présent, chaque
109
  événement est sérialisé en base avant d'être diffusé aux abonnés
110
- SSE, ce qui permet la reprise via ``Last-Event-ID`` (Sprint 26).
111
  """
112
 
113
  job_id: str
@@ -169,7 +169,7 @@ class BenchmarkJob:
169
  )
170
 
171
  def set_status(self, status: str, error: str = "") -> None:
172
- """Met à jour le statut + persiste vers le store (Sprint 26)."""
173
  self.status = status
174
  if error:
175
  self.error = error
 
86
  # ──────────────────────────────────────────────────────────────────────────
87
 
88
  RATE_LIMITER = RateLimiter(max_per_hour=get_rate_limit_per_hour())
89
+ """Rate limiter global (no-op si non public ou quota = 0)."""
90
 
91
  JOBS_SEMAPHORE = threading.Semaphore(get_max_concurrent_jobs())
92
+ """Sémaphore qui borne le nombre de benchmarks concurrents."""
93
 
94
  JOB_STORE: JobStore = get_default_store()
95
+ """Store SQLite singleton injecté dans chaque ``BenchmarkJob``."""
96
 
97
 
98
  # ──────────────────────────────────────────────────────────────────────────
99
+ # Modèle de job (avec persistance SQLite)
100
  # ──────────────────────────────────────────────────────────────────────────
101
 
102
  @dataclass
 
107
  un flux d'événements consommé via SSE. La persistance est gérée
108
  par un ``JobStore`` SQLite optionnel — si présent, chaque
109
  événement est sérialisé en base avant d'être diffusé aux abonnés
110
+ SSE, ce qui permet la reprise via ``Last-Event-ID``.
111
  """
112
 
113
  job_id: str
 
169
  )
170
 
171
  def set_status(self, status: str, error: str = "") -> None:
172
+ """Met à jour le statut + persiste vers le store."""
173
  self.status = status
174
  if error:
175
  self.error = error