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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in new issue
Block a user