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 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-18 00:59:25 +02:00
1 parent c1b192f6d1
commit 24e5fc3d8b
23 files changed
+374 -111

No files matched your search

+1 -11
View File
@@ -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
+2 -2
View File
@@ -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
```
+1 -1
View File
@@ -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
+17 -8
View File
@@ -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`.
+12 -1
View File
@@ -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)
+2 -2
View File
@@ -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
+16 -3
View File
@@ -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))
+7
View File
@@ -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)
+4 -2
View File
@@ -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)
+35 -1
View File
@@ -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")
+10 -3
View File
@@ -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) => `
<tr>
+1 -1
View File
@@ -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;
+49
View File
@@ -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': '这是你自己的充值地址 — 请提现到外部钱包。',
@@ -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`
@@ -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.
+12 -21
View File
@@ -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": <JWT>, "address": <indirizzo>}`.
- **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
-1
View File
@@ -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.
+1 -2
View File
@@ -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.
+8 -22
View File
@@ -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`
-2
View File
@@ -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.
+4 -14
View File
@@ -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:<username minuscolo>` | 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:<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:<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:<id utente>`, 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:<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:<id utente>`, 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:<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.
+4 -4
View File
@@ -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`.
+183
View File
@@ -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")