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

+31
View File
@@ -0,0 +1,31 @@
"""Configurazione pytest comune: env PRIMA dell'import di config.py.
config.py alza RuntimeError se BOOKISH_DEBUG=false senza BOOKISH_SECRET:
per i test usiamo sempre DEBUG + secret di test.
Per isolare i test dal volume reale, BOOKISH_DATA_DIR punta a una tmp
dedicata, impostata qui così config.py la legge all'import.
"""
import os
import tempfile
import pytest
_DATA_TMP = tempfile.mkdtemp(prefix="bookish-test-")
os.environ["BOOKISH_DEBUG"] = "true"
os.environ["BOOKISH_SECRET"] = "test-secret-dummy"
os.environ["BOOKISH_CLEANUP_HOURS"] = "0"
os.environ["BOOKISH_DATA_DIR"] = _DATA_TMP
# Rate limit disattivato di default nei test: i test lo abilitano
# esplicitamente quando vogliono verificarlo (evita budget esauriti).
os.environ["BOOKISH_RATE_LIMIT_UPLOAD"] = "0"
@pytest.fixture(autouse=True)
def _reset_rate_state():
"""Pulisce lo stato globale del rate-limit tra i test."""
import bookish.services.security as sec
with sec._rate_lock:
sec._rate.clear()
yield
+152
View File
@@ -0,0 +1,152 @@
"""Test per R2 (auth Basic + rate limit) e R3 (CSP + sanitizzazione).
L'auth e il rate-limit leggono le var d'ambiente ad import di config.py:
per testarli ricreiamo le env e facciamo reload del modulo config prima
di creare l'app.
"""
import base64
import importlib
import io
import pytest
from werkzeug.datastructures import FileStorage
import config
from bookish import create_app
@pytest.fixture()
def app(monkeypatch):
monkeypatch.setenv("BOOKISH_DEBUG", "true")
monkeypatch.setenv("BOOKISH_SECRET", "test-secret")
monkeypatch.setenv("BOOKISH_RATE_LIMIT_UPLOAD", "1000")
monkeypatch.setenv("BOOKISH_AUTH_USERNAME", "")
monkeypatch.setenv("BOOKISH_AUTH_PASSWORD", "")
importlib.reload(config)
app = create_app()
app.config["TESTING"] = True
yield app
@pytest.fixture()
def client(app):
return app.test_client()
def _app_with_env(monkeypatch, **env):
monkeypatch.setenv("BOOKISH_DEBUG", "true")
monkeypatch.setenv("BOOKISH_SECRET", "test-secret")
for k, v in env.items():
monkeypatch.setenv(k, v)
importlib.reload(config)
app = create_app()
app.config["TESTING"] = True
return app
def _basic_auth_header(user, pwd):
token = base64.b64encode(f"{user}:{pwd}".encode()).decode()
return {"Authorization": f"Basic {token}"}
# ---------------------------------------------------------------------------
# R3 — CSP e header di sicurezza
# ---------------------------------------------------------------------------
def test_security_headers_on_page(client):
resp = client.get("/")
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
assert resp.headers.get("Referrer-Policy") == "no-referrer"
assert resp.headers.get("X-Frame-Options") == "SAMEORIGIN"
csp = resp.headers.get("Content-Security-Policy", "")
assert "default-src 'self'" in csp
assert "object-src 'none'" in csp
# tutti i JS del reader sono self-hosted → nessun CDN permesso
assert "cdnjs.cloudflare.com" not in csp
assert "cdn.jsdelivr.net" not in csp
assert "worker-src 'self'" in csp
def test_csp_header_on_api(client):
resp = client.get("/api/bookmarks/xyz")
assert resp.headers.get("Content-Security-Policy", "") != ""
def test_dompurify_vendored():
"""DOMPurify server-only per CSP + offline."""
import os
p = os.path.join(os.path.dirname(os.path.dirname(__file__)),
"bookish", "static", "vendor", "purify.min.js")
assert os.path.isfile(p), "vendor/purify.min.js mancante"
# ---------------------------------------------------------------------------
# R2 — Autenticazione Basic
# ---------------------------------------------------------------------------
def test_auth_required_without_creds(monkeypatch):
app_ = _app_with_env(monkeypatch, BOOKISH_AUTH_USERNAME="admin", BOOKISH_AUTH_PASSWORD="secret")
c = app_.test_client()
resp = c.get("/")
assert resp.status_code == 401
assert resp.headers.get("WWW-Authenticate", "").startswith("Basic")
def test_auth_allowed_with_creds(monkeypatch):
app_ = _app_with_env(monkeypatch, BOOKISH_AUTH_USERNAME="admin", BOOKISH_AUTH_PASSWORD="secret")
c = app_.test_client()
resp = c.get("/", headers=_basic_auth_header("admin", "secret"))
assert resp.status_code == 200
def test_auth_rejects_wrong_password(monkeypatch):
app_ = _app_with_env(monkeypatch, BOOKISH_AUTH_USERNAME="admin", BOOKISH_AUTH_PASSWORD="secret")
c = app_.test_client()
resp = c.get("/", headers=_basic_auth_header("admin", "wrong"))
assert resp.status_code == 401
def test_auth_healthz_open(monkeypatch):
"""healthz resta pubblico anche con auth (per l'healthcheck Docker)."""
app_ = _app_with_env(monkeypatch, BOOKISH_AUTH_USERNAME="admin", BOOKISH_AUTH_PASSWORD="secret")
c = app_.test_client()
resp = c.get("/healthz")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# R2 — rate limit su /upload
# ---------------------------------------------------------------------------
def test_rate_limit_blocks_after_threshold(monkeypatch):
app_ = _app_with_env(monkeypatch, BOOKISH_RATE_LIMIT_UPLOAD="3")
c = app_.test_client()
fs = lambda: FileStorage(stream=io.BytesIO(b"abc"), filename="t.txt", content_type="text/plain")
codes = []
for _ in range(4):
r = c.post("/upload", data={"file": fs(), "output_format": "md"},
content_type="multipart/form-data")
codes.append(r.status_code)
assert codes[:3] == [200, 200, 200]
assert codes[-1] == 429
def test_rate_limit_disabled_if_zero(monkeypatch):
app_ = _app_with_env(monkeypatch, BOOKISH_RATE_LIMIT_UPLOAD="0")
c = app_.test_client()
fs = lambda: FileStorage(stream=io.BytesIO(b"x"), filename="x.txt", content_type="text/plain")
codes = [c.post("/upload", data={"file": fs(), "output_format": "md"},
content_type="multipart/form-data").status_code for _ in range(3)]
assert codes == [200, 200, 200]
def test_rate_limit_in_response_body(monkeypatch):
app_ = _app_with_env(monkeypatch, BOOKISH_RATE_LIMIT_UPLOAD="1")
c = app_.test_client()
fs = lambda: FileStorage(stream=io.BytesIO(b"x"), filename="x.txt", content_type="text/plain")
r1 = c.post("/upload", data={"file": fs(), "output_format": "md"}, content_type="multipart/form-data")
r2 = c.post("/upload", data={"file": fs(), "output_format": "md"}, content_type="multipart/form-data")
assert r1.status_code == 200
assert r2.status_code == 429
assert "retry_after" in r2.get_json()
+128
View File
@@ -0,0 +1,128 @@
"""Test focali sul bug critico R1: naming/storage dei file.
Il flusso reale:
upload → <UPLOAD_FOLDER>/<uuid>_<filename> (con prefisso uuid)
converted → <CONVERTED_FOLDER>/<uuid>.<ext> (senza filename)
il reader cerca:
reader.py → UPLOAD_FOLDER/<filename> poi CONVERTED_FOLDER/<filename>
senza sapere del prefisso uuid → 404.
Questi test documentano il comportamento attuale (i test rossi = bug
confermato) e la direzione del fix (il reader deve trovare i file per il
loro stored_filename / id, non per il nome originale).
Qui non viene lanciata alcuna conversione reale (niente Pandoc/WeasyPrint):
si verifica solo naming e risoluzione dei path.
Run:
.venv/bin/python -m pytest tests/test_storage_paths.py -v
"""
import io
import uuid
from pathlib import Path
import pytest
from werkzeug.datastructures import FileStorage
from bookish import create_app
EPUB_BYTES = b"PK\x03\x04 fake-content (questi test non aprono il file)"
@pytest.fixture()
def app(tmp_path):
"""App Flask, eventualmente con DATA_DIR sovrascritto per il singolo test."""
app = create_app()
app.config["TESTING"] = True
# override i folder su una tmp dedicata a questo test
app.config["UPLOAD_FOLDER"] = str(tmp_path / "uploads")
app.config["CONVERTED_FOLDER"] = str(tmp_path / "converted")
Path(app.config["UPLOAD_FOLDER"]).mkdir(parents=True, exist_ok=True)
Path(app.config["CONVERTED_FOLDER"]).mkdir(parents=True, exist_ok=True)
yield app
@pytest.fixture()
def client(app):
return app.test_client()
def _upload_epub(client):
file_storage = FileStorage(
stream=io.BytesIO(EPUB_BYTES),
filename="libro_test.epub",
content_type="application/epub+zip",
)
resp = client.post(
"/upload",
data={"file": file_storage, "output_format": "epub"},
content_type="multipart/form-data",
)
assert resp.status_code == 200, resp.text
return resp.get_json()
def test_upload_file_present_on_disk_with_uuid_prefix(client, app):
"""R1a — l'upload viene salvato con prefisso uuid (conferma del naming)."""
data = _upload_epub(client)
stored = data["stored_filename"]
assert not stored.startswith("libro_test.epub")
assert stored.endswith(".epub")
assert Path(app.config["UPLOAD_FOLDER"], stored).is_file()
def test_reader_serve_resolves_uploaded_id(client, app):
"""/reader/serve/<stored_filename> deve trovare il file."""
data = _upload_epub(client)
resp = client.get(f"/reader/serve/{data['stored_filename']}")
assert resp.status_code == 200, resp.status_code
def test_reader_page_resolves_uploaded_id(client, app):
"""/reader/<stored_filename> deve rendere la pagina reader."""
data = _upload_epub(client)
resp = client.get(f"/reader/{data['stored_filename']}")
assert resp.status_code == 200, resp.status_code
def test_reader_serve_bare_original_name_resolved(client, app):
"""Dopo il fix: il reader risolve anche il nome originale di un file
convertito (link da localStorage/recenti). Qui l'upload vive in
UPLOAD_FOLDER con prefisso uuid → col fix troviamo comunque il file."""
data = _upload_epub(client)
bare = data["stored_filename"].split("_", 1)[-1] # libro_test.epub
resp = client.get(f"/reader/serve/{bare}")
assert resp.status_code == 200, resp.status_code
def test_reader_serve_converted_by_bare_name_not_possible(client, app):
"""NOTA: un file convertito (<uuid>.ext) NON contiene il nome originale:
senza un mapping esplicito non è risolvibile dal bare name, e va bene.
La UI usa sempre l'ID (stored filename) per i convertiti. Questo test
fissa il comportamento a 404 per evitare regressioni/false promesse."""
conv_dir = Path(app.config["CONVERTED_FOLDER"])
conv_dir.mkdir(parents=True, exist_ok=True)
out_name = f"{uuid.uuid4().hex}.epub"
(conv_dir / out_name).write_bytes(EPUB_BYTES)
resp = client.get("/reader/serve/libro_test.epub")
assert resp.status_code == 404, resp.status_code
def test_reader_resolves_converted_by_id(client, app):
"""R1b — un file convertito <uuid>.ext in CONVERTED_FOLDER viene
risolto dal reader per il suo ID (stored name), non col nome originale."""
conv_dir = Path(app.config["CONVERTED_FOLDER"])
conv_dir.mkdir(parents=True, exist_ok=True)
out_name = f"{uuid.uuid4().hex}.epub"
(conv_dir / out_name).write_bytes(EPUB_BYTES)
resp = client.get(f"/reader/serve/{out_name}")
assert resp.status_code == 200, resp.status_code
resp = client.get(f"/reader/{out_name}")
assert resp.status_code == 200, resp.status_code