diff --git a/app/config.py b/app/config.py index 3ad1b52..82d9521 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,10 @@ from pydantic_settings import BaseSettings, SettingsConfigDict +# Minimum JWT signing key length. HS256 keys shorter than the hash output weaken +# the MAC, and PyJWT warns about it — enforced here so it fails at startup rather +# than being shipped by accident. +MIN_JWT_SECRET_LENGTH = 32 + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") @@ -25,3 +30,40 @@ class Settings(BaseSettings): settings = Settings() + + +class ConfigError(Exception): + """A misconfiguration serious enough that the app must refuse to serve.""" + + +def validate_runtime_secrets(config: Settings | None = None) -> None: + """Fail fast on secrets that would otherwise only break at first use: an empty + jwt_secret makes PyJWT raise InvalidKeyError on every login, and an empty + xprv_encryption_key makes Fernet fail on the first key derivation. Either way + the container comes up looking healthy and breaks the moment a real user + touches it. + + Called from the app's lifespan (app/main.py) rather than as a Settings + field_validator on purpose: Settings is constructed at import time by every + module that reads config, including the test suite, which has no .env and no + business holding real secrets. At startup the guarantee still holds where it + matters — the server refuses to serve half-configured — without coupling every + import to a gitignored file. + + ADMIN_TOKEN is deliberately not fatal: require_admin already denies every + request when it's empty, so the effect is a locked admin panel, not an open one. + """ + config = config or settings + problems = [] + if len(config.jwt_secret) < MIN_JWT_SECRET_LENGTH: + problems.append( + f"JWT_SECRET must be at least {MIN_JWT_SECRET_LENGTH} characters " + '(generate: python -c "import secrets; print(secrets.token_urlsafe(32))")' + ) + if not config.xprv_encryption_key.strip(): + problems.append( + "XPRV_ENCRYPTION_KEY must be set (generate: python -c " + '"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")' + ) + if problems: + raise ConfigError("invalid configuration in .env: " + "; ".join(problems)) diff --git a/app/main.py b/app/main.py index 6165355..95449de 100644 --- a/app/main.py +++ b/app/main.py @@ -1,8 +1,9 @@ import asyncio import logging from contextlib import asynccontextmanager +from pathlib import Path -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, status from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles @@ -20,7 +21,8 @@ from app.api.routes.rounds import router as rounds_router from app.api.routes.users import router as users_router from app.api.routes.withdrawals import router as withdrawals_router from app.auth.routes import router as auth_router -from app.config import settings +from app.api.errors import ApiError, http_error +from app.config import settings, validate_runtime_secrets from app.db.base import AsyncSessionLocal from app.electrum.client import ElectrumClient from app.electrum.listener import ElectrumListener @@ -74,7 +76,10 @@ app.include_router(rounds_router) @app.exception_handler(Exception) async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse: logger.exception("Unhandled error on %s %s", request.method, request.url.path) - return JSONResponse(status_code=500, content={"detail": "internal server error"}) + return JSONResponse( + status_code=500, + content={"detail": ApiError("internal_error", "internal server error").as_detail()}, + ) @app.get("/health") @@ -99,8 +104,18 @@ async def admin_panel() -> FileResponse: async def user_guide() -> FileResponse: """Serves docs/guida-utente.md as plain text so it opens inline in the browser (no markdown rendering — keeps this simple), linked from the - navbar's help button in app/static/index.html.""" - return FileResponse("docs/guida-utente.md", media_type="text/plain; charset=utf-8") + navbar's help button in app/static/index.html. + + The Dockerfile must COPY docs/ for this to exist inside the container (it + didn't, which made this a guaranteed 500 in every real deployment — B-16). + Answers a clean 404 if the file is missing rather than an unhandled error.""" + if not _USER_GUIDE_PATH.is_file(): + logger.error("user guide missing at %s (is docs/ shipped in the image?)", _USER_GUIDE_PATH) + raise http_error(status.HTTP_404_NOT_FOUND, "guide_unavailable", "user guide not available") + return FileResponse(_USER_GUIDE_PATH, media_type="text/plain; charset=utf-8") app.mount("/", StaticFiles(directory="app/static", html=True), name="static") +_USER_GUIDE_PATH = Path("docs/guida-utente.md") + + diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..57d39c0 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,51 @@ +"""B-15: secrets that would only break at first use must stop the app at startup.""" + +import pytest + +from app.config import MIN_JWT_SECRET_LENGTH, ConfigError, Settings, validate_runtime_secrets + + +def _settings(**overrides) -> Settings: + base = { + "jwt_secret": "x" * MIN_JWT_SECRET_LENGTH, + "xprv_encryption_key": "a-fernet-key", + } + base.update(overrides) + # _env_file=None so a developer's real .env can't make this test pass or fail. + return Settings(_env_file=None, **base) + + +def test_valid_secrets_pass(): + validate_runtime_secrets(_settings()) + + +def test_empty_jwt_secret_is_refused(): + """An empty JWT_SECRET makes PyJWT raise InvalidKeyError on every single login — + a 500 with no hint about the real cause, on a container that started up healthy.""" + with pytest.raises(ConfigError, match="JWT_SECRET"): + validate_runtime_secrets(_settings(jwt_secret="")) + + +def test_short_jwt_secret_is_refused(): + with pytest.raises(ConfigError, match="JWT_SECRET"): + validate_runtime_secrets(_settings(jwt_secret="x" * (MIN_JWT_SECRET_LENGTH - 1))) + + +def test_empty_xprv_encryption_key_is_refused(): + """Without it Fernet fails on the first key derivation — i.e. the first time + anyone registers or a transaction needs signing.""" + with pytest.raises(ConfigError, match="XPRV_ENCRYPTION_KEY"): + validate_runtime_secrets(_settings(xprv_encryption_key=" ")) + + +def test_all_problems_are_reported_at_once(): + with pytest.raises(ConfigError) as exc_info: + validate_runtime_secrets(_settings(jwt_secret="", xprv_encryption_key="")) + message = str(exc_info.value) + assert "JWT_SECRET" in message and "XPRV_ENCRYPTION_KEY" in message + + +def test_an_empty_admin_token_is_not_fatal(): + """require_admin already denies every request when it's unset, so the effect is a + locked admin panel rather than an open one — no reason to refuse to boot.""" + validate_runtime_secrets(_settings(admin_token=""))