Marcel Bautista-Kuljevan commited on
Commit
06aac6a
·
unverified ·
2 Parent(s): c9fc206d7b2813

Merge pull request #50 from maribakulj/claude/code-quality-audit-ACnhK

Browse files
picarones/web/security.py CHANGED
@@ -256,7 +256,42 @@ class RateLimiter:
256
  # CSP middleware
257
  # ---------------------------------------------------------------------------
258
 
259
- #: Politique CSP par défaut.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  #:
261
  #: Sprint 25 a extrait tout le JavaScript de la SPA (~1131 lignes) dans
262
  #: ``picarones/web/static/web-app.js`` — c'est la victoire concrète. Reste
@@ -266,29 +301,51 @@ class RateLimiter:
266
  #: avec l'extraction des templates pour limiter les risques de régression).
267
  #: ``style-src`` reste sur ``'unsafe-inline'`` pour les ``style="..."``
268
  #: sémantiques dans les partials (états vert/rouge/jaune).
269
- DEFAULT_CSP = (
270
  "default-src 'self'; "
271
  "script-src 'self' 'unsafe-inline'; "
272
  "style-src 'self' 'unsafe-inline'; "
273
  "img-src 'self' data: blob:; "
274
  "font-src 'self' data:; "
275
  "connect-src 'self'; "
276
- "frame-ancestors 'none'; "
277
  "base-uri 'self'; "
278
  "form-action 'self'"
279
  )
280
 
 
 
 
 
 
281
 
282
  def get_csp_policy() -> str:
283
- """Retourne la CSP à appliquer (override possible via env)."""
284
- return os.environ.get("PICARONES_CSP", DEFAULT_CSP)
 
 
 
 
 
 
 
 
 
285
 
286
 
287
  async def csp_middleware(request, call_next):
288
- """Middleware FastAPI : ajoute Content-Security-Policy + en-têtes durcis."""
 
 
 
 
 
 
 
 
289
  response = await call_next(request)
290
  response.headers.setdefault("Content-Security-Policy", get_csp_policy())
291
  response.headers.setdefault("X-Content-Type-Options", "nosniff")
292
- response.headers.setdefault("X-Frame-Options", "DENY")
 
293
  response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
294
  return response
 
256
  # CSP middleware
257
  # ---------------------------------------------------------------------------
258
 
259
+ def is_huggingface_space() -> bool:
260
+ """Vrai si l'instance tourne dans un HuggingFace Space.
261
+
262
+ HuggingFace injecte ``SPACE_ID`` (au format ``user/space``) dans
263
+ l'environnement du container — c'est le marqueur canonique
264
+ documenté par HuggingFace, présent quel que soit le SDK (Docker,
265
+ Streamlit, Gradio…). On l'utilise pour adapter automatiquement la
266
+ CSP : un Space est servi via une ``<iframe>`` côté
267
+ ``huggingface.co`` / ``*.hf.space``, donc ``frame-ancestors 'none'``
268
+ et ``X-Frame-Options: DENY`` rendent la SPA invisible (page blanche
269
+ bien que le serveur réponde).
270
+ """
271
+ return bool(os.environ.get("SPACE_ID", "").strip())
272
+
273
+
274
+ #: Origines autorisées à embarquer la SPA dans une iframe quand on tourne
275
+ #: dans un HuggingFace Space. ``huggingface.co`` est l'origine du Hub qui
276
+ #: rend la page parente, ``*.hf.space`` est le domaine où HF expose les
277
+ #: containers Space (utilisé par certains rendus directs et liens
278
+ #: partageables).
279
+ _HF_FRAME_ANCESTORS = "'self' https://huggingface.co https://*.hf.space"
280
+
281
+
282
+ def _frame_ancestors_directive() -> str:
283
+ """Retourne la directive ``frame-ancestors`` adaptée au déploiement.
284
+
285
+ - Local / institutionnel : ``'none'`` (pas d'embed possible).
286
+ - HuggingFace Space : autorise ``huggingface.co`` et ``*.hf.space``
287
+ pour que la SPA s'affiche dans l'iframe du Space sans tomber en
288
+ page blanche.
289
+ """
290
+ return f"frame-ancestors {_HF_FRAME_ANCESTORS}" if is_huggingface_space() else "frame-ancestors 'none'"
291
+
292
+
293
+ #: Politique CSP par défaut (sans la directive ``frame-ancestors``, qui est
294
+ #: composée dynamiquement par :func:`get_csp_policy` selon le déploiement).
295
  #:
296
  #: Sprint 25 a extrait tout le JavaScript de la SPA (~1131 lignes) dans
297
  #: ``picarones/web/static/web-app.js`` — c'est la victoire concrète. Reste
 
301
  #: avec l'extraction des templates pour limiter les risques de régression).
302
  #: ``style-src`` reste sur ``'unsafe-inline'`` pour les ``style="..."``
303
  #: sémantiques dans les partials (états vert/rouge/jaune).
304
+ _CSP_BASE = (
305
  "default-src 'self'; "
306
  "script-src 'self' 'unsafe-inline'; "
307
  "style-src 'self' 'unsafe-inline'; "
308
  "img-src 'self' data: blob:; "
309
  "font-src 'self' data:; "
310
  "connect-src 'self'; "
 
311
  "base-uri 'self'; "
312
  "form-action 'self'"
313
  )
314
 
315
+ #: Politique CSP complète exposée pour rétrocompatibilité (mode local
316
+ #: strict). En production HuggingFace, :func:`get_csp_policy` la
317
+ #: recompose dynamiquement avec ``frame-ancestors`` permissif.
318
+ DEFAULT_CSP = _CSP_BASE + "; frame-ancestors 'none'"
319
+
320
 
321
  def get_csp_policy() -> str:
322
+ """Retourne la CSP à appliquer (override possible via env).
323
+
324
+ Si ``PICARONES_CSP`` est défini, il prend précédence absolue —
325
+ l'admin sait ce qu'il fait. Sinon, on compose ``_CSP_BASE`` plus la
326
+ directive ``frame-ancestors`` adaptée à l'environnement détecté
327
+ (HF Space ou local).
328
+ """
329
+ override = os.environ.get("PICARONES_CSP")
330
+ if override:
331
+ return override
332
+ return f"{_CSP_BASE}; {_frame_ancestors_directive()}"
333
 
334
 
335
  async def csp_middleware(request, call_next):
336
+ """Middleware FastAPI : ajoute Content-Security-Policy + en-têtes durcis.
337
+
338
+ Sur HuggingFace Space, ``X-Frame-Options: DENY`` est sciemment omis :
339
+ ce header (priorité absolue dans les anciens navigateurs, fallback
340
+ moderne quand le navigateur ne supporte pas ``frame-ancestors``)
341
+ bloque l'iframe parente du Hub HF même si la CSP est permissive.
342
+ Le contrôle d'embed est alors entièrement délégué à
343
+ ``frame-ancestors``.
344
+ """
345
  response = await call_next(request)
346
  response.headers.setdefault("Content-Security-Policy", get_csp_policy())
347
  response.headers.setdefault("X-Content-Type-Options", "nosniff")
348
+ if not is_huggingface_space():
349
+ response.headers.setdefault("X-Frame-Options", "DENY")
350
  response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
351
  return response
tests/web/test_sprint24_security.py CHANGED
@@ -212,7 +212,9 @@ class TestCSPHeaders:
212
  from picarones.web.app import app
213
  return TestClient(app)
214
 
215
- def test_csp_header_present(self, client):
 
 
216
  r = client.get("/api/status")
217
  assert r.status_code == 200
218
  assert "Content-Security-Policy" in r.headers
@@ -220,12 +222,50 @@ class TestCSPHeaders:
220
  assert "default-src 'self'" in csp
221
  assert "frame-ancestors 'none'" in csp
222
 
223
- def test_security_headers_present(self, client):
 
224
  r = client.get("/api/status")
225
  assert r.headers.get("X-Content-Type-Options") == "nosniff"
226
  assert r.headers.get("X-Frame-Options") == "DENY"
227
  assert r.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
  # ---------------------------------------------------------------------------
231
  # 8. Public mode bloque les benchmarks LLM (intégration FastAPI)
 
212
  from picarones.web.app import app
213
  return TestClient(app)
214
 
215
+ def test_csp_header_present(self, client, monkeypatch):
216
+ # Mode local strict — pas de SPACE_ID dans l'env.
217
+ monkeypatch.delenv("SPACE_ID", raising=False)
218
  r = client.get("/api/status")
219
  assert r.status_code == 200
220
  assert "Content-Security-Policy" in r.headers
 
222
  assert "default-src 'self'" in csp
223
  assert "frame-ancestors 'none'" in csp
224
 
225
+ def test_security_headers_present(self, client, monkeypatch):
226
+ monkeypatch.delenv("SPACE_ID", raising=False)
227
  r = client.get("/api/status")
228
  assert r.headers.get("X-Content-Type-Options") == "nosniff"
229
  assert r.headers.get("X-Frame-Options") == "DENY"
230
  assert r.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
231
 
232
+ def test_csp_allows_huggingface_iframe_when_on_space(self, client, monkeypatch):
233
+ """Sur HF Space (``SPACE_ID`` défini), la CSP doit autoriser l'embed.
234
+
235
+ Garde-fou contre la régression historique « page blanche sur HF » :
236
+ ``frame-ancestors 'none'`` masquait la SPA dans l'iframe parente du
237
+ Hub HuggingFace.
238
+ """
239
+ monkeypatch.setenv("SPACE_ID", "Ma-Ri-Ba-Ku/Picarones")
240
+ r = client.get("/api/status")
241
+ csp = r.headers["Content-Security-Policy"]
242
+ assert "frame-ancestors" in csp
243
+ assert "'none'" not in csp.split("frame-ancestors")[1].split(";")[0]
244
+ assert "huggingface.co" in csp
245
+ assert "*.hf.space" in csp
246
+
247
+ def test_x_frame_options_omitted_on_huggingface_space(self, client, monkeypatch):
248
+ """Sur HF Space, ``X-Frame-Options: DENY`` doit être absent.
249
+
250
+ Ce header a priorité absolue sur ``frame-ancestors`` dans les anciens
251
+ navigateurs (et reste un fallback moderne) ; le laisser à ``DENY``
252
+ bloque l'iframe parente même avec la CSP permissive.
253
+ """
254
+ monkeypatch.setenv("SPACE_ID", "Ma-Ri-Ba-Ku/Picarones")
255
+ r = client.get("/api/status")
256
+ assert "X-Frame-Options" not in r.headers
257
+ # Les autres headers de durcissement restent intacts.
258
+ assert r.headers.get("X-Content-Type-Options") == "nosniff"
259
+ assert r.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
260
+
261
+ def test_csp_override_via_env_takes_precedence(self, client, monkeypatch):
262
+ """``PICARONES_CSP`` reste un override absolu pour l'admin."""
263
+ monkeypatch.setenv("PICARONES_CSP", "default-src 'self'; frame-ancestors 'self'")
264
+ monkeypatch.setenv("SPACE_ID", "Ma-Ri-Ba-Ku/Picarones") # ignoré
265
+ r = client.get("/api/status")
266
+ csp = r.headers["Content-Security-Policy"]
267
+ assert csp == "default-src 'self'; frame-ancestors 'self'"
268
+
269
 
270
  # ---------------------------------------------------------------------------
271
  # 8. Public mode bloque les benchmarks LLM (intégration FastAPI)