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:
+3
-2
@@ -2492,14 +2492,15 @@ def test_dashboard_create_default_layout(client):
|
||||
|
||||
|
||||
def test_view_chart_renders(client):
|
||||
"""Chart view renders with Chart.js CDN."""
|
||||
"""Chart view renders with vendored Chart.js (A20 : plus de CDN)."""
|
||||
resp = client.post("/db/api", json={"name": "Chart DB"})
|
||||
coll_id = resp.json()["id"]
|
||||
client.post(f"/db/{coll_id}/pages/api", json={"title": "Item A", "properties": {"Count": "5"}})
|
||||
client.post(f"/db/{coll_id}/pages/api", json={"title": "Item B", "properties": {"Count": "8"}})
|
||||
resp = client.get(f"/db/{coll_id}/view/chart")
|
||||
assert resp.status_code == 200
|
||||
assert "chart.js" in resp.text
|
||||
assert "/static/js/vendor/chart.umd.js" in resp.text
|
||||
assert "cdn.jsdelivr" not in resp.text
|
||||
|
||||
|
||||
def test_view_form_renders(client):
|
||||
|
||||
@@ -270,6 +270,45 @@ def _re_search_nonce(csp: str) -> str:
|
||||
return _re.search(r"'nonce-([^']+)'", _re.search(r"script-src ([^;]*);", csp).group(1)).group(1)
|
||||
|
||||
|
||||
def test_csp_no_cdn_and_vendor(client):
|
||||
"""A20 phase 2 : plus aucun hôte CDN tiers, chart/leaflet vendorisés,
|
||||
connect-src fermé (scopé à l'hôte de la requête)."""
|
||||
import pathlib as _pathlib
|
||||
|
||||
page = None
|
||||
for url in ("/", "/dashboard", "/board", "/notes", "/settings", "/workspaces"):
|
||||
cand = client.get(url)
|
||||
if cand.status_code == 200 and "htmx-config" in cand.text:
|
||||
page = cand
|
||||
break
|
||||
assert page is not None, "aucune page base.html atteignable"
|
||||
csp = page.headers.get("content-security-policy", "")
|
||||
assert "cdn.jsdelivr" not in csp, csp
|
||||
assert "unpkg.com" not in csp, csp
|
||||
assert "fonts.googleapis.com" not in csp, csp
|
||||
assert "fonts.gstatic.com" not in csp, csp
|
||||
import re as _re
|
||||
|
||||
conn = _re.search(r"connect-src ([^;]*);", csp).group(1)
|
||||
assert conn == "'self' ws://testserver wss://testserver", conn
|
||||
assert " https:" not in conn and not conn.startswith("https:"), conn
|
||||
|
||||
# vues chart/map : références locales (aucun CDN dans collections.py)
|
||||
src = _pathlib.Path("app/routers/collections.py").read_text(encoding="utf-8")
|
||||
assert "cdn.jsdelivr" not in src and "unpkg.com" not in src
|
||||
|
||||
# assets vendor servis
|
||||
for path in (
|
||||
"/static/js/vendor/chart.umd.js",
|
||||
"/static/js/vendor/leaflet.js",
|
||||
"/static/js/vendor/leaflet.css",
|
||||
"/static/js/vendor/leaflet/images/marker-icon.png",
|
||||
):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200, (path, r.status_code)
|
||||
assert len(r.content) > 500, (path, len(r.content))
|
||||
|
||||
|
||||
def test_no_duplicate_routes():
|
||||
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
|
||||
from app.main import app
|
||||
|
||||
Reference in New Issue
Block a user