Spaces:
Sleeping
Sleeping
Merge pull request #50 from maribakulj/claude/code-quality-audit-ACnhK
Browse files- picarones/web/security.py +64 -7
- tests/web/test_sprint24_security.py +42 -2
picarones/web/security.py
CHANGED
|
@@ -256,7 +256,42 @@ class RateLimiter:
|
|
| 256 |
# CSP middleware
|
| 257 |
# ---------------------------------------------------------------------------
|
| 258 |
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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)
|