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")