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

+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`.