From 24e5fc3d8b329e161a97887ac4250f98754922d7 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 18 Oct 2026 00:59:25 +0200 Subject: [PATCH] Bound credentials, brake password changes, cap bug reports, page the user list, structure every error (B-94, SEC-54, SEC-55, API-09, API-15, API-16, API-19, API-21, API-28, API-33, REG-12, AUD-42) - login and password-change fields: at most 256 characters - wrong current password: per-user brake (5 free, 2s doubling to 300s), 429 even for the right password while locked; password_changed audited in the same transaction - POST /bug-reports: 10 per IP per hour, consumed only by a stored report - GET /admin/users: limit/after_id paging, the panel walks the pages - 401/403/404/405/422/503 all answer {code, message, params}; the 422 omits the input - static files revalidated on every use (Cache-Control: no-cache) - error.* keys for the new codes in all seven languages Co-Authored-By: Claude Sonnet 5.5 --- BUGS.md | 12 +- CLAUDE.md | 4 +- README.md | 2 +- app/api/routes/admin.py | 25 ++- app/api/routes/bug_reports.py | 13 +- app/api/routes/rounds.py | 4 +- app/api/routes/users.py | 19 +- app/auth/rate_limit.py | 7 + app/auth/routes.py | 6 +- app/main.py | 36 +++- app/static/admin.js | 13 +- app/static/app.js | 2 +- app/static/i18n.js | 49 +++++ ...azione-tentativi-su-login-registrazione.md | 10 +- ...ontend-statici-senza-framework-ne-build.md | 5 +- docs/api.md | 33 ++-- docs/architettura.md | 1 - docs/audit-log.md | 3 +- docs/limiti-noti.md | 30 +-- docs/registrazione-e-depositi.md | 2 - docs/sicurezza.md | 18 +- docs/storico-bug.md | 8 +- tests/unit/test_abuse_limits.py | 183 ++++++++++++++++++ 23 files changed, 374 insertions(+), 111 deletions(-) create mode 100644 tests/unit/test_abuse_limits.py diff --git a/BUGS.md b/BUGS.md index f7238da..a3ad75d 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,7 +1,7 @@ # BUGS.md — open findings Only the 2026-09-28 specification review (B-73 … B-100) still has open entries -(B-92, B-93, B-94, B-96). A finding is removed from this file once it is fixed, and +(B-92, B-93, B-96). A finding is removed from this file once it is fixed, and `git log --all --grep 'B-nn'` is the record of how it was closed. Per CLAUDE.md's convention each entry gets its own commit with its own regression test, and the `B-nn` marker goes in a comment next to the fix. @@ -57,16 +57,6 @@ Fix: close SSE streams on shutdown, `stop_grace_period: 30s`, await task cancellation; tag images per release; `SITE_ADDRESS` in `.env` with `${SITE_ADDRESS:?}`; documented pre-deploy backup and downgrade procedure. -### B-94 — Account and endpoint abuse limits - -`change-password` has no rate limit, no audit and no maximum length; login fields -have no length bounds; `POST /bug-reports` is anonymous and unlimited; -`GET /admin/users` has no pagination; admin, SSE and framework errors are bare -strings; static JS/CSS have no `Cache-Control`. - -Fix: throttle and audit (`password_changed`) the password change, cap every -credential field at 256, rate-limit bug reports per IP, paginate the user list, -structured errors everywhere, `Cache-Control: no-cache` on static files. ### B-96 — Smaller state inconsistencies diff --git a/CLAUDE.md b/CLAUDE.md index ff4c8bb..aa112fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin ## Project status -All 10 stages of the original build order are code-complete and unit-tested — 728 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. +All 10 stages of the original build order are code-complete and unit-tested — 738 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only. @@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace) PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip -python -m pytest # all 728 tests +python -m pytest # all 738 tests python -m pytest tests/unit/test_hd.py # one file python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test ``` diff --git a/README.md b/README.md index f79870c..df3caf1 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ python -m pytest # all tests python -m pytest tests/unit/test_hd.py # one file ``` -728 unit tests cover HD derivation, PSBT building, the Electrum client, bets, +738 unit tests cover HD derivation, PSBT building, the Electrum client, bets, deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config, the pending-inclusive balance calculation, and the SSE push channel. No automated integration tests against a live Electrum connection — mainnet diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 6628c1e..0424bb3 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -1,7 +1,7 @@ import json import secrets -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from fastapi import APIRouter, Depends, Header, Query, status from pydantic import BaseModel, Field, field_validator from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -28,11 +28,11 @@ async def require_admin(x_admin_token: str = Header(default="")) -> None: # on two empty strings returns True and would otherwise open the panel to # anyone on an instance that never configured a token. if not settings.admin_token: - raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token") + raise http_error(status.HTTP_403_FORBIDDEN, "invalid_admin_token", "invalid admin token") # compare_digest raises TypeError on a str containing non-ASCII characters # (B-46) -- comparing the UTF-8 bytes instead accepts any input safely. if not secrets.compare_digest(x_admin_token.encode(), settings.admin_token.encode()): - raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token") + raise http_error(status.HTTP_403_FORBIDDEN, "invalid_admin_token", "invalid admin token") # `paused` is deliberately NOT here: it has its own audit-logged endpoints @@ -226,8 +226,17 @@ class AdminUserResponse(BaseModel): @router.get("/users", response_model=list[AdminUserResponse], dependencies=[Depends(require_admin)]) -async def list_users(session: AsyncSession = Depends(get_session)) -> list[AdminUserResponse]: - users = (await session.scalars(select(User).order_by(User.id))).all() +async def list_users( + limit: int = Query(default=100, ge=1, le=500), + after_id: int | None = Query(default=None, ge=0), + session: AsyncSession = Depends(get_session), +) -> list[AdminUserResponse]: + """B-94 (API-33): by ascending id, a page at a time; the next page asks for `after_id` equal + to the last id received, and a list shorter than `limit` is the last one.""" + query = select(User).order_by(User.id).limit(limit) + if after_id is not None: + query = query.where(User.id > after_id) + users = (await session.scalars(query)).all() return [ AdminUserResponse( id=u.id, @@ -254,7 +263,7 @@ async def user_privkey(user_id: int, session: AsyncSession = Depends(get_session the most sensitive data the platform holds.""" user = await session.get(User, user_id) if user is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found") + raise http_error(status.HTTP_404_NOT_FOUND, "user_not_found", "user not found") wif = derive_user_wif(user.derivation_index) await write_audit_log(session, "admin_privkey_accessed", {"user_id": user_id}, user_id=user_id) @@ -282,7 +291,7 @@ async def reset_user_password( reset; only an admin (via /admin, token-gated) can trigger this.""" user = await session.get(User, user_id) if user is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found") + raise http_error(status.HTTP_404_NOT_FOUND, "user_not_found", "user not found") new_password = secrets.token_urlsafe(12) user.password_hash = await hash_password_async(new_password) @@ -484,7 +493,7 @@ async def update_bug_report_status( ) -> AdminBugReportResponse: report = await session.get(BugReport, report_id) if report is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found") + raise http_error(status.HTTP_404_NOT_FOUND, "bug_report_not_found", "bug report not found") # B-60: every other admin mutation (config edit, pause/resume, privkey export, # password reset) leaves a trace; this one silently marked a report `resolved`. diff --git a/app/api/routes/bug_reports.py b/app/api/routes/bug_reports.py index 45e403c..37d02ec 100644 --- a/app/api/routes/bug_reports.py +++ b/app/api/routes/bug_reports.py @@ -1,10 +1,12 @@ -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, Request, status from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.client_ip import client_ip from app.api.timeutil import isoformat_utc from app.auth.dependencies import get_current_user, get_optional_user +from app.auth.routes import _rate_limited_error, _rate_limiters from app.db.models import BugReport, User from app.db.session import get_session @@ -39,9 +41,17 @@ class BugReportResponse(BaseModel): @router.post("", response_model=BugReportResponse, status_code=status.HTTP_201_CREATED) async def create_bug_report( body: BugReportCreate, + request: Request, user: User | None = Depends(get_optional_user), session: AsyncSession = Depends(get_session), ) -> BugReportResponse: + # B-94 (SEC-55): a quota per IP, checked before the insert and consumed only by a report + # that was stored (validation failures never get here). + quota = _rate_limiters(request).bug_reports + ip_key = f"ip:{client_ip(request)}" + retry_after = quota.retry_after(ip_key) + if retry_after > 0: + raise _rate_limited_error(retry_after) report = BugReport( description=body.description, contact=body.contact, @@ -49,6 +59,7 @@ async def create_bug_report( ) session.add(report) await session.commit() + quota.record(ip_key) return BugReportResponse(id=report.id) diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index 482070d..598bd6d 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -3,7 +3,7 @@ import json from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, Request -from fastapi.responses import JSONResponse, Response, StreamingResponse +from fastapi.responses import Response, StreamingResponse from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -57,7 +57,7 @@ async def round_stream(request: Request) -> Response: try: queue = broadcaster.subscribe(client_ip(request)) except RoundEventCapacityError: - return JSONResponse(status_code=503, content={"detail": "too many concurrent update streams"}) + raise http_error(503, "too_many_streams", "too many concurrent update streams") async def event_generator(): ticks_since_keepalive = 0 diff --git a/app/api/routes/users.py b/app/api/routes/users.py index 6355cae..759ca8a 100644 --- a/app/api/routes/users.py +++ b/app/api/routes/users.py @@ -1,11 +1,13 @@ from fastapi import APIRouter, Depends, Request, status -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.errors import http_error +from app.audit.log import write_audit_log from app.api.timeutil import isoformat_utc from app.auth.dependencies import get_current_user +from app.auth.routes import _rate_limited_error, _rate_limiters from app.auth.security import ( MIN_PASSWORD_LENGTH, create_access_token, @@ -52,8 +54,8 @@ async def me( class ChangePasswordRequest(BaseModel): - current_password: str - new_password: str + current_password: str = Field(max_length=256) # B-94 (REG-12) + new_password: str = Field(max_length=256) class ChangePasswordResponse(BaseModel): @@ -63,13 +65,22 @@ class ChangePasswordResponse(BaseModel): @router.post("/me/change-password", response_model=ChangePasswordResponse) async def change_password( body: ChangePasswordRequest, + request: Request, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> ChangePasswordResponse: """Self-service password change — requires the current password, unlike the admin-only /admin/users/{id}/reset-password (which is for a user who's actually locked out and can't provide it).""" + # B-94 (SEC-54): a stolen token must not be able to guess the current password, change it + # and lock the owner out. Checked before the Argon2 hash and even for the right password. + limiter = _rate_limiters(request).change_password + key = f"user:{user.id}" + retry_after = limiter.retry_after(key) + if retry_after > 0: + raise _rate_limited_error(retry_after) if not await verify_password_async(body.current_password, user.password_hash): + limiter.record_failure(key) raise http_error( status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect" ) @@ -89,7 +100,9 @@ async def change_password( # every other open session (this user's other devices, or an attacker's) # gets "session_expired" on its next request. user.token_version += 1 + await write_audit_log(session, "password_changed", {"user_id": user.id}, user_id=user.id) await session.commit() + limiter.record_success(key) return ChangePasswordResponse(access_token=create_access_token(user.id, user.token_version)) diff --git a/app/auth/rate_limit.py b/app/auth/rate_limit.py index ff69a5d..6bd4eb5 100644 --- a/app/auth/rate_limit.py +++ b/app/auth/rate_limit.py @@ -186,6 +186,8 @@ class RollingQuota: _REGISTRATIONS_PER_IP = 5 _REGISTRATION_WINDOW_SECONDS = 3600.0 +_BUG_REPORTS_PER_IP = 10 +_BUG_REPORT_WINDOW_SECONDS = 3600.0 class AuthRateLimiters: @@ -208,3 +210,8 @@ class AuthRateLimiters: self.register_ip = RollingQuota( limit=_REGISTRATIONS_PER_IP, window_seconds=_REGISTRATION_WINDOW_SECONDS ) + # B-94 (SEC-54): wrong `current_password` on a password change, per user — a stolen + # token must not be able to guess the password and lock the owner out. + self.change_password = RateLimiter(threshold=5, base_delay=2.0, max_delay=300.0) + # B-94 (SEC-55): bug reports are anonymous; a quota per IP keeps them from filling the DB. + self.bug_reports = RollingQuota(limit=_BUG_REPORTS_PER_IP, window_seconds=_BUG_REPORT_WINDOW_SECONDS) diff --git a/app/auth/routes.py b/app/auth/routes.py index 2a70bbd..c734c63 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -148,8 +148,10 @@ async def register( class LoginRequest(BaseModel): - username: str - password: str + # B-94 (API-19): bounded before anything is hashed or counted; a megabyte password would + # otherwise be an Argon2 input and a rate-limit key. + username: str = Field(max_length=256) + password: str = Field(max_length=256) @router.post("/login", response_model=TokenResponse) diff --git a/app/main.py b/app/main.py index 3d68ee3..5c09314 100644 --- a/app/main.py +++ b/app/main.py @@ -3,8 +3,10 @@ import logging from contextlib import asynccontextmanager from fastapi import FastAPI, Request, status +from fastapi.exceptions import RequestValidationError from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles +from starlette.exceptions import HTTPException as StarletteHTTPException from app.logging_config import setup_logging @@ -117,6 +119,28 @@ app.include_router(qr_router) app.include_router(rounds_router) +# B-94 (API-15, API-16): no error answers with a bare-string or list `detail`; the framework's +# own 401/404/405 and request-validation failures take the structured shape too. +_FRAMEWORK_CODES = {401: "not_authenticated", 404: "not_found", 405: "method_not_allowed"} + + +@app.exception_handler(StarletteHTTPException) +async def structured_http_exception(request: Request, exc: StarletteHTTPException) -> JSONResponse: + if isinstance(exc.detail, dict) and "code" in exc.detail: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers) + code = _FRAMEWORK_CODES.get(exc.status_code, f"http_{exc.status_code}") + detail = ApiError(code, str(exc.detail)).as_detail() + return JSONResponse(status_code=exc.status_code, content={"detail": detail}, headers=exc.headers) + + +@app.exception_handler(RequestValidationError) +async def structured_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse: + """`input` is left out on purpose: an over-long password must not come back in the response.""" + errors = [{"loc": list(e["loc"]), "msg": e["msg"], "type": e["type"]} for e in exc.errors()] + detail = ApiError("invalid_request", "invalid request", errors=errors).as_detail() + return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": detail}) + + @app.exception_handler(Exception) async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse: """Answers with the same structured `detail` shape as every deliberate failure @@ -157,4 +181,14 @@ async def report_bug_page() -> FileResponse: return FileResponse("app/static/report-bug.html", headers=_NO_STORE_HEADERS) -app.mount("/", StaticFiles(directory="app/static", html=True), name="static") +class NoCacheStaticFiles(StaticFiles): + """B-94 (API-09): the browser may keep a copy but revalidates it on every use, so after an + update it does not run an old app.js against the new API.""" + + async def get_response(self, path, scope): + response = await super().get_response(path, scope) + response.headers["Cache-Control"] = "no-cache" + return response + + +app.mount("/", NoCacheStaticFiles(directory="app/static", html=True), name="static") diff --git a/app/static/admin.js b/app/static/admin.js index 9182da2..5b08c2f 100644 --- a/app/static/admin.js +++ b/app/static/admin.js @@ -34,8 +34,8 @@ async function callAdmin(method, path, body) { const headers = { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken }; const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined }); const data = await res.json().catch(() => ({})); - // detail is a bare string on the admin endpoints, but the shared dependencies - // (auth) answer with the structured {code, message} form of app/api/errors.py. + // Every error answers with the structured {code, message, params} detail (API-16); + // the bare-string fallback only covers a proxy's own error page. if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText); return data; } @@ -230,7 +230,14 @@ async function adminSave() { async function loadUsers() { try { - const users = await callAdmin('GET', '/admin/users'); + // B-94 (API-33): the list comes in pages of up to 500, by ascending id. + const users = []; + for (;;) { + const afterId = users.length ? users[users.length - 1].id : 0; + const page = await callAdmin('GET', `/admin/users?limit=500&after_id=${afterId}`); + users.push(...page); + if (page.length < 500) break; + } const tbody = document.getElementById('users-tbody'); tbody.innerHTML = users.map((u) => ` diff --git a/app/static/app.js b/app/static/app.js index 480f528..cfbc3e0 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -87,7 +87,7 @@ async function call(method, path, body) { // A token the server no longer accepts can't be recovered from by retrying: // without this every poll keeps failing against a dashboard that still looks // logged in, toasting "session expired" forever. Drop back to the login form. - if (res.status === 401 && data.detail?.code === 'session_expired' && token) logout(); + if (res.status === 401 && ['session_expired', 'not_authenticated'].includes(data.detail?.code) && token) logout(); throw new Error(apiErrorMessage(data.detail) || res.statusText); } return data; diff --git a/app/static/i18n.js b/app/static/i18n.js index b4c036e..f10d109 100644 --- a/app/static/i18n.js +++ b/app/static/i18n.js @@ -162,6 +162,13 @@ const TRANSLATIONS = { 'error.broadcast_failed': 'The network refused the transaction. Please try again shortly.', 'error.service_busy': 'The service is busy right now and nothing was sent. Please try again in a moment.', 'error.registration_unavailable': 'Registration is temporarily unavailable. Please try again later.', + 'error.not_authenticated': 'You are not signed in. Please sign in again.', + 'error.not_found': 'Not found.', + 'error.method_not_allowed': 'This action is not allowed.', + 'error.too_many_streams': 'Too many live-update connections right now; the page will keep refreshing on its own.', + 'error.invalid_admin_token': 'The admin token is missing or wrong.', + 'error.user_not_found': 'User not found.', + 'error.bug_report_not_found': 'Bug report not found.', 'error.amount_below_dust_limit': 'The amount is too small to be sent.', 'error.too_many_inputs': 'Your balance is split across too many small deposits to be spent in a single transaction (max {max_inputs}). Please contact support to consolidate it.', 'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.', @@ -331,6 +338,13 @@ const TRANSLATIONS = { 'error.broadcast_failed': 'La rete ha rifiutato la transazione. Riprova tra poco.', 'error.service_busy': 'Il servizio è occupato in questo momento e non è stato inviato nulla. Riprova tra poco.', 'error.registration_unavailable': 'La registrazione è temporaneamente non disponibile. Riprova più tardi.', + 'error.not_authenticated': 'Non hai effettuato l\'accesso. Accedi di nuovo.', + 'error.not_found': 'Non trovato.', + 'error.method_not_allowed': 'Questa azione non è consentita.', + 'error.too_many_streams': 'Troppe connessioni di aggiornamento in tempo reale; la pagina continuerà ad aggiornarsi da sola.', + 'error.invalid_admin_token': 'Il token admin manca o è errato.', + 'error.user_not_found': 'Utente non trovato.', + 'error.bug_report_not_found': 'Segnalazione non trovata.', 'error.amount_below_dust_limit': "L'importo è troppo basso per essere inviato.", 'error.too_many_inputs': 'Il tuo saldo è suddiviso in troppi piccoli depositi per essere speso in una sola transazione (max {max_inputs}). Contatta l\'assistenza per consolidarlo.', 'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.', @@ -500,6 +514,13 @@ const TRANSLATIONS = { 'error.broadcast_failed': 'La red rechazó la transacción. Inténtalo de nuevo en un momento.', 'error.service_busy': 'El servicio está ocupado en este momento y no se ha enviado nada. Inténtalo de nuevo en un momento.', 'error.registration_unavailable': 'El registro no está disponible temporalmente. Inténtalo de nuevo más tarde.', + 'error.not_authenticated': 'No has iniciado sesión. Inicia sesión de nuevo.', + 'error.not_found': 'No encontrado.', + 'error.method_not_allowed': 'Esta acción no está permitida.', + 'error.too_many_streams': 'Demasiadas conexiones de actualización en tiempo real; la página seguirá actualizándose sola.', + 'error.invalid_admin_token': 'Falta el token de administrador o es incorrecto.', + 'error.user_not_found': 'Usuario no encontrado.', + 'error.bug_report_not_found': 'Informe de error no encontrado.', 'error.amount_below_dust_limit': 'El importe es demasiado pequeño para enviarse.', 'error.too_many_inputs': 'Tu saldo está repartido en demasiados depósitos pequeños para gastarse en una sola transacción (máx. {max_inputs}). Contacta con soporte para consolidarlo.', 'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.', @@ -669,6 +690,13 @@ const TRANSLATIONS = { 'error.broadcast_failed': 'Le réseau a refusé la transaction. Veuillez réessayer dans un instant.', 'error.service_busy': 'Le service est occupé pour le moment et rien n\'a été envoyé. Veuillez réessayer dans un instant.', 'error.registration_unavailable': 'L\'inscription est temporairement indisponible. Veuillez réessayer plus tard.', + 'error.not_authenticated': 'Vous n\'êtes pas connecté. Veuillez vous reconnecter.', + 'error.not_found': 'Introuvable.', + 'error.method_not_allowed': 'Cette action n\'est pas autorisée.', + 'error.too_many_streams': 'Trop de connexions de mise à jour en direct ; la page continuera de s\'actualiser seule.', + 'error.invalid_admin_token': 'Le jeton d\'administration est absent ou incorrect.', + 'error.user_not_found': 'Utilisateur introuvable.', + 'error.bug_report_not_found': 'Signalement introuvable.', 'error.amount_below_dust_limit': "Le montant est trop faible pour être envoyé.", 'error.too_many_inputs': 'Votre solde est réparti sur trop de petits dépôts pour être dépensé en une seule transaction (max {max_inputs}). Contactez le support pour le consolider.', 'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.", @@ -838,6 +866,13 @@ const TRANSLATIONS = { 'error.broadcast_failed': 'Das Netzwerk hat die Transaktion abgelehnt. Bitte versuche es in Kürze erneut.', 'error.service_busy': 'Der Dienst ist gerade ausgelastet und es wurde nichts gesendet. Bitte versuche es gleich noch einmal.', 'error.registration_unavailable': 'Die Registrierung ist vorübergehend nicht verfügbar. Bitte versuche es später noch einmal.', + 'error.not_authenticated': 'Du bist nicht angemeldet. Bitte melde dich erneut an.', + 'error.not_found': 'Nicht gefunden.', + 'error.method_not_allowed': 'Diese Aktion ist nicht erlaubt.', + 'error.too_many_streams': 'Zu viele Live-Update-Verbindungen; die Seite aktualisiert sich weiterhin selbst.', + 'error.invalid_admin_token': 'Das Admin-Token fehlt oder ist falsch.', + 'error.user_not_found': 'Benutzer nicht gefunden.', + 'error.bug_report_not_found': 'Fehlermeldung nicht gefunden.', 'error.amount_below_dust_limit': 'Der Betrag ist zu klein, um gesendet zu werden.', 'error.too_many_inputs': 'Ihr Guthaben ist auf zu viele kleine Einzahlungen verteilt, um in einer einzigen Transaktion ausgegeben zu werden (max. {max_inputs}). Bitte wenden Sie sich an den Support, um es zusammenzufassen.', 'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.', @@ -1007,6 +1042,13 @@ const TRANSLATIONS = { 'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.', 'error.service_busy': 'Сервис сейчас занят, ничего не было отправлено. Попробуйте ещё раз через минуту.', 'error.registration_unavailable': 'Регистрация временно недоступна. Попробуйте позже.', + 'error.not_authenticated': 'Вы не вошли в систему. Войдите снова.', + 'error.not_found': 'Не найдено.', + 'error.method_not_allowed': 'Это действие не разрешено.', + 'error.too_many_streams': 'Слишком много соединений для живых обновлений; страница продолжит обновляться сама.', + 'error.invalid_admin_token': 'Токен администратора отсутствует или неверен.', + 'error.user_not_found': 'Пользователь не найден.', + 'error.bug_report_not_found': 'Сообщение об ошибке не найдено.', 'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.', 'error.too_many_inputs': 'Ваш баланс разбит на слишком много мелких депозитов, чтобы потратить его одной транзакцией (максимум {max_inputs}). Обратитесь в поддержку для консолидации.', 'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.', @@ -1176,6 +1218,13 @@ const TRANSLATIONS = { 'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。', 'error.service_busy': '服务当前繁忙,没有发送任何内容,请稍后重试。', 'error.registration_unavailable': '注册暂时不可用,请稍后再试。', + 'error.not_authenticated': '您尚未登录,请重新登录。', + 'error.not_found': '未找到。', + 'error.method_not_allowed': '不允许此操作。', + 'error.too_many_streams': '实时更新连接过多;页面将自行继续刷新。', + 'error.invalid_admin_token': '管理员令牌缺失或错误。', + 'error.user_not_found': '未找到用户。', + 'error.bug_report_not_found': '未找到错误报告。', 'error.amount_below_dust_limit': '金额过小,无法发送。', 'error.too_many_inputs': '您的余额分散在过多的小额存款中,无法在一笔交易中花费(最多 {max_inputs} 笔)。请联系客服进行归集。', 'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。', diff --git a/docs/adr/ADR-41-limitazione-tentativi-su-login-registrazione.md b/docs/adr/ADR-41-limitazione-tentativi-su-login-registrazione.md index a2adcdc..59c083e 100644 --- a/docs/adr/ADR-41-limitazione-tentativi-su-login-registrazione.md +++ b/docs/adr/ADR-41-limitazione-tentativi-su-login-registrazione.md @@ -1,6 +1,6 @@ # ADR-41 — Limitazione dei tentativi solo su login e registrazione -- **Stato:** Accettata — implementazione da completare (B-94) +- **Stato:** Accettata - **Area:** Account, amministrazione e sicurezza - **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*. @@ -17,12 +17,10 @@ account creati davvero. I contatori sono limitati in numero. limita la produzione. - *Conseguenze:* nessun limite su scommesse, prelievi, admin ([LIM-07]). Il cambio password (limitato e registrato come `password_changed`) e le - segnalazioni di bug (limitate per IP) si aggiungono con B-94; ogni campo + segnalazioni di bug (limitate per IP) sono limitati (B-94); ogni campo delle credenziali ha una lunghezza massima di 256 caratteri ([sicurezza.md](../sicurezza.md#limiti-di-frequenza), B-94). *Codice:* `app/auth/rate_limit.py:AuthRateLimiters` · `app/auth/rate_limit.py:RateLimiter` · `app/auth/rate_limit.py:RollingQuota` · `app/auth/routes.py:login` · `app/api/routes/users.py:change_password` · `app/api/routes/bug_reports.py:create_bug_report` · -*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py` -*Stato:* da implementare (B-94) · *Oggi:* cambio password e segnalazioni di -bug non hanno limiti né lunghezze massime, e il cambio password non scrive -audit. +*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_abuse_limits.py` + diff --git a/docs/adr/ADR-50-frontend-statici-senza-framework-ne-build.md b/docs/adr/ADR-50-frontend-statici-senza-framework-ne-build.md index ffbd8c3..bdaeeff 100644 --- a/docs/adr/ADR-50-frontend-statici-senza-framework-ne-build.md +++ b/docs/adr/ADR-50-frontend-statici-senza-framework-ne-build.md @@ -1,6 +1,6 @@ # ADR-50 — Frontend statici, senza framework né build -- **Stato:** Accettata — implementazione da completare (B-94) +- **Stato:** Accettata - **Area:** Interfacce, API e lingue - **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*. @@ -15,6 +15,3 @@ aggiornamento non lascia una pagina nuova con uno script vecchio; [architettura.md](../architettura.md#allimport), B-94). *Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` · `app/static/app.js` · `app/static/admin.js` -*Stato:* da implementare (B-94) · *Oggi:* solo le quattro pagine HTML hanno -`Cache-Control` (`no-store`); JS e CSS, serviti da `StaticFiles`, non ne -hanno. diff --git a/docs/api.md b/docs/api.md index 62a693e..941fb81 100644 --- a/docs/api.md +++ b/docs/api.md @@ -117,7 +117,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. modo: torna alla schermata di login. *Codice:* `app/auth/dependencies.py:get_current_user` · *Test:* `tests/unit/test_users.py`, `tests/unit/test_bug_reports.py` - *Stato:* da implementare (B-94), per l'header assente · *Oggi:* l'assenza dell'header è intercettata da FastAPI prima del codice applicativo e risponde con il corpo non strutturato `{"detail": "Not authenticated"}`. @@ -137,7 +136,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. (vedi [sicurezza.md](sicurezza.md)). *Codice:* `app/api/routes/admin.py:require_admin` · *Test:* `tests/unit/test_admin.py` - *Stato:* da implementare (B-94) · *Oggi:* `403` con il corpo non strutturato `{"detail": "invalid admin token"}`. - **[API-09]** Intestazioni di cache: @@ -156,7 +154,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. `app/main.py:user_guide`, `app/main.py:report_bug_page`, `app/api/routes/rounds.py:round_stream`, `app/api/routes/qr.py:address_qr`, `app/main.py` (`app.mount`) - *Stato:* da implementare (B-94), per i file statici · *Oggi:* i file serviti dal mount non hanno `Cache-Control`, solo `ETag`/`Last-Modified`, e il browser può riusarli senza rivalidarli. @@ -169,8 +166,8 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. *Test:* `tests/unit/test_docs_disabled.py` - **[API-11]** Dove un controllo dipende dall'indirizzo IP del chiamante (limiti - di login e registrazione, tetto per IP del flusso SSE e, da implementare con - B-94, quota per IP di `POST /bug-reports`), l'IP è l'**ultimo** + di login e registrazione, tetto per IP del flusso SSE e quota per IP di + `POST /bug-reports`), l'IP è l'**ultimo** elemento di `X-Forwarded-For` (quello scritto dal proxy fidato), o in sua assenza l'indirizzo della connessione, o `"unknown"`. Il motivo e il ruolo di Caddy sono in [sicurezza.md](sicurezza.md). @@ -238,11 +235,10 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. con `type: "json_invalid"`). Il valore ricevuto (`input`) **non** è ripetuto nella risposta, così una password troppo lunga non torna indietro in chiaro. - *Codice:* `app/main.py` (gestore di `RequestValidationError`, da scrivere), + *Codice:* `app/main.py` (gestore di `RequestValidationError`), `app/static/app.js:apiErrorMessage` · *Test:* `tests/unit/test_users.py`, `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py` - *Stato:* da implementare (B-94) · *Oggi:* `422` nel formato **nativo** di FastAPI: `detail` è una **lista** di oggetti `{type, loc, msg, input, ctx?}`, senza `code`; il frontend riconosce la lista e mostra il messaggio generico tradotto `error.invalid_request`. @@ -269,7 +265,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. `app/api/routes/admin.py:update_bug_report_status`, `app/api/routes/rounds.py:round_stream`, `app/main.py` · *Test:* `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py` - *Stato:* da implementare (B-94) · *Oggi:* tutte le risposte della tabella hanno `detail` come stringa inglese (colonna "Oggi"), e il frontend la mostra così com'è. @@ -334,7 +329,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. massimo 256 caratteri (oltre: `422 invalid_request`, prima di qualunque hash Argon2 e senza consumare i contatori di fallimento). Lo username è confrontato senza distinzione tra maiuscole e minuscole. - *Stato:* da implementare (B-94) · *Oggi:* nessun vincolo di lunghezza. - **Risposta `200`:** `{"access_token": , "address": }`. - **Errori:** `422`; `429 rate_limited` (`params.retry_after_seconds`, arrotondato per eccesso al secondo); `401 invalid_credentials` — @@ -399,7 +393,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. *Codice:* `app/api/routes/users.py:change_password`, `app/auth/rate_limit.py:RateLimiter` · *Test:* `tests/unit/test_users.py` - *Stato:* da implementare (B-94) · *Oggi:* nessuna lunghezza massima, nessun limite di frequenza (la password attuale si può provare senza limiti) e nessuna voce di audit. @@ -609,7 +602,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. `app/rounds/events.py:RoundEventBroadcaster.subscribe`, `app/rounds/events.py:RoundEventBroadcaster.publish` · *Test:* `tests/unit/test_round_events.py` - *Stato:* da implementare (B-94, B-93) · *Oggi:* il `503` ha il corpo non + *Stato:* da implementare (B-93), per la chiusura dei flussi all'arresto strutturato `{"detail": "too many concurrent update streams"}`, e un flusso aperto non viene chiuso all'arresto: uvicorn lo attende finché Docker non termina il processo. @@ -720,7 +713,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. `app/api/routes/bug_reports.py:BugReportCreate`, `app/auth/rate_limit.py:RollingQuota` · *Test:* `tests/unit/test_bug_reports.py` - *Stato:* da implementare (B-94) · *Oggi:* nessun limite di frequenza: un anonimo può inserire segnalazioni senza limite. #### GET /bug-reports/mine @@ -841,7 +833,6 @@ dei parametri operativi in "balance_sats", "created_at"}`; `balance_sats` è il saldo confermato. *Codice:* `app/api/routes/admin.py:list_users` · *Test:* `tests/unit/test_admin.py` - *Stato:* da implementare (B-94) · *Oggi:* restituisce **tutti** gli utenti in una sola risposta, senza paginazione né limite. #### GET /admin/users/{user_id}/privkey @@ -1029,7 +1020,7 @@ dei parametri operativi in |---|---|---|---|---|---| | `session_expired` | 401 | `app/auth/dependencies.py:get_current_user` | — | token illeggibile, scaduto, utente inesistente o token superato da un cambio/reset password | sì | | `invalid_credentials` | 401 | `app/auth/routes.py:login` | — | username inesistente o password sbagliata (risposta identica) | sì | -| `rate_limited` | 429 | `app/auth/routes.py:register`, `app/auth/routes.py:login`; anche `app/api/routes/users.py:change_password` e `app/api/routes/bug_reports.py` *da implementare (B-94)* | `retry_after_seconds` | quota di registrazione, blocco per tentativi falliti, o quota di segnalazioni | sì | +| `rate_limited` | 429 | `app/auth/routes.py:register`, `app/auth/routes.py:login`; anche `app/api/routes/users.py:change_password` e `app/api/routes/bug_reports.py` | `retry_after_seconds` | quota di registrazione, blocco per tentativi falliti, o quota di segnalazioni | sì | | `username_taken` | 409 | `app/auth/routes.py:register` | — | nome già usato (senza distinzione di maiuscole) | sì | | `derivation_index_conflict` | 409 | `app/auth/routes.py:register` | — | nessun indice di derivazione libero dopo 5 tentativi | sì | | `current_password_incorrect` | 401 | `app/api/routes/users.py:change_password` | — | password attuale errata | sì | @@ -1053,14 +1044,14 @@ dei parametri operativi in | `withdrawal_to_platform_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni | sì | | `service_busy` | 503 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/auth/routes.py:register` | — | il database non ha concesso il lock di scrittura entro il timeout; nulla è stato trasmesso, si può riprovare | sì | | `registration_unavailable` | 503 | `app/auth/routes.py:register` | — | registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto ([registrazione-e-depositi.md](registrazione-e-depositi.md), B-90) | sì | -| `not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header `Authorization` assente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | -| `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | -| `user_not_found` | 404 | `app/api/routes/admin.py` | — | utente inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | -| `bug_report_not_found` | 404 | `app/api/routes/admin.py` | — | segnalazione inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | +| `not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header `Authorization` assente | sì | +| `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato | sì | +| `user_not_found` | 404 | `app/api/routes/admin.py` | — | utente inesistente | sì | +| `bug_report_not_found` | 404 | `app/api/routes/admin.py` | — | segnalazione inesistente | sì | | `round_not_found` | 404 | `app/api/routes/rounds.py` (`GET /rounds/{id}/proof`) | — | round inesistente | sì | -| `not_found` | 404 | gestore delle eccezioni HTTP in `app/main.py` | — | percorso inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | -| `method_not_allowed` | 405 | gestore delle eccezioni HTTP in `app/main.py` | — | metodo non ammesso — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | -| `too_many_streams` | 503 | `app/api/routes/rounds.py` (SSE) | — | tetto globale degli stream raggiunto — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | +| `not_found` | 404 | gestore delle eccezioni HTTP in `app/main.py` | — | percorso inesistente | sì | +| `method_not_allowed` | 405 | gestore delle eccezioni HTTP in `app/main.py` | — | metodo non ammesso | sì | +| `too_many_streams` | 503 | `app/api/routes/rounds.py` (SSE) | — | tetto globale degli stream raggiunto | sì | Due codici di `app/wallet/psbt_builder.py` — `winner_share_below_network_fee` e `commission_below_dust_limit` — riguardano solo la costruzione del diff --git a/docs/architettura.md b/docs/architettura.md index 4c7cfd6..c315358 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -397,7 +397,6 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]). `/app`. *Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` · `app/main.py:user_guide` · `app/main.py:report_bug_page` - *Stato:* da implementare (B-94) · *Oggi:* `Cache-Control` c'è **solo** sulle quattro pagine HTML; i file serviti da `StaticFiles` (e `/index.html` chiesto per nome) non ne hanno, e il browser può usare uno script vecchio con una pagina nuova. diff --git a/docs/audit-log.md b/docs/audit-log.md index 0dc3eed..55e3fb2 100644 --- a/docs/audit-log.md +++ b/docs/audit-log.md @@ -275,7 +275,6 @@ flowchart LR sbagliata, nuova troppo corta) non scrive nulla. Nessuna logica rilegge la riga. *Codice:* `app/api/routes/users.py:change_password` · *Test:* — - *Stato:* da implementare (B-94) · *Oggi:* il cambio password non scrive alcun evento. - **[AUD-43]** `restore_gap_detected` — il controllo all'avvio ha trovato @@ -627,7 +626,7 @@ flowchart LR `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/confirmation.py:_on_payout_confirmed`, `app/tx/confirmation.py:poll_once`, `app/tx/broadcast.py:bump_fee` - *Stato:* da implementare (B-92, B-94), per i due fatti che escono + *Stato:* da implementare (B-92), per user_registered dall'elenco · *Oggi:* la registrazione e il cambio password riuscito non producono alcuna riga. diff --git a/docs/limiti-noti.md b/docs/limiti-noti.md index 0390133..5e50f89 100644 --- a/docs/limiti-noti.md +++ b/docs/limiti-noti.md @@ -151,10 +151,10 @@ implementato il [piano delle correzioni](piano-correzioni.md). blocco per-utente li serializza, non li limita); il cambio password permette di provare password attuali senza ritardi a chi possiede già un token valido; le segnalazioni anonime possono riempire il database. - - *Stato:* da chiudere in parte con B-94 (cambio password limitato e - registrato, segnalazioni di bug limitate per IP, lunghezza massima di 256 - caratteri sui campi delle credenziali). Scommesse, prelievi ed endpoint - admin restano senza limite di frequenza anche dopo il piano. + - *Stato:* chiuso in parte da B-94 (cambio password limitato e registrato, + segnalazioni di bug limitate per IP, lunghezza massima di 256 caratteri sui + campi delle credenziali). Scommesse, prelievi ed endpoint admin restano + senza limite di frequenza. - *Per chiuderlo:* un limitatore per utente e per IP sugli endpoint interessati, sullo stesso modello di `RateLimiter`/`RollingQuota`. @@ -166,8 +166,8 @@ implementato il [piano delle correzioni](piano-correzioni.md). - *Impatto:* dopo una compromissione non c'è traccia di quando e da dove sia avvenuto l'accesso. - *Stato:* da chiudere in parte con B-92 (`user_registered`, con indice di - derivazione e indirizzo) e B-94 (`password_changed`). Login riusciti e - falliti restano senza voce di audit dopo il piano. + derivazione e indirizzo); chiusa da B-94 la parte `password_changed`. Login + riusciti e falliti restano senza voce di audit dopo il piano. - *Per chiuderlo:* eventi di audit per questi casi (vedi [audit-log.md](audit-log.md)). @@ -414,21 +414,7 @@ implementato il [piano delle correzioni](piano-correzioni.md). *Codice:* `app/static/i18n.js:t` -- **[LIM-22]** **L'API di amministrazione e alcuni errori di contorno non usano - il formato d'errore strutturato.** Gli endpoint `/admin/*` rispondono con un - `detail` stringa (per esempio `"invalid admin token"`, `"user not found"`), e - così il 503 del canale SSE oltre il tetto; è coerente con un pannello non - tradotto, ma non è dichiarato altrove. - - *Stato:* da chiudere con B-94. - - *Per chiuderlo:* errori strutturati ovunque: `/admin/*`, il 503 del canale - SSE e gli errori generati dal framework usano lo stesso formato - `{code, message, params}` ([ADR-47](adr/ADR-47-l-api-parla-sola-lingua-risponde.md)). +- ~~[LIM-22]~~ Rimosso: chiuso da B-94. Gli endpoint `/admin/*`, il `503` del canale SSE e gli errori del framework (`401`, `404`, `405`, `422`) rispondono con il formato strutturato `{code, message, params}` ([API-16](api.md#contratto-degli-errori), [ADR-47](adr/ADR-47-l-api-parla-sola-lingua-risponde.md)). - *Codice:* `app/api/routes/admin.py:require_admin` · `app/api/routes/rounds.py:round_stream` +- ~~[LIM-23]~~ Rimosso: chiuso da B-94. `GET /admin/users` è paginato ([API-33](api.md)). -- **[LIM-23]** **`GET /admin/users` non ha limite né paginazione**, a - differenza degli altri elenchi admin. - - *Impatto:* con molti utenti la risposta cresce senza limite. - - *Stato:* da chiudere con B-94 (paginazione dell'elenco utenti). - - *Codice:* `app/api/routes/admin.py:list_users` diff --git a/docs/registrazione-e-depositi.md b/docs/registrazione-e-depositi.md index 8e19b6c..3083db1 100644 --- a/docs/registrazione-e-depositi.md +++ b/docs/registrazione-e-depositi.md @@ -123,7 +123,6 @@ il freno sui tentativi di login falliti è prima di qualunque verifica. *Codice:* `app/auth/routes.py:login`, `app/auth/routes.py:LoginRequest` · *Test:* `tests/unit/test_auth.py` - *Stato:* da implementare (B-94), per la lunghezza massima · *Oggi:* i campi del login non hanno limiti di lunghezza. - ~~[REG-10]~~ Assorbito da [SEC-30](sicurezza.md#limiti-di-frequenza): il freno sui login falliti (chiavi, soglie, azzeramento) non ha nulla di @@ -146,7 +145,6 @@ il freno sui tentativi di login falliti è e [api.md](api.md) vi rimandano. *Codice:* `app/api/routes/users.py:change_password` · *Test:* `tests/unit/test_users.py` - *Stato:* da implementare (B-94), per lunghezza massima, freno ed evento `password_changed` · *Oggi:* nessun limite di lunghezza, nessun freno sui tentativi, nessun evento di audit. - **[REG-13]** Non esiste recupero password: non viene raccolta alcuna email. diff --git a/docs/sicurezza.md b/docs/sicurezza.md index 55fd01b..f0a3380 100644 --- a/docs/sicurezza.md +++ b/docs/sicurezza.md @@ -324,7 +324,6 @@ che ne è il proprietario; qui ci sono le regole di sicurezza. *Codice:* `app/auth/security.py:MIN_PASSWORD_LENGTH`, `app/auth/routes.py:RegisterRequest`, `app/auth/routes.py:LoginRequest`, `app/api/routes/users.py:ChangePasswordRequest` · *Test:* `tests/unit/test_users.py` - *Stato:* da implementare (B-94), per il massimo su login e cambio password · *Oggi:* il massimo di 256 vale solo per la password della registrazione; i campi del login e del cambio password non hanno limite di lunghezza. @@ -374,7 +373,6 @@ che ne è il proprietario; qui ci sono le regole di sicurezza. *Codice:* `app/auth/dependencies.py:get_current_user`, `app/api/routes/users.py:change_password`, `app/api/routes/admin.py:reset_user_password` · *Test:* `tests/unit/test_users.py`, `tests/unit/test_security.py`, `tests/unit/test_admin.py` - *Stato:* da implementare (B-94), per `not_authenticated` · *Oggi:* l'header assente risponde 401 con il corpo non strutturato di FastAPI `{"detail": "Not authenticated"}`. @@ -398,7 +396,6 @@ endpoint `/admin/*`, che richiedono l'header `X-Admin-Token`. `invalid_admin_token` ([API-08](api.md#convenzioni-generali)). *Codice:* `app/api/routes/admin.py:require_admin` · *Test:* `tests/unit/test_admin.py` - *Stato:* da implementare (B-94), per il codice · *Oggi:* 403 con il corpo non strutturato `{"detail": "invalid admin token"}`. - **[SEC-25]** Il token è **unico e condiviso**: non esiste un'identità per @@ -457,8 +454,8 @@ ogni riavvio e non funzionerebbero con più worker. | Login per username | `user:` | Dal 5° fallimento: blocco di 2s, raddoppiato a ogni fallimento successivo, massimo 300s. Il contatore si azzera dopo 900s senza fallimenti, o con un login riuscito | `POST /auth/login` | | Login per IP | `ip:` | Dal 20° fallimento: stessa progressione (2s → 300s), stesso decadimento di 900s. **Non** si azzera con un login riuscito | `POST /auth/login` | | Registrazione per IP | `ip:` | Al massimo 5 account creati per IP in una finestra mobile di 3600s. Contano solo gli account creati davvero | `POST /auth/register` | -| Cambio password per utente *(B-94, da implementare)* | `user:`, in un limitatore separato da quello del login | Dal 5° fallimento della password attuale: stessa progressione del login (2s → 300s), stesso decadimento di 900s; un cambio riuscito azzera il contatore | `POST /users/me/change-password` | -| Segnalazioni di bug per IP *(B-94, da implementare)* | `ip:` | Al massimo 10 segnalazioni per IP in una finestra mobile di 3600s, anche da autenticati. Contano solo le segnalazioni salvate davvero | `POST /bug-reports` | +| Cambio password per utente *(B-94)* | `user:`, in un limitatore separato da quello del login | Dal 5° fallimento della password attuale: stessa progressione del login (2s → 300s), stesso decadimento di 900s; un cambio riuscito azzera il contatore | `POST /users/me/change-password` | +| Segnalazioni di bug per IP *(B-94)* | `ip:` | Al massimo 10 segnalazioni per IP in una finestra mobile di 3600s, anche da autenticati. Contano solo le segnalazioni salvate davvero | `POST /bug-reports` | | Stream SSE per IP | IP | Al massimo 5 stream contemporanei per IP: il 6° chiude il più vecchio dello stesso IP | `GET /rounds/stream` | | Stream SSE globale | — | Al massimo 500 stream contemporanei: oltre, 503 | `GET /rounds/stream` | @@ -493,8 +490,7 @@ ogni riavvio e non funzionerebbero con più worker. [API-21](api.md). *Codice:* `app/api/routes/users.py:change_password`, `app/auth/rate_limit.py:RateLimiter`, `app/auth/rate_limit.py:AuthRateLimiters` · - *Test:* — - *Stato:* da implementare (B-94) · *Oggi:* nessun freno: con un token valido + *Test:* `tests/unit/test_abuse_limits.py` la password attuale si può provare senza limiti. - **[SEC-55]** L'invio di segnalazioni di bug è una **quota** per IP @@ -506,8 +502,7 @@ ogni riavvio e non funzionerebbero con più worker. vecchia ([API-28](api.md)). *Codice:* `app/api/routes/bug_reports.py:create_bug_report`, `app/auth/rate_limit.py:RollingQuota` · - *Test:* — - *Stato:* da implementare (B-94) · *Oggi:* nessun limite: un anonimo può + *Test:* `tests/unit/test_abuse_limits.py` inserire segnalazioni senza limite, fino a riempire il database. - **[SEC-32]** La memoria dei limitatori è limitata: le voci esaurite vengono @@ -524,7 +519,6 @@ ogni riavvio e non funzionerebbero con più worker. *Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe`, `app/api/routes/rounds.py:round_stream` · *Test:* `tests/unit/test_round_events.py` - *Stato:* da implementare (B-94), per il codice `too_many_streams` · *Oggi:* il 503 ha il corpo non strutturato `{"detail": "too many concurrent update streams"}`. @@ -536,7 +530,6 @@ ogni riavvio e non funzionerebbero con più worker. che non è un limite di frequenza ([LIM-07](limiti-noti.md)). *Codice:* `app/api/routes/bets.py`, `app/api/routes/withdrawals.py`, `app/api/routes/admin.py:require_admin` - *Stato:* da implementare (B-94) · *Oggi:* anche il cambio password e l'invio di segnalazioni di bug sono senza limite ([SEC-54], [SEC-55]). ## Indirizzo IP del client @@ -667,7 +660,6 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in chiamate all'API solo dalla stessa origine. *Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`, `app/main.py:user_guide`, `app/main.py:report_bug_page`, `app/main.py` (`app.mount`) - *Stato:* da implementare (B-94), per i file statici · *Oggi:* i file serviti dal mount di `app/static/` non hanno `Cache-Control`, solo `ETag`/`Last-Modified`, e il browser può riusare uno script vecchio con una pagina nuova. @@ -687,7 +679,6 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in ([API-16](api.md#contratto-degli-errori)) e non ripete il valore ricevuto, così una password troppo lunga non torna indietro in chiaro. *Codice:* `app/main.py:log_unhandled_exception`, `app/main.py` - *Stato:* da implementare (B-94), per gli errori diversi dal 500 · *Oggi:* 401 senza header, 403 admin, 404/405 del framework e 503 dell'SSE hanno `detail` come stringa, e il 422 di validazione ripete nel campo `input` il valore ricevuto. @@ -734,7 +725,6 @@ di discrezione, non una protezione: la pagina è pubblica, gli endpoint no. `app/api/routes/admin.py:list_pending_transactions`, `app/api/routes/admin.py:list_bug_reports`, `app/api/routes/admin.py:list_users` · *Test:* `tests/unit/test_admin.py` - *Stato:* da implementare (B-94), per l'elenco utenti · *Oggi:* `GET /admin/users` restituisce tutti gli utenti in una sola risposta, senza limite né paginazione. diff --git a/docs/storico-bug.md b/docs/storico-bug.md index d1aa65e..c4e1fcb 100644 --- a/docs/storico-bug.md +++ b/docs/storico-bug.md @@ -242,7 +242,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). | B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | *da registrare* | `test_container_hardening.py`, `test_config.py` | SEC, OPS | | B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | **aperto** | — | OPS, AUD, API | | B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS | -| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | **aperto** | — | SEC, API | +| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | *da registrare* | `test_abuse_limits.py` | SEC, API | | B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | *da registrare* | `test_round_snapshot.py`, `test_admin.py`, `test_rounds_route.py`, `test_scheduler.py` | RND, BET | | B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | **aperto** | — | TX, BET, WDR, DEP | | B-97 | Le migrazioni possono eliminare in silenzio gli indici su espressione | Media | *da registrare* | `test_migrations.py` | DAT | @@ -254,8 +254,8 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). - **Corretti:** B-01 … B-72, tranne B-16, che è chiuso nella sostanza (il crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera - come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-91, B-95, B-98, B-99, B-100. -- **Aperti:** dalla revisione del 2026-09-28, B-92, B-93, B-94 e B-96 (0 critici, - 0 alti, 4 medi), da implementare nell'ordine di + come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-91, B-94, B-95, B-98, B-99, B-100. +- **Aperti:** dalla revisione del 2026-09-28, B-92, B-93 e B-96 (0 critici, + 0 alti, 3 medi), da implementare nell'ordine di [piano-correzioni.md](piano-correzioni.md). - **Prossimo numero libero:** `B-101`. diff --git a/tests/unit/test_abuse_limits.py b/tests/unit/test_abuse_limits.py new file mode 100644 index 0000000..f28ba5d --- /dev/null +++ b/tests/unit/test_abuse_limits.py @@ -0,0 +1,183 @@ +"""B-94 (SEC-54, SEC-55, API-09, API-15, API-16, API-19, API-21, API-28, API-33, REG-12): +credential length bounds, the password-change brake and audit, the bug-report quota, paged +user list, structured errors everywhere and revalidated static files.""" + +import json + +import pytest +from cryptography.fernet import Fernet +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.config import settings + + +@pytest.fixture +async def client(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db") + monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret") + monkeypatch.setattr(settings, "admin_token", "admin-secret") + monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode()) + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc")) + + import app.db.models # noqa: F401 + import app.wallet.hd as hd + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from app.db import base as db_base + from app.db import session as db_session + + hd._account_key = None + hd.generate_master_key() + db_base.engine = create_async_engine(settings.database_url) + db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False) + db_session.AsyncSessionLocal = db_base.AsyncSessionLocal + async with db_base.engine.begin() as conn: + await conn.run_sync(db_base.Base.metadata.create_all) + + from app.electrum.listener import ElectrumListener + from app.main import app + + app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal) + app.state.auth_rate_limiters = None + if hasattr(app.state, "auth_rate_limiters"): + del app.state.auth_rate_limiters # fresh throttle state per test + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + yield ac + await db_base.engine.dispose() + + +ADMIN = {"X-Admin-Token": "admin-secret"} + + +async def _register(client, username="alice", password="original-password"): + resp = await client.post("/auth/register", json={"username": username, "password": password}) + assert resp.status_code == 201 + return {"Authorization": f"Bearer {resp.json()['access_token']}"} + + +def _code(resp): + return resp.json()["detail"]["code"] + + +# --- field lengths (API-19, REG-12) and the 422 shape (API-15) --------------------- + + +async def test_login_fields_are_bounded_and_the_value_is_not_echoed(client): + resp = await client.post("/auth/login", json={"username": "a", "password": "secret-" + "x" * 300}) + assert resp.status_code == 422 + assert _code(resp) == "invalid_request" + errors = resp.json()["detail"]["params"]["errors"] + assert errors[0]["loc"] == ["body", "password"] and set(errors[0]) == {"loc", "msg", "type"} + assert "secret-" not in resp.text + + +async def test_change_password_fields_are_bounded(client): + headers = await _register(client) + resp = await client.post( + "/users/me/change-password", + headers=headers, + json={"current_password": "original-password", "new_password": "x" * 257}, + ) + assert resp.status_code == 422 and _code(resp) == "invalid_request" + + +# --- the password-change brake (SEC-54) and its audit (AUD-42) --------------------- + + +async def test_the_fifth_wrong_current_password_locks_even_the_right_one_out(client): + headers = await _register(client) + body = {"current_password": "wrong-password", "new_password": "brand-new-password"} + for _ in range(5): + assert (await client.post("/users/me/change-password", headers=headers, json=body)).status_code == 401 + resp = await client.post("/users/me/change-password", headers=headers, json=body) + assert resp.status_code == 429 and _code(resp) == "rate_limited" + right = {"current_password": "original-password", "new_password": "brand-new-password"} + locked = await client.post("/users/me/change-password", headers=headers, json=right) + assert locked.status_code == 429 and locked.json()["detail"]["params"]["retry_after_seconds"] >= 1 + + +async def test_a_successful_change_is_audited_and_clears_the_brake(client): + from app.db import base as db_base + from app.db.models import AuditLog + + headers = await _register(client) + bad = {"current_password": "wrong-password", "new_password": "brand-new-password"} + for _ in range(3): + await client.post("/users/me/change-password", headers=headers, json=bad) + ok = {"current_password": "original-password", "new_password": "brand-new-password"} + assert (await client.post("/users/me/change-password", headers=headers, json=ok)).status_code == 200 + async with db_base.AsyncSessionLocal() as session: + rows = (await session.execute(select(AuditLog).where(AuditLog.event_type == "password_changed"))).scalars().all() + assert len(rows) == 1 and json.loads(rows[0].payload_json) == {"user_id": rows[0].user_id} + assert "password" not in rows[0].payload_json.replace("user_id", "") + + +# --- bug reports (SEC-55) ---------------------------------------------------------- + + +async def test_the_11th_bug_report_from_one_ip_waits_but_an_invalid_one_costs_nothing(client): + for _ in range(3): + assert (await client.post("/bug-reports", json={"description": " "})).status_code == 422 + for i in range(10): + assert (await client.post("/bug-reports", json={"description": f"bug {i}"})).status_code == 201 + resp = await client.post("/bug-reports", json={"description": "one too many"}) + assert resp.status_code == 429 and _code(resp) == "rate_limited" + assert 3000 < resp.json()["detail"]["params"]["retry_after_seconds"] <= 3601 + + +# --- paged user list (API-33) ------------------------------------------------------ + + +async def test_admin_users_are_paged_by_id(client): + for i in range(5): + await _register(client, username=f"user{i}") + first = (await client.get("/admin/users", headers=ADMIN, params={"limit": 2})).json() + second = (await client.get("/admin/users", headers=ADMIN, params={"limit": 2, "after_id": first[-1]["id"]})).json() + last = (await client.get("/admin/users", headers=ADMIN, params={"limit": 2, "after_id": second[-1]["id"]})).json() + assert [u["username"] for u in first + second + last] == [f"user{i}" for i in range(5)] + assert len(last) == 1 + assert (await client.get("/admin/users", headers=ADMIN, params={"limit": 501})).status_code == 422 + + +# --- structured errors everywhere (API-16) ----------------------------------------- + + +async def test_admin_errors_are_structured(client): + wrong = await client.get("/admin/users", headers={"X-Admin-Token": "nope"}) + assert wrong.status_code == 403 and _code(wrong) == "invalid_admin_token" + missing = await client.post("/admin/users/999/reset-password", headers=ADMIN) + assert missing.status_code == 404 and _code(missing) == "user_not_found" + report = await client.post("/admin/bug-reports/999/status", headers=ADMIN, json={"status": "read"}) + assert report.status_code == 404 and _code(report) == "bug_report_not_found" + + +async def test_framework_errors_are_structured(client): + absent = await client.get("/users/me") + assert absent.status_code == 401 and _code(absent) == "not_authenticated" + assert absent.headers["www-authenticate"] == "Bearer" + assert _code(await client.get("/no/such/path")) == "not_found" + wrong_method = await client.put("/rounds/current") + assert _code(wrong_method) in {"method_not_allowed", "not_found"} + assert (await client.get("/users/me", headers={"Authorization": "Bearer junk"})).json()["detail"]["code"] == "session_expired" + + +async def test_the_sse_cap_answers_in_the_structured_shape(client, monkeypatch): + from app.api.routes import rounds + from app.rounds.events import RoundEventCapacityError + + def full(_ip): + raise RoundEventCapacityError() + + monkeypatch.setattr(rounds.broadcaster, "subscribe", full) + resp = await client.get("/rounds/stream") + assert resp.status_code == 503 and _code(resp) == "too_many_streams" + + +# --- static files (API-09) --------------------------------------------------------- + + +async def test_static_files_are_revalidated_on_every_use(client): + resp = await client.get("/app.js") + assert resp.status_code == 200 and resp.headers["cache-control"] == "no-cache" + assert resp.headers.get("etag")