feat(offline): la policy arriva dove le pagine si aprono davvero

Punto 12. Le cinque librerie erano già state portate in casa (28ee44b): nessun
template carica più niente dalla rete, il worker di PDF.js è locale in tutti e
quattro i file che lo impostano, i font sono woff2 nel pacchetto. Verificato riga
per riga, e le impronte SHA-256 in VERSIONS.md corrispondono ancora.

Mancava però la seconda metà dell'intervento, e mancava dove conta. La
Content-Security-Policy a sola origine locale esisteva sul backend, cioè sulle
risposte API; le pagine HTML le serve il client Flask, che non mandava alcuna
policy. La regola stava scritta dove non poteva essere infranta e assente dove
poteva. Ora il client la manda su ogni risposta.

Serve meno a difendere e più a non far tornare indietro il punto: un tag verso un
CDN aggiunto fra sei mesi viene rifiutato dal browser alla scrivania, dove c'è la
rete e l'errore si legge in console, invece che in reparto dove la rete non c'è.

Tailwind era ancora agganciato a `tailwindcss@3` nel Dockerfile: stesso difetto
che il documento cita per le librerie del browser, un gradino più in basso. Fissato
a 3.4.19, che è la versione con cui l'immagine in esercizio è stata costruita.

I test non renderizzano niente: leggono i sorgenti, perché il difetto che devono
impedire si scrive in un template e non si vede finché non si stacca la rete.
Controllano anche le impronte — una libreria sostituita sul posto tiene lo stesso
nome e la stessa riga in tabella, e l'hash è l'unica parte che se ne accorge.

Annotato in VERSIONS.md che `html5-qrcode` è l'unica libreria dichiarata e mai
caricata: lo scanner da fotocamera non è incluso da nessuna pagina, il lettore che
l'operatore usa è un campo di testo. Va portata in casa prima di accenderlo.

README aggiornato a V3.0.0: novità della versione punto per punto, ruolo
Supervisor, librerie locali al posto dei CDN, stato dei test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 21:58:13 +00:00
parent 78816dfe8c
commit c4a429d952
5 changed files with 335 additions and 15 deletions
@@ -0,0 +1,174 @@
"""Point 12: the install must work on a network with no way out.
The factory floor at Tràfilo has no internet. A page that fetches a library from
a CDN there does not load slowly, it does not load: the interface never starts.
The libraries were brought in-house; this is what keeps them there. The failure
mode this guards against is not dramatic - someone adds a `<script src="https://
cdn...">` because it is the quickest way to try something, it works at their desk,
and nobody finds out until the machine is on the shop floor.
"""
import re
from pathlib import Path
import pytest
APP_ROOT = Path(__file__).resolve().parents[1]
TEMPLATES = APP_ROOT / "templates"
STATIC = APP_ROOT / "static"
# Anything loaded by the browser: src=, href=, url(), importScripts, workerSrc.
_EXTERNAL_ASSET_RX = re.compile(
r"""(?:src|href)\s*=\s*["']https?://""", re.I,
)
_EXTERNAL_URL_RX = re.compile(r"""https?://[^\s"'<>)]+""")
# Hosts a comment may legitimately mention: the check is about what gets fetched,
# not about what gets written down. Only documentation links live here.
_DOC_HOSTS = (
"tailwindcss.com", "github.com", "bugzilla.mozilla.org",
"bugs.chromium.org", "bugs.webkit.org", "www.w3.org", "schema.org",
"developer.mozilla.org",
)
def _first_party_files(suffixes):
for path in sorted(STATIC.rglob("*")):
if path.suffix in suffixes and "vendor" not in path.parts:
yield path
for path in sorted(TEMPLATES.rglob("*.html")):
yield path
def test_no_template_loads_anything_from_the_network():
"""A src= or href= pointing outside is the whole bug, in one line."""
offenders = []
for path in sorted(TEMPLATES.rglob("*.html")):
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if _EXTERNAL_ASSET_RX.search(line):
offenders.append(f"{path.relative_to(APP_ROOT)}:{n}")
assert not offenders, f"asset caricati dalla rete: {offenders}"
def test_no_first_party_script_calls_out():
"""Not only tags: a fetch() or a workerSrc reaches the network just the same."""
offenders = []
for path in _first_party_files({".js", ".css"}):
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
for url in _EXTERNAL_URL_RX.findall(line):
if any(host in url for host in _DOC_HOSTS):
continue
offenders.append(f"{path.relative_to(APP_ROOT)}:{n} {url}")
assert not offenders, f"riferimenti esterni: {offenders}"
def test_the_libraries_are_actually_in_the_box():
"""Repointing the tags without shipping the files is the same white screen."""
vendor = STATIC / "vendor"
missing = [
name for name in (
"alpinejs-3.15.12.min.js",
"plotly-2.32.0.min.js",
"pdf-3.11.174.min.js",
"pdf.worker-3.11.174.min.js",
"fabric-5.3.1.min.js",
"fonts.css",
)
if not (vendor / name).is_file()
]
assert not missing, f"librerie dichiarate ma non presenti: {missing}"
assert list((vendor / "fonts").glob("*.woff2")), "i font non sono nel pacchetto"
def test_the_pdf_worker_is_local_everywhere_it_is_set():
"""The trap of this point: the library loads from disk and its worker does not.
Everything looks right until someone opens a drawing.
"""
setters = []
for path in sorted(TEMPLATES.rglob("*.html")):
text = path.read_text(encoding="utf-8")
if "workerSrc" not in text:
continue
setters.append(path.relative_to(APP_ROOT).as_posix())
for n, line in enumerate(text.splitlines(), 1):
if "workerSrc" in line:
assert "vendor/pdf.worker" in line, (
f"{path.relative_to(APP_ROOT)}:{n} punta il worker fuori casa"
)
# Four templates render PDFs; if that number changes the new one needs the
# worker set too, and this is where you find out.
assert len(setters) == 4, f"template che impostano il worker: {setters}"
def test_the_shipped_libraries_are_the_ones_that_were_checked():
"""The fingerprints in VERSIONS.md are the record of what was validated.
A library swapped in place - a hurried patch, a bad merge, something worse -
keeps the same filename and the same version in the table. The hash is the
only part that notices.
"""
vendor = STATIC / "vendor"
recorded = dict(
(name, digest)
for digest, name in (
line.split(maxsplit=1)
for line in (
l.strip() for l in
(vendor / "VERSIONS.md").read_text(encoding="utf-8").splitlines()
)
if re.fullmatch(r"[0-9a-f]{64}\s+\S+", line)
)
)
assert recorded, "VERSIONS.md non elenca più le impronte"
import hashlib
wrong = []
for name, digest in recorded.items():
path = vendor / name
if not path.is_file():
wrong.append(f"{name}: assente")
continue
actual = hashlib.sha256(path.read_bytes()).hexdigest()
if actual != digest:
wrong.append(f"{name}: {actual} != {digest}")
assert not wrong, f"librerie diverse da quelle validate: {wrong}"
def test_the_versions_are_pinned():
"""No `3.x.x`: a library that updates itself changes an audit system without
anyone validating it. The filenames on disk carry the version."""
vendor = STATIC / "vendor"
unpinned = [
p.name for p in vendor.glob("*.js")
if not re.search(r"-\d+\.\d+\.\d+(\.min)?\.js$", p.name)
]
assert not unpinned, f"librerie senza versione nel nome: {unpinned}"
# ---------------------------------------------------------------------------
# The policy that keeps it that way
# ---------------------------------------------------------------------------
PAGES = ["/auth/login", "/"]
@pytest.mark.parametrize("path", PAGES)
def test_every_page_carries_the_policy(client, path):
resp = client.get(path, follow_redirects=False)
csp = resp.headers.get("Content-Security-Policy", "")
assert "default-src 'self'" in csp
assert "connect-src 'self'" in csp
# No CDN host may be allowed back in through the policy itself.
assert "http://" not in csp and "https://" not in csp
def test_the_policy_allows_what_the_app_actually_needs(client):
"""A policy that breaks the interface gets removed, and then it protects
nothing. These are the four allowances the libraries genuinely require."""
csp = client.get("/auth/login").headers["Content-Security-Policy"]
assert "'unsafe-eval'" in csp # Alpine 3, Plotly
assert "'unsafe-inline'" in csp # componenti Alpine nei template
assert "worker-src 'self' blob:" in csp # worker di PDF.js
assert "img-src 'self' data: blob:" in csp # canvas Fabric, miniature