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
+41
View File
@@ -17,6 +17,28 @@ from config import Config
# line break. DOTALL so a phrase that wraps onto the next line still closes.
_BOLD_RX = re.compile(r"\*\*(.+?)\*\*", re.DOTALL)
# Punto 12. Nothing loads from outside: every library ships with the install
# (static/vendor/), because the shop floor network has no way out and a page that
# waits for a CDN there is a white screen, not a slow one.
#
# unsafe-inline the templates carry their Alpine components inline
# unsafe-eval Alpine 3 and Plotly both compile expressions at runtime
# blob: PDF.js runs its worker from a blob, Fabric exports canvases
# data: thumbnails and canvas exports are inlined
CSP = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self'; "
"img-src 'self' data: blob:; "
"connect-src 'self'; "
"worker-src 'self' blob:; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
)
def get_locale():
"""Get user's preferred language from session or Accept-Language header."""
@@ -115,6 +137,25 @@ def create_app() -> Flask:
)
return Markup(rv)
@app.after_request
def security_headers(response):
"""Same-origin only, on the pages the browser actually loads.
The policy existed on the backend, which serves the API; the HTML comes
from here and carried no policy at all. So the rule that says «nothing
from outside» was written where it could not be broken and absent where
it could.
It is also the guard that makes point 12 stay done: a CDN tag added to a
template months from now is refused by the browser here, at a desk with a
network, instead of on a shop floor that has none.
"""
response.headers.setdefault("Content-Security-Policy", CSP)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
return response
@app.context_processor
def inject_globals():
"""Inject global variables into all templates."""
+43 -1
View File
@@ -64,7 +64,49 @@ db5ff4db83e580426280e9337a58dc57d3a83784a1b03ad80914651594441d52 fonts/jetbrain
```
Verifica: `sha256sum -c` da questa cartella, oppure
`sha256sum *.js fonts/*.woff2`.
`sha256sum *.js fonts/*.woff2`. Il confronto è anche un test
(`tests/test_offline.py::test_the_shipped_libraries_are_the_ones_that_were_checked`):
una libreria sostituita sul posto tiene lo stesso nome e la stessa riga in tabella,
e l'impronta è l'unica parte che se ne accorge.
## La regola che tiene il punto chiuso
Le copie locali risolvono il problema una volta; quello che lo tiene risolto è la
**Content-Security-Policy** servita dal client Flask (`app.py`, `CSP`), che ammette
solo l'origine locale. Prima esisteva solo sul backend — cioè sulle risposte API,
non sulle pagine che il browser disegna: la regola stava scritta dove non poteva
essere infranta e mancava dove poteva.
Con la policy in vigore, un `<script src="https://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
Tailwind non sta in questa cartella: è compilato in fase di build dell'immagine
(`Dockerfile.frontend`) e il CSS finito viene servito da `static/css/tailwind.css`.
Vale però lo stesso vincolo — la versione è fissata a `tailwindcss@3.4.19`. Con
`tailwindcss@3` ogni ricostruzione poteva prendere una minor diversa e produrre un
foglio di stile diverso senza che nessuno l'avesse validato.
Nota per l'installazione on-premise: la **costruzione** dell'immagine richiede rete
(npm, apt, uv). È l'esecuzione a non richiederla. In reparto va portata l'immagine
già costruita, non il repository da compilare sul posto.
## Una sesta libreria, oggi non caricata
`static/js/barcode.js` e `templates/components/barcode_scanner.html` implementano lo
scanner QR **con la fotocamera** e si appoggiano a `html5-qrcode`, che non è mai
caricata da nessuna pagina: il componente non è incluso da nessuna parte e la
libreria non è in questa cartella. A runtime non rompe niente — è codice morto.
La lettura del barcode che l'operatore usa davvero è un campo di testo in
`measure/select_recipe.html`: il lettore hardware la digita, e non serve nessuna
libreria.
Se un giorno si accende lo scanner da fotocamera, `html5-qrcode` va portata qui
**prima**, altrimenti in reparto quel pulsante mostra «scanner_lib_not_loaded» e
basta.
## Aggiornare una libreria
@@ -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