fix: A20 phase 2 — chart/leaflet vendorisés + connect-src fermé (v7.27.0)
- Vendorisation : chart.js 4.5.1 + leaflet 1.9 (leaflet.js, leaflet.css,
5 images marker/layer) vers static/js/vendor/ (déjà ignoré par eslint) ;
les 3 URL CDN des vues chart/map (collections.py) pointent en local →
la CSP n'a plus AUCUN hôte tiers dans script-src ni style-src.
- connect-src fermé : `'self' ws://{host} wss://{host}` — Host de la
requête (uvicorn rejette déjà les Host invalides) + filtrage des
caractères hors base URL. Le `https:` universel (canal d'exfil) et les
ws:/wss: tout-hôtes disparaissent. Grep négatif : 0 fetch cross-origin
côté front.
- Google Fonts : entrées CSP mortes (0 référence dans le code) retirées
de style-src/font-src.
- img-src https: CONSERVÉ volontairement (unfurls YouTube/Vimeo… + tuiles
OSM inénumérables) — ponytail: commenté dans security.py.
Tests : test_csp_no_cdn_and_vendor (CSP sans CDN/Google, connect-src
exact 'self' ws://testserver wss://testserver, 4 assets vendor 200,
source collections.py sans CDN) + test_view_chart_renders mis à jour
(chemin vendor). Suite complète 1090/1090 (1089 + 1).
Reste A20 : unsafe-eval (Alpine x-data + htmx hx-on/hx-vars = eval)
→ build @alpinejs/csp + couverture E2E des vues d'abord (même logique
que la décision A39).
suite **1090/1090** · ruff OK · docs à jour
This commit is contained in:
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.26.0",
|
||||
version="7.27.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
@@ -74,16 +75,24 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
|
||||
CSP_VALUE = (
|
||||
"default-src 'self'; "
|
||||
# ponytail: chart.js et leaflet sont chargés depuis CDN par les vues
|
||||
# chart/map de collections — l'upgrade est de les vendoriser dans
|
||||
# /static/js puis de retirer ces deux hôtes.
|
||||
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}' "
|
||||
"https://cdn.jsdelivr.net https://unpkg.com; "
|
||||
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
|
||||
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
|
||||
# `unsafe-eval` reste : Alpine (x-data) + htmx (hx-on/hx-vars) en
|
||||
# ont besoin → retrait avec le build CSP d'Alpine (reste d'A20).
|
||||
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
|
||||
"script-src-attr 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com; "
|
||||
# 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: https://fonts.gstatic.com; "
|
||||
"connect-src 'self' https: wss: ws:; "
|
||||
"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'; "
|
||||
@@ -101,7 +110,13 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
# Only set CSP on HTML responses
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "text/html" in content_type:
|
||||
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(nonce=nonce)
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -2292,7 +2292,7 @@ def _render_chart(view_type: str, collection: dict, pages: list[dict], config: d
|
||||
canvas{{max-height:400px}}
|
||||
</style>
|
||||
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<script src="/static/js/vendor/chart.umd.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
new Chart(document.getElementById('chartCanvas'), {{
|
||||
type: '{chart_type}',
|
||||
@@ -2404,9 +2404,9 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
|
||||
<style>
|
||||
#map{{height:400px;border-radius:8px}}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="/static/js/vendor/leaflet.css" />
|
||||
<div id="map"></div>
|
||||
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
|
||||
<script src="/static/js/vendor/leaflet.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
|
||||
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
|
||||
|
||||
Reference in New Issue
Block a user