securite: #87 T5b nonces CSP prets pour bascule (sans changement)

This commit is contained in:
2026-09-26 22:44:12 -04:00
parent 36a4030c09
commit d70ecd0968
15 changed files with 278 additions and 85 deletions
+38 -9
View File
@@ -167,6 +167,13 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to all HTTP responses."""
async def dispatch(self, request, call_next):
from backend.csp import new_nonce
# Nonce CSP frais par réponse (#87 T5b) : injecté dans script-src et
# dans le balisage HTML par les routes (backend.csp.inject_csp_nonce).
# 'unsafe-inline' est conservé jusqu'en T5c (bascule avec validation E2E).
nonce = new_nonce()
request.state.csp_nonce = nonce
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
@@ -177,7 +184,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
if "Content-Security-Policy" not in response.headers:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
f"script-src 'self' 'unsafe-inline' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
@@ -700,6 +707,15 @@ def _get_backup_dir(vault_name: str, relative_path: str) -> Path:
# Static files & SPA fallback
# ---------------------------------------------------------------------------
def _html_with_nonce(request: Request, name: str) -> str:
"""Read a frontend HTML file and inject the per-response CSP nonce (#87 T5b)."""
from backend.csp import inject_csp_nonce
return inject_csp_nonce(
(FRONTEND_DIR / name).read_text(encoding="utf-8"),
request.state.csp_nonce,
)
if FRONTEND_DIR.exists():
# ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache).
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
@@ -732,23 +748,36 @@ if FRONTEND_DIR.exists():
raise HTTPException(status_code=404, detail="Manifest not found")
@app.get("/popout/{vault_name}/{path:path}")
async def serve_popout(vault_name: str, path: str):
async def serve_popout(request: Request, vault_name: str, path: str):
"""Serve the minimalist popout page for a specific file."""
popout_file = FRONTEND_DIR / "popout.html"
if popout_file.exists():
return HTMLResponse(content=popout_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_html_with_nonce(request, "popout.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Popout template not found")
@app.get("/editor-poc")
async def serve_editor_poc():
async def serve_editor_poc(request: Request):
"""Serve the standalone Editor POC page (multi-zone toolbar demo)."""
poc_file = FRONTEND_DIR / "editor-poc.html"
if poc_file.exists():
return HTMLResponse(content=poc_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_html_with_nonce(request, "editor-poc.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Editor POC not found")
@app.get("/excalidraw-editor.html", include_in_schema=False)
async def serve_excalidraw_editor(request: Request):
"""Serve the Excalidraw editor with CSP nonce (#87 T5b).
Remplace l'accès direct via ``/static/`` (utilisé par l'iframe du
viewer) : sans injection, les scripts inline seraient bloqués dès
le retrait de ``'unsafe-inline'`` (T5c).
"""
exca_file = FRONTEND_DIR / "excalidraw-editor.html"
if exca_file.exists():
return HTMLResponse(content=_html_with_nonce(request, "excalidraw-editor.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Excalidraw editor not found")
@app.get("/admin.html", response_class=HTMLResponse)
async def serve_admin_page(_current_user=Depends(require_admin)):
async def serve_admin_page(request: Request, _current_user=Depends(require_admin)):
"""Serve the admin dashboard page (ROADMAP #71) — admin-gated.
Must be declared BEFORE the SPA catch-all ``/{full_path:path}`` or the
@@ -757,13 +786,13 @@ if FRONTEND_DIR.exists():
"""
admin_file = FRONTEND_DIR / "admin.html"
if admin_file.exists():
return HTMLResponse(content=admin_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_html_with_nonce(request, "admin.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Admin page not found")
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
async def serve_spa(request: Request, full_path: str):
"""Serve the SPA index.html for all non-API routes."""
index_file = FRONTEND_DIR / "index.html"
if index_file.exists():
return HTMLResponse(content=index_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_html_with_nonce(request, "index.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Frontend not found")