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:
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user