feat: self-hosted frontend JS (offline) + docker hardening

- Vendored epub.js/pdf.js+worker/JSZip/DOMPurify in static/vendor/
- reader.html/reader.js servono i JS da /static (niente CDN, offline-ready)
- CSP: rimosse allowlist CDN, aggiunto worker-src 'self' blob:
- Test CSP aggiornati; tutti 16 verdi; verificato smoke test offline in container
This commit is contained in:
truetype committed 2026-08-29 22:32:10 +02:00
1 parent e62c50c616
commit 0f15b7958f
31 files changed
+1363 -215

No files matched your search

+2 -5
View File
@@ -1,13 +1,10 @@
import sqlite3
import os
import time
from flask import current_app
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "bookish.db")
from config import DB_PATH
def _get_db():
db = sqlite3.connect(DB_PATH)
db = sqlite3.connect(DB_PATH, timeout=30)
db.row_factory = sqlite3.Row
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA foreign_keys=ON")
+125
View File
@@ -0,0 +1,125 @@
"""
Middleware di sicurezza applicativi:
- AUTH Basic opzionale per tutte le route (eccetto healthz/static)
- Rate limit in-memory su /upload (anti disk-DoS: 1 IP = 20 upload/ora)
- Header CSP restrittivi per mitigare XSS nel reader (contenuti iniettati)
"""
import time
from threading import Lock
import hmac
from importlib import import_module
from flask import request, jsonify
def _cfg(name, default=None):
"""Legge dal modulo config a runtime: così il reload di config nelle
fixture/test non richiede re-import di questo modulo."""
return getattr(import_module("config"), name, default)
# --- Rate limiting (in-memory, ok per single-user) ---
_rate = {} # ip -> list[timestamp]
_rate_lock = Lock()
RATE_WINDOW_SECONDS = 3600 # 1 ora
# --- Rate limiting (in-memory, ok per single-user) ---
_rate = {} # ip -> list[timestamp]
_rate_lock = Lock()
RATE_WINDOW_SECONDS = 3600 # 1 ora
def _rate_limited_ip(ip, limit=0):
"""True se l'IP ha superato `limit` richieste nell'ultima ora (sliding)."""
if limit <= 0:
return False
now = time.time()
with _rate_lock:
recent = [t for t in _rate.get(ip, []) if now - t < RATE_WINDOW_SECONDS]
if len(recent) >= limit:
_rate[ip] = recent
return True
recent.append(now)
_rate[ip] = recent
return False
# ---------------------------------------------------------------------------
# CSP — mira a rendere sicuro il contenuto iniettato nel reader (file HTML).
# Tutti gli script frontend (epub.js, pdf.js, jszip, DOMPurify) sono
# self-hosted in /static/vendor → nessuna allowlist CDN necessaria.
# default-src 'self' blocca script da CDN di terze parti. L'hardening del
# contenuto iniettato sta nella SANITIZZAZIONE (DOMPurify + formatter) — la
# CSP qui dà ridondanza per i vettori che violano la whitelist.
# ---------------------------------------------------------------------------
CSP_DEFAULT = (
"default-src 'self'; "
"script-src 'self' "
"'unsafe-inline'; " # code inline per EPUB/pdf init
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; "
"font-src 'self' data:; "
"connect-src 'self'; "
"frame-src 'self' blob:; "
"worker-src 'self' blob:; " # worker instanziato da pdf.js/EPUB
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
def _apply_csp(response):
"""Inietta l'header CSP nel response se non presente."""
if not response.headers.get("Content-Security-Policy"):
response.headers["Content-Security-Policy"] = CSP_DEFAULT
return response
def register_security(app):
"""Hook pre/post request: auth, rate-limit, header hardening."""
@app.before_request
def _security_before():
auth_user = _cfg("AUTH_USERNAME")
auth_pass = _cfg("AUTH_PASSWORD")
if auth_user and auth_pass:
auth = request.authorization
# Aperti senza login: solo healthz (healthcheck Docker) e static
# (il browser ne ha bisogno per il rendering della pagina).
if not (request.path.startswith("/static")
or request.path == "/healthz"):
ok = False
if auth:
user = auth.username or ""
pwd = auth.password or ""
ok = hmac.compare_digest(user, auth_user) and \
hmac.compare_digest(pwd, auth_pass)
if not ok:
resp = jsonify({"error": "Autenticazione richiesta"})
resp.status_code = 401
resp.headers["WWW-Authenticate"] = 'Basic realm="Bookish"'
return resp
rate_limit = _cfg("RATE_LIMIT_UPLOAD", 0) or 0
if request.method == "POST" and request.path == "/upload" and rate_limit > 0:
ip = request.remote_addr or "unknown"
if _rate_limited_ip(ip, rate_limit):
return jsonify({
"error": "Troppe richieste. Riprova tra qualche minuto.",
"retry_after": RATE_WINDOW_SECONDS,
}), 429
@app.after_request
def _security_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
_apply_csp(response)
return response