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:
@@ -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