security: TLS, проверка Host, шифрованное запоминание входа и аудит-лог

- TLS через подключаемый overlay docker-compose.tls.yml + Caddyfile:
  Caddy сам получает и продлевает сертификат Let's Encrypt, HSTS
  добавляется на уровне прокси — намеренно там, а не в nginx.conf,
  иначе установки без TLS заперли бы сами себя;
- проверка заголовка Host против DNS rebinding: AURUM_ALLOWED_HOSTS,
  по умолчанию localhost и 127.0.0.1, чужой Host получает 421 с
  подсказкой, какую переменную поправить;
- «запомнить меня на 7 дней» вернулось в безопасном виде: пароль
  шифруется AES-GCM ключом с extractable: false в IndexedDB, вместо
  прежнего base64 в localStorage, который разворачивался обратно в
  пароль одним atob. Требует secure context, поэтому по обычному HTTP
  к LAN-адресу чекбокс не показывается, а не притворяется рабочим;
- аудит-лог необратимых операций (восстановление бэкапа, массовое
  создание транзакций, удаление счёта) в лог контейнера. Не в БД
  сознательно: таблица аудита исчезла бы ровно в том событии, которое
  должна объяснять;
- README: раздел про TLS и таблица двух переменных, решающих, кто
  может достучаться до инстанса.
This commit is contained in:
ZProger
2026-09-10 21:34:40 +03:00
parent f9413aa6e1
commit 14948aff1d
17 changed files with 454 additions and 70 deletions

View File

@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.core.audit import log_destructive
from app.schemas.account import AccountCreate, AccountUpdate, AccountWithBalance
from app.services.account_service import create_account, delete_account, list_accounts, update_account
@@ -31,4 +32,7 @@ async def update_account_route(
@router.delete("/{account_id}", status_code=204)
async def delete_account_route(account_id: int, session: AsyncSession = Depends(get_session)) -> None:
# Takes every transaction on the account with it, so it's worth a line in
# the log even though the UI asks for confirmation first.
await delete_account(session, account_id)
log_destructive("account.deleted", account_id=account_id)

View File

@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.core.audit import log_destructive
from app.schemas.backup import BackupPayload
from app.services.backup_service import build_backup, restore_backup
@@ -15,5 +16,16 @@ async def export_backup(session: AsyncSession = Depends(get_session)) -> BackupP
@router.post("/import")
async def import_backup(payload: BackupPayload, session: AsyncSession = Depends(get_session)) -> dict[str, str]:
# Logged before the fact as well as after: if the restore dies partway
# (it rolls back, but the process could also be killed outright), the
# "started" line is the only evidence it was ever attempted.
log_destructive(
"backup.restore.started",
exported_at=payload.exported_at,
source_app_version=payload.app_version,
accounts=len(payload.accounts),
transactions=len(payload.transactions),
)
await restore_backup(session, payload)
log_destructive("backup.restore.completed", transactions=len(payload.transactions))
return {"status": "ok"}

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.deps import get_session
from app.core.audit import log_destructive
from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType
from app.models.tag import Tag
@@ -228,6 +229,9 @@ async def bulk_create_transactions(
session.add_all(transactions)
await session.commit()
# One request can add thousands of rows (CSV import) — worth a line so an
# unexpected pile of transactions can be dated later.
log_destructive("transactions.bulk_created", count=len(transactions))
return TransactionBulkCreateResult(created=len(transactions))

32
backend/app/core/audit.py Normal file
View File

@@ -0,0 +1,32 @@
"""Audit trail for the handful of operations that destroy or wholesale
replace data.
Aurum is single-user, so this isn't about "who did it" — there's only one
person. It's about *what happened and when*, after the fact. The operations
recorded here are the ones that can't be undone from the UI: restoring a
backup wipes every table before writing the new rows, deleting an account
takes its transactions with it, and a CSV import can add thousands of rows in
one request. When someone opens the app and finds it empty, the container log
is the only place that can say whether a restore ran at 02:14 or whether
something else went wrong.
Deliberately just the standard logger, no table of its own: an audit trail
that lives in the same database it's auditing disappears in exactly the event
it exists to explain (a restore truncates it too). Read it with
`docker compose logs backend`.
"""
import logging
logger = logging.getLogger("aurum.audit")
def log_destructive(action: str, **details: object) -> None:
"""Record one irreversible operation. `details` are rendered as
key=value; keep them to counts and identifiers — never transaction
descriptions, amounts or anything else that would spill the user's
finances into a log file that gets shared when debugging."""
if details:
rendered = " ".join(f"{key}={value}" for key, value in details.items())
logger.warning("%s %s", action, rendered)
else:
logger.warning("%s", action)