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."""