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:
1 parent
e62c50c616
commit
0f15b7958f
31 files changed
+1363
-215
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user