83 lines
2.9 KiB
Python
83 lines
2.9 KiB
Python
"""Tests — nonces CSP (ROADMAP #87 T5b).
|
|
|
|
- `inject_csp_nonce` ne touche que les scripts inline exécutables
|
|
(`<script>`, `type="module"` / `type="importmap"` sans `src`), jamais les
|
|
blocs de données (`type="text/plain"`) ni les scripts externes.
|
|
- Chaque page HTML servie avec des scripts inline les porte tous avec un
|
|
nonce après injection.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
NONCE = "TESTNONCE1234567890"
|
|
|
|
|
|
def _read(name: str) -> str:
|
|
return (ROOT / "frontend" / name).read_text(encoding="utf-8")
|
|
|
|
|
|
def test_inject_only_bare_executable_scripts():
|
|
from backend.csp import inject_csp_nonce
|
|
|
|
html = (
|
|
"<script>var a = 1;</script>"
|
|
'<script type="module">import x from "y";</script>'
|
|
'<script type="importmap">{"imports": {}}</script>'
|
|
'<script type="module" src="/static/js/app.js"></script>'
|
|
'<script src="https://cdnjs.cloudflare.com/x.js"></script>'
|
|
'<script id="raw-content" type="text/plain">hello</script>'
|
|
'<script nonce="OLD">var b = 2;</script>'
|
|
)
|
|
out = inject_csp_nonce(html, NONCE)
|
|
assert out.count(f'nonce="{NONCE}"') == 3
|
|
assert '<script src="https://cdnjs.cloudflare.com/x.js">' in out
|
|
assert '<script id="raw-content" type="text/plain">' in out
|
|
assert '<script nonce="OLD">' in out
|
|
|
|
|
|
def test_new_nonce_unique_per_call():
|
|
from backend.csp import new_nonce
|
|
|
|
assert new_nonce() != new_nonce()
|
|
|
|
|
|
def test_all_pages_fully_nonced():
|
|
"""Aucun script inline exécutable sans nonce après injection (sauf src=)."""
|
|
from backend.csp import inject_csp_nonce
|
|
|
|
for name in ("index.html", "popout.html", "admin.html", "editor-poc.html", "excalidraw-editor.html"):
|
|
out = inject_csp_nonce(_read(name), NONCE)
|
|
bare = re.findall(r"<script>", out)
|
|
assert not bare, f"{name} : scripts sans nonce restants"
|
|
inline_mods = [m for m in re.findall(r'<script type="(?:module|importmap)">', out)]
|
|
assert not inline_mods, f"{name} : modules/importmap sans nonce restants"
|
|
|
|
|
|
def _nonce_of(csp: str) -> str | None:
|
|
m = re.search(r"'nonce-([^']+)'", csp or "")
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def test_nonce_header_fresh_per_response(client):
|
|
"""Chaque réponse porte un nonce frais dans `script-src`."""
|
|
r1 = client.get("/")
|
|
r2 = client.get("/")
|
|
n1, n2 = _nonce_of(r1.headers.get("content-security-policy")), _nonce_of(
|
|
r2.headers.get("content-security-policy")
|
|
)
|
|
assert n1 and n2 and n1 != n2
|
|
|
|
|
|
def test_nonce_matches_injected_html(client):
|
|
"""Le nonce de l'en-tête est celui injecté dans le HTML (`/`, excalidraw)."""
|
|
for path in ("/", "/excalidraw-editor.html"):
|
|
resp = client.get(path)
|
|
assert resp.status_code == 200, path
|
|
nonce = _nonce_of(resp.headers.get("content-security-policy"))
|
|
assert nonce, path
|
|
assert f'nonce="{nonce}"' in resp.text, path
|