security: устранить находки аудита, версия 1.1.7

Конфигурация и эксплуатационный слой:
- nginx: client_max_body_size 64m — восстановление из бэкапа не работало
  вовсе, дефолтный лимит в 1 МБ резал дампы (реальный экспорт ~3.4 МБ)
  с 413 ещё до backend;
- nginx: заголовки безопасности (nosniff, Referrer-Policy,
  Permissions-Policy) и полный Content-Security-Policy. Ради него
  inline-скрипт темы вынесен в public/theme-init.js;
- nginx: limit_req на /api/ — перебор пароля Basic Auth шёл со
  скоростью сети, ни блокировки, ни задержки не было;
- nginx: server_tokens off;
- порт web привязан к 127.0.0.1, адрес настраивается через
  AURUM_BIND_ADDRESS (раньше слушался на всех интерфейсах);
- backend работает от непривилегированного пользователя,
  no-new-privileges для backend и web;
- дефолты пароля Postgres сведены к одному значению в обоих сервисах,
  в .env.example описана ловушка с инициализацией тома.

Приложение:
- версия убрана из неавторизованного /api/health и отдаётся как
  app_version в GET /api/settings;
- /api/docs, /api/redoc и /api/openapi.json отключаются через
  AURUM_ENABLE_DOCS;
- текст исключения при неудачном восстановлении уходит в лог, а не
  клиенту;
- max_length для notes в схемах транзакций, активов и регулярных
  платежей (и в соответствующих полях UI);
- срок «запомнить меня» сокращён с 30 дней до 7;
- pytest обновлён до 9.0.3 (PYSEC-2026-1845), pytest-asyncio до 1.4.0;
- CI: permissions contents:read для GITHUB_TOKEN.
This commit is contained in:
ZProger
2026-09-10 21:12:31 +03:00
parent eba8add92f
commit f9413aa6e1
27 changed files with 268 additions and 78 deletions

View File

@@ -1,6 +1,17 @@
# Copy this file to .env and adjust values before running `docker compose up`. # Copy this file to .env and adjust values before running `docker compose up`.
# --- Postgres --- # --- Postgres ---
# IMPORTANT, and easy to get wrong: Postgres only applies this password when
# it FIRST creates its data directory. Change it later and the running
# database keeps the old one — the value here and the value in the database
# drift apart, and the backend starts failing to connect with a password
# authentication error that looks like it came out of nowhere. So set it
# before the first `docker compose up`. Changing it afterwards means either
# ALTER USER inside the running database, or deleting the aurum_pgdata volume
# (which deletes all your data — export a backup from Settings first).
#
# The database is not published to your host machine (no ports mapping for the
# db service), so it's only reachable from inside the container network.
AURUM_POSTGRES_USER=aurum AURUM_POSTGRES_USER=aurum
AURUM_POSTGRES_PASSWORD=change-me AURUM_POSTGRES_PASSWORD=change-me
AURUM_POSTGRES_DB=aurum AURUM_POSTGRES_DB=aurum
@@ -23,6 +34,13 @@ AURUM_DEFAULT_CURRENCY=USD
# you to set this — nothing else in the app is affected. # you to set this — nothing else in the app is affected.
AURUM_COINGECKO_API_KEY= AURUM_COINGECKO_API_KEY=
# Swagger UI / ReDoc / openapi.json at /api/docs. Left on: the REST API is a
# documented feature (see DOCS.md), and when basic auth is configured these
# sit behind it like every other /api/ route. Set it to false on an instance
# that's reachable from the internet without basic auth — an open
# openapi.json is a complete machine-readable map of every endpoint.
AURUM_ENABLE_DOCS=true
# --- Basic auth (STRONGLY recommended if this instance is reachable from # --- Basic auth (STRONGLY recommended if this instance is reachable from
# anywhere other than your own machine) --- # anywhere other than your own machine) ---
# Aurum has no login system of its own — it's built for one person # Aurum has no login system of its own — it's built for one person
@@ -36,3 +54,10 @@ AURUM_BASIC_AUTH_PASSWORD=
# --- Ports exposed on your host machine --- # --- Ports exposed on your host machine ---
AURUM_WEB_PORT=3000 AURUM_WEB_PORT=3000
# Which host interface Aurum listens on. The default, 127.0.0.1, means "this
# machine only" — nothing else on your network can reach it. Change it to
# 0.0.0.0 to open it up (phone on the same Wi-Fi, another PC, a reverse
# proxy), but set the basic auth variables above first: without them the app
# has no password, and this is the line that decides who can knock.
AURUM_BIND_ADDRESS=127.0.0.1

View File

@@ -8,6 +8,12 @@ on:
push: push:
branches: [main] branches: [main]
# Least privilege for GITHUB_TOKEN: these jobs only ever read the checkout.
# Without this the token inherits the repository default, which can include
# write access — handed to every third-party action in the run.
permissions:
contents: read
jobs: jobs:
backend-tests: backend-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest

15
DOCS.md
View File

@@ -116,9 +116,13 @@ string.
### `GET /api/health` ### `GET /api/health`
No auth required. Returns `{"status": "ok", "version": "1.1.0"}` — `version` is the running app's No auth required. Returns `{"status": "ok"}`. Use this to check the backend is up before hitting
version, the same value shown in the UI under Settings. Use this to check the backend is up before anything else.
hitting anything else.
This response deliberately carries nothing else. It used to include the running app version, but
this is the one endpoint served without authentication — handing the exact release to anyone who
can reach the instance tells them which known issues it still has. The version now comes back as
`app_version` from `GET /settings`, which sits behind auth like everything else.
## Accounts ## Accounts
@@ -763,10 +767,13 @@ Single row, created automatically on first run — there's nothing to create, on
"net_worth_decline_threshold_months": 2, "net_worth_decline_threshold_months": 2,
"risky_allocation_threshold_percent": 20, "risky_allocation_threshold_percent": 20,
"idle_cash_threshold_amount": "1000.00", "idle_cash_threshold_amount": "1000.00",
"idle_cash_threshold_days": 60 "idle_cash_threshold_days": 60,
"app_version": "1.1.7"
} }
``` ```
- `app_version`: read-only, ignored on `PATCH` — the running app's release, shown in the UI under
Settings. It lives here rather than on the unauthenticated `/health`.
- `currency`: 3-letter uppercase code — display-only, no conversion. - `currency`: 3-letter uppercase code — display-only, no conversion.
- `negative_cash_flow_threshold_months` / `net_worth_decline_threshold_months`: consecutive months - `negative_cash_flow_threshold_months` / `net_worth_decline_threshold_months`: consecutive months
before the corresponding alert fires (`1`–`24`). before the corresponding alert fires (`1`–`24`).

View File

@@ -14,6 +14,19 @@ RUN pip install --upgrade pip && pip install -r requirements.txt
COPY . . COPY . .
# Drop root. The app never writes to disk (PYTHONDONTWRITEBYTECODE above, no
# uploads, no local cache), so it only needs read access to /app — which is
# why the source stays root-owned and is merely readable here. Anything that
# manages to execute code in this container now lands as an unprivileged user
# instead of root, which is the difference between a bad day and a container
# escape.
#
# Note for running the test suite (see tests/README.md): installing
# requirements-dev.txt inside the container now needs `--user`, or run the
# exec with `-u root`.
RUN useradd --create-home --uid 10001 aurum
USER aurum
EXPOSE 8000 EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=5 \ HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=5 \

View File

@@ -9,6 +9,9 @@ from app.services.settings_service import get_or_create_app_settings
router = APIRouter(prefix="/settings", tags=["settings"]) router = APIRouter(prefix="/settings", tags=["settings"])
# Both routes still return the ORM row as-is: AppSettingsRead.app_version
# isn't a stored column, it defaults to APP_VERSION when FastAPI validates
# the response (see schemas/settings.py).
@router.get("", response_model=AppSettingsRead) @router.get("", response_model=AppSettingsRead)
async def read_settings(session: AsyncSession = Depends(get_session)) -> AppSettings: async def read_settings(session: AsyncSession = Depends(get_session)) -> AppSettings:
return await get_or_create_app_settings(session) return await get_or_create_app_settings(session)

View File

@@ -4,10 +4,11 @@ from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# Single source of truth for the running app's version — surfaced in the API # Single source of truth for the running app's version — surfaced in the API
# title, /api/health (which the frontend reads to show it in Settings), and # title, in GET /api/settings as app_version (which the frontend reads to show
# embedded in exported backups so an old file can be told apart from a # it in Settings; it deliberately does NOT ride on the unauthenticated
# current one. # /api/health), and embedded in exported backups so an old file can be told
APP_VERSION = "1.1.6" # apart from a current one.
APP_VERSION = "1.1.7"
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -35,6 +36,14 @@ class Settings(BaseSettings):
# rather than silently hitting CoinGecko's much stingier keyless tier. # rather than silently hitting CoinGecko's much stingier keyless tier.
coingecko_api_key: str = "" coingecko_api_key: str = ""
# Swagger/ReDoc/openapi.json at /api/docs. On by default because the API
# is a documented feature of this app (see DOCS.md), and because when
# basic auth is configured these sit behind it like everything else under
# /api/. Turn it off on an instance that's reachable from the internet
# *without* basic auth: an open /api/openapi.json is a complete,
# machine-readable map of every endpoint and payload shape.
enable_docs: bool = True
@property @property
def database_url(self) -> str: def database_url(self) -> str:
return ( return (

View File

@@ -46,9 +46,12 @@ app = FastAPI(
# backend (see frontend/nginx.conf) — everything else falls through to # backend (see frontend/nginx.conf) — everything else falls through to
# the SPA's index.html, which is why the defaults (/docs, /openapi.json) # the SPA's index.html, which is why the defaults (/docs, /openapi.json)
# would silently 404 through the reverse proxy. # would silently 404 through the reverse proxy.
docs_url="/api/docs", # All three go away together when AURUM_ENABLE_DOCS=false — leaving
redoc_url="/api/redoc", # openapi.json reachable would keep handing out the full API map even
openapi_url="/api/openapi.json", # with the Swagger UI itself switched off.
docs_url="/api/docs" if settings.enable_docs else None,
redoc_url="/api/redoc" if settings.enable_docs else None,
openapi_url="/api/openapi.json" if settings.enable_docs else None,
) )
# CORS stays off unless someone deliberately opens it, and credentials are # CORS stays off unless someone deliberately opens it, and credentials are
@@ -88,7 +91,10 @@ app.include_router(crypto.router, prefix="/api")
@app.get("/api/health") @app.get("/api/health")
async def health() -> dict[str, str]: async def health() -> dict[str, str]:
# version rides along so the frontend's Settings page can show which # Status only. This is the one route nginx serves without auth (Docker's
# release is actually running without a separate authenticated endpoint — # HEALTHCHECK and uptime monitors need it — see frontend/nginx.conf), so
# this route is already auth_basic-exempt for Docker's HEALTHCHECK. # anything returned here is world-readable. The running version used to
return {"status": "ok", "version": APP_VERSION} # ride along, which told an unauthenticated caller exactly which release
# they were looking at, and therefore which known issues it still has;
# it now comes back from GET /api/settings, which is behind auth.
return {"status": "ok"}

View File

@@ -23,7 +23,7 @@ class AssetBase(BaseModel):
name: str = Field(min_length=1, max_length=150) name: str = Field(min_length=1, max_length=150)
asset_class: AssetClass asset_class: AssetClass
currency: str = Field(default="USD", min_length=3, max_length=3) currency: str = Field(default="USD", min_length=3, max_length=3)
notes: str | None = None notes: str | None = Field(default=None, max_length=2000)
capital_role: CapitalRole = CapitalRole.NEUTRAL capital_role: CapitalRole = CapitalRole.NEUTRAL
# Rough self-reported monthly net cash flow — informational, not tracked # Rough self-reported monthly net cash flow — informational, not tracked
# transactions (see Asset.monthly_cash_flow). # transactions (see Asset.monthly_cash_flow).
@@ -40,7 +40,7 @@ class AssetUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=150) name: str | None = Field(default=None, min_length=1, max_length=150)
asset_class: AssetClass | None = None asset_class: AssetClass | None = None
currency: str | None = Field(default=None, min_length=3, max_length=3) currency: str | None = Field(default=None, min_length=3, max_length=3)
notes: str | None = None notes: str | None = Field(default=None, max_length=2000)
capital_role: CapitalRole | None = None capital_role: CapitalRole | None = None
monthly_cash_flow: Decimal | None = Field(default=None, max_digits=14, decimal_places=2) monthly_cash_flow: Decimal | None = Field(default=None, max_digits=14, decimal_places=2)
risk_level: RiskLevel | None = None risk_level: RiskLevel | None = None

View File

@@ -15,7 +15,7 @@ class RecurringTransactionCreate(BaseModel):
amount: Decimal = Field(gt=0) amount: Decimal = Field(gt=0)
description: str = Field(min_length=1, max_length=255) description: str = Field(min_length=1, max_length=255)
merchant: str | None = None merchant: str | None = None
notes: str | None = None notes: str | None = Field(default=None, max_length=2000)
frequency: RecurringFrequency frequency: RecurringFrequency
anchor_date: date_ anchor_date: date_
@@ -35,7 +35,7 @@ class RecurringTransactionUpdate(BaseModel):
amount: Decimal | None = Field(default=None, gt=0) amount: Decimal | None = Field(default=None, gt=0)
description: str | None = Field(default=None, min_length=1, max_length=255) description: str | None = Field(default=None, min_length=1, max_length=255)
merchant: str | None = None merchant: str | None = None
notes: str | None = None notes: str | None = Field(default=None, max_length=2000)
frequency: RecurringFrequency | None = None frequency: RecurringFrequency | None = None
anchor_date: date_ | None = None anchor_date: date_ | None = None
is_active: bool | None = None is_active: bool | None = None

View File

@@ -1,15 +1,25 @@
from decimal import Decimal from decimal import Decimal
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
from app.core.config import APP_VERSION
class AppSettingsRead(BaseModel): class AppSettingsRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
currency: str currency: str
negative_cash_flow_threshold_months: int negative_cash_flow_threshold_months: int
net_worth_decline_threshold_months: int net_worth_decline_threshold_months: int
risky_allocation_threshold_percent: int risky_allocation_threshold_percent: int
idle_cash_threshold_amount: Decimal idle_cash_threshold_amount: Decimal
idle_cash_threshold_days: int idle_cash_threshold_days: int
# Not a stored column: the default fills itself in when FastAPI validates
# the ORM row against this model, so neither route has to assemble it.
# It lives on this (authenticated) response rather than on /api/health,
# which is deliberately unauthenticated and so would hand the running
# version to anyone who can reach the instance.
app_version: str = APP_VERSION
class AppSettingsUpdate(BaseModel): class AppSettingsUpdate(BaseModel):

View File

@@ -86,7 +86,7 @@ class TransactionFields(BaseModel):
amount: Decimal = Field(gt=0, max_digits=14, decimal_places=2) amount: Decimal = Field(gt=0, max_digits=14, decimal_places=2)
description: str = Field(min_length=1, max_length=255) description: str = Field(min_length=1, max_length=255)
merchant: str | None = Field(default=None, max_length=150) merchant: str | None = Field(default=None, max_length=150)
notes: str | None = None notes: str | None = Field(default=None, max_length=2000)
date: date_ date: date_
# Auto-capitalizes "траты на продукты" -> "Траты на продукты" so mixed # Auto-capitalizes "траты на продукты" -> "Траты на продукты" so mixed
@@ -152,7 +152,7 @@ class TransactionUpdate(BaseModel):
amount: Decimal | None = Field(default=None, gt=0, max_digits=14, decimal_places=2) amount: Decimal | None = Field(default=None, gt=0, max_digits=14, decimal_places=2)
description: str | None = Field(default=None, min_length=1, max_length=255) description: str | None = Field(default=None, min_length=1, max_length=255)
merchant: str | None = Field(default=None, max_length=150) merchant: str | None = Field(default=None, max_length=150)
notes: str | None = None notes: str | None = Field(default=None, max_length=2000)
date: date_ | None = None date: date_ | None = None
# Omitted -> tags untouched; sent (even as []) -> replaces the full tag set. # Omitted -> tags untouched; sent (even as []) -> replaces the full tag set.
tag_ids: list[int] | None = None tag_ids: list[int] | None = None

View File

@@ -9,6 +9,7 @@ run first, before any row is touched), and any failure during the swap rolls
the database back to exactly where it was, so a bad file never leaves the the database back to exactly where it was, so a bad file never leaves the
app half-restored. app half-restored.
""" """
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import HTTPException from fastapi import HTTPException
@@ -46,6 +47,8 @@ from app.schemas.backup import (
TransactionSplitBackup, TransactionSplitBackup,
) )
logger = logging.getLogger(__name__)
BACKUP_FORMAT_VERSION = 1 BACKUP_FORMAT_VERSION = 1
@@ -304,4 +307,9 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
raise raise
except Exception as exc: except Exception as exc:
await session.rollback() await session.rollback()
raise HTTPException(400, f"Restore failed, no changes were made: {exc}") from exc # The exception text is SQLAlchemy's, naming tables, columns and
# constraints — a free map of the schema for anyone probing the API.
# It goes to the container log, where the operator can actually read
# it; the client gets told only that nothing changed.
logger.exception("Backup restore failed")
raise HTTPException(400, "Restore failed, no changes were made") from exc

View File

@@ -3,6 +3,6 @@
# backend container (see backend/tests/README.md) rather than baking these # backend container (see backend/tests/README.md) rather than baking these
# into the Docker image. # into the Docker image.
-r requirements.txt -r requirements.txt
pytest==8.3.4 pytest==9.0.3
pytest-asyncio==0.25.0 pytest-asyncio==1.4.0
httpx==0.28.1 httpx==0.28.1

View File

@@ -14,12 +14,22 @@ lifetime, then run pytest inside the running `backend` container — it's the
only place with network access to Postgres: only place with network access to Postgres:
```bash ```bash
docker compose exec backend pip install -r requirements-dev.txt docker compose exec backend pip install --user -r requirements-dev.txt
docker compose exec backend pytest -v docker compose exec backend python -m pytest -v
``` ```
Re-run just `pytest -v` for subsequent runs; the `pip install` only needs Re-run just the `python -m pytest -v` line for subsequent runs; the
repeating after a container restart/rebuild. `pip install` only needs repeating after a container restart/rebuild.
Two details that both come from the container running as an unprivileged user
(`aurum`, uid 10001 — see backend/Dockerfile) rather than root:
- `--user` on the install: site-packages belongs to root and isn't writable.
The packages land in `/home/aurum/.local`, which isn't on `PATH`, hence
`python -m pytest` instead of a bare `pytest`.
- pytest prints a `PytestCacheWarning` about not being able to write
`/app/.pytest_cache`. Expected — the source tree is read-only to the app
user by design. Tests still pass; add `-p no:cacheprovider` to silence it.
## Adding a test ## Adding a test

View File

@@ -4,7 +4,14 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: ${AURUM_POSTGRES_USER:-aurum} POSTGRES_USER: ${AURUM_POSTGRES_USER:-aurum}
POSTGRES_PASSWORD: ${AURUM_POSTGRES_PASSWORD:-aurum} # "change-me" so this matches .env.example exactly: with no .env at all
# the fallback here used to be "aurum" while the template said
# "change-me", i.e. "the default password" meant two different things
# depending on whether the file existed. Postgres isn't published to the
# host (no ports: below), so reaching it means already being on the
# container network — but see .env.example for the trap that makes this
# worth getting right the first time.
POSTGRES_PASSWORD: ${AURUM_POSTGRES_PASSWORD:-change-me}
POSTGRES_DB: ${AURUM_POSTGRES_DB:-aurum} POSTGRES_DB: ${AURUM_POSTGRES_DB:-aurum}
volumes: volumes:
- aurum_pgdata:/var/lib/postgresql/data - aurum_pgdata:/var/lib/postgresql/data
@@ -17,9 +24,17 @@ services:
backend: backend:
build: ./backend build: ./backend
restart: unless-stopped restart: unless-stopped
# Nothing here ever needs to gain privileges (no setuid binaries, no sudo),
# so forbid it outright — this is what stops a compromised process from
# escalating back to root inside the container.
security_opt:
- no-new-privileges:true
environment: environment:
AURUM_POSTGRES_USER: ${AURUM_POSTGRES_USER:-aurum} AURUM_POSTGRES_USER: ${AURUM_POSTGRES_USER:-aurum}
AURUM_POSTGRES_PASSWORD: ${AURUM_POSTGRES_PASSWORD:-aurum} # Must stay identical to the db service's fallback above — if the two
# drift, an install without .env brings up a database with one password
# and a backend dialling it with another.
AURUM_POSTGRES_PASSWORD: ${AURUM_POSTGRES_PASSWORD:-change-me}
AURUM_POSTGRES_DB: ${AURUM_POSTGRES_DB:-aurum} AURUM_POSTGRES_DB: ${AURUM_POSTGRES_DB:-aurum}
AURUM_POSTGRES_HOST: db AURUM_POSTGRES_HOST: db
AURUM_POSTGRES_PORT: 5432 AURUM_POSTGRES_PORT: 5432
@@ -28,6 +43,7 @@ services:
# Free CoinGecko Demo key (coingecko.com/en/api/pricing) — the Crypto # Free CoinGecko Demo key (coingecko.com/en/api/pricing) — the Crypto
# tab's price sync 400s with a clear message until this is set. # tab's price sync 400s with a clear message until this is set.
AURUM_COINGECKO_API_KEY: ${AURUM_COINGECKO_API_KEY:-} AURUM_COINGECKO_API_KEY: ${AURUM_COINGECKO_API_KEY:-}
AURUM_ENABLE_DOCS: ${AURUM_ENABLE_DOCS:-true}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -35,8 +51,19 @@ services:
web: web:
build: ./frontend build: ./frontend
restart: unless-stopped restart: unless-stopped
# nginx's master still needs root to bind :80 and to write the htpasswd
# file at boot (docker-entrypoint.d/20-basic-auth.sh), so this one can't
# drop to a non-root user without reworking both — but it can still be
# barred from escalating any further.
security_opt:
- no-new-privileges:true
ports: ports:
- "${AURUM_WEB_PORT:-3000}:80" # Bound to loopback by default: published as "3000:80" the app answers
# on every interface, so anyone on the same Wi-Fi reaches it — and by
# default there is no password on it at all (see 20-basic-auth.sh).
# Set AURUM_BIND_ADDRESS=0.0.0.0 to reach it from other devices, but
# set AURUM_BASIC_AUTH_USER/PASSWORD before you do.
- "${AURUM_BIND_ADDRESS:-127.0.0.1}:${AURUM_WEB_PORT:-3000}:80"
environment: environment:
# Both unset = no login screen at all. See frontend/docker-entrypoint.d/20-basic-auth.sh. # Both unset = no login screen at all. See frontend/docker-entrypoint.d/20-basic-auth.sh.
AURUM_BASIC_AUTH_USER: ${AURUM_BASIC_AUTH_USER:-} AURUM_BASIC_AUTH_USER: ${AURUM_BASIC_AUTH_USER:-}

View File

@@ -6,24 +6,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b0b0b" /> <meta name="theme-color" content="#0b0b0b" />
<title>Aurum — Личные финансы</title> <title>Aurum — Личные финансы</title>
<script> <!-- Runs before the first paint to avoid a wrong-theme flash. Kept in its
// Applies the saved theme (light/dark/system) to <html> before the own file (public/theme-init.js), not inline, so the CSP in
// first paint, so an explicit override never flashes the wrong theme frontend/nginx.conf can forbid inline scripts entirely. -->
// for a frame while React/lib/theme.ts loads. "system" needs no <script src="/theme-init.js"></script>
// attribute at all — index.css's prefers-color-scheme media query
// already handles that case with zero JS.
(function () {
var stored = localStorage.getItem("aurum:theme");
if (stored === "light" || stored === "dark") {
document.documentElement.setAttribute("data-theme", stored);
}
var isDark =
stored === "dark" || (stored !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
var meta = document.querySelector('meta[name="theme-color"]');
// Kept in sync by hand with index.css's --surface-0 light/dark values.
if (meta) meta.setAttribute("content", isDark ? "#0d0d0d" : "#f9f9f7");
})();
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,9 +1,53 @@
# Rate limit shared by every /api/ request. This file is included from inside
# nginx's http{} block, which is the only context where a zone can be declared.
# Basic Auth has no lockout, no captcha and no delay of its own, so without
# this the single instance password can be guessed at network speed. The rate
# is deliberately well above what the UI needs (a dashboard load fires a
# dozen or so queries at once, and CSV import pages through history) and still
# cuts a brute-force attempt by orders of magnitude.
limit_req_zone $binary_remote_addr zone=aurum_api:10m rate=20r/s;
server { server {
listen 80; listen 80;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Don't advertise the exact nginx version — it turns "is this instance
# vulnerable to X" into a lookup instead of a probe.
server_tokens off;
# Security headers, applied at server level so every location inherits
# them. Note for whoever edits this next: adding an add_header inside a
# location block drops ALL of these for that location — nginx does not
# merge them.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
# Enforced. This started out as Content-Security-Policy-Report-Only, but
# Report-Only without a report-uri/report-to endpoint is inert in Firefox
# — it says so itself in the console: "CSP will not block and cannot
# report violations of this policy". So an empty console proved nothing,
# and the choice was between standing up a report-collecting endpoint for
# a single self-hosted app or enforcing and watching. Enforcing wins: a
# violation then shows up in every browser's console *and* as a visibly
# broken element, and rolling back is renaming this one header.
#
# Why each part is the way it is:
# script-src 'self' — verified: the built bundle contains no eval/
# new Function, and the theme script was moved
# out of index.html into /theme-init.js.
# style-src 'unsafe-inline' — unavoidable for now: 27 components use
# style={{...}} and Recharts injects inline
# styles of its own.
# img-src ... coingecko — coin icons come straight from CoinGecko's
# CDN (thumb_url on a holding); both the
# current and the older asset host are listed.
# img-src blob: — the chart/download paths build blob URLs.
# connect-src 'self' — the API is same-origin; nothing else is called.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://coin-images.coingecko.com https://assets.coingecko.com; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# Always open, no auth — this is what Docker's own HEALTHCHECK and any # Always open, no auth — this is what Docker's own HEALTHCHECK and any
# external uptime monitor hit, and it returns nothing but {"status":"ok"}. # external uptime monitor hit, and it returns nothing but {"status":"ok"}.
# docker-entrypoint.d/20-basic-auth.sh writes basic-auth.conf; without an # docker-entrypoint.d/20-basic-auth.sh writes basic-auth.conf; without an
@@ -11,6 +55,11 @@ server {
# sets below and healthchecks would start failing with 401. # sets below and healthchecks would start failing with 401.
location = /api/health { location = /api/health {
auth_basic off; auth_basic off;
# Rate limited too: this is the one unauthenticated route, so it's the
# one an attacker can hammer for free. Docker's HEALTHCHECK hits it
# once every 10s, nowhere near the limit.
limit_req zone=aurum_api burst=40 nodelay;
limit_req_status 429;
proxy_pass http://backend:8000/api/health; proxy_pass http://backend:8000/api/health;
proxy_set_header Host $host; proxy_set_header Host $host;
} }
@@ -20,6 +69,21 @@ server {
# aren't set, so this is a no-op by default) — see docker-entrypoint.d/. # aren't set, so this is a no-op by default) — see docker-entrypoint.d/.
location /api/ { location /api/ {
include /etc/nginx/basic-auth.conf; include /etc/nginx/basic-auth.conf;
limit_req zone=aurum_api burst=40 nodelay;
limit_req_status 429;
# Backup restore posts the whole snapshot as one JSON body, and a
# real one runs to several MB — nginx's 1m default silently rejected
# those with a 413 before they ever reached the backend, which meant
# backups could be taken but not restored. Bounded on purpose rather
# than disabled (0), so an oversized upload still can't exhaust memory.
client_max_body_size 64m;
client_body_timeout 60s;
# A large restore rewrites every table in one transaction; the default
# 60s read timeout can cut that off midway.
proxy_read_timeout 120s;
proxy_pass http://backend:8000/api/; proxy_pass http://backend:8000/api/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;

View File

@@ -1,7 +1,7 @@
{ {
"name": "aurum-frontend", "name": "aurum-frontend",
"private": true, "private": true,
"version": "1.1.6", "version": "1.1.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -0,0 +1,24 @@
// Applies the saved theme (light/dark/system) to <html> before the first
// paint, so an explicit override never flashes the wrong theme for a frame
// while React/lib/theme.ts loads. "system" needs no attribute at all —
// index.css's prefers-color-scheme media query already handles that case
// with zero JS.
//
// Lives in public/ as its own file rather than inline in index.html so the
// Content-Security-Policy in frontend/nginx.conf can forbid inline scripts
// outright — an inline block would need either 'unsafe-inline' (which gives
// up most of what CSP buys) or a hash that has to be regenerated by hand on
// every edit to these lines. Loaded as a plain synchronous <script src> in
// <head>, which still blocks the first paint, so the no-flash behaviour is
// unchanged.
(function () {
var stored = localStorage.getItem("aurum:theme");
if (stored === "light" || stored === "dark") {
document.documentElement.setAttribute("data-theme", stored);
}
var isDark =
stored === "dark" || (stored !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
var meta = document.querySelector('meta[name="theme-color"]');
// Kept in sync by hand with index.css's --surface-0 light/dark values.
if (meta) meta.setAttribute("content", isDark ? "#0d0d0d" : "#f9f9f7");
})();

View File

@@ -1,6 +0,0 @@
import { api } from "@/api/client";
import type { HealthStatus } from "@/types";
export function fetchHealth() {
return api.get<HealthStatus>("/health");
}

View File

@@ -208,6 +208,7 @@ export function AssetFormModal({ open, onClose, asset }: AssetFormModalProps) {
<Label htmlFor="asset-notes">{t("netWorth.form.notesLabel")}</Label> <Label htmlFor="asset-notes">{t("netWorth.form.notesLabel")}</Label>
<Input <Input
id="asset-notes" id="asset-notes"
maxLength={2000}
value={form.notes} value={form.notes}
onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))} onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))}
/> />

View File

@@ -485,8 +485,11 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
<div> <div>
<Label htmlFor="notes">{t("transactions.form.notesLabel")}</Label> <Label htmlFor="notes">{t("transactions.form.notesLabel")}</Label>
{/* Matches the server's max_length on notes — without it an overlong
note only fails on save, as an untranslated 422. */}
<Input <Input
id="notes" id="notes"
maxLength={2000}
value={form.notes} value={form.notes}
onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))} onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))}
/> />

View File

@@ -1,8 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { fetchHealth } from "@/api/health";
// The running version only changes on a deploy (a full page reload), so
// there's no need to ever refetch within a session.
export function useHealth() {
return useQuery({ queryKey: ["health"], queryFn: fetchHealth, staleTime: Infinity });
}

View File

@@ -20,7 +20,11 @@ import { useSyncExternalStore } from "react";
*/ */
const SESSION_KEY = "aurum:basicAuth"; const SESSION_KEY = "aurum:basicAuth";
const REMEMBER_KEY = "aurum:basicAuth:remember"; const REMEMBER_KEY = "aurum:basicAuth:remember";
const REMEMBER_DAYS = 30; // 7, not 30: what's stored is the Basic Auth header, i.e. the instance
// password recoverable in plaintext by anyone who can read localStorage (an
// XSS, a browser extension, someone with the machine). It can't be revoked
// server-side, so the only lever on that exposure is how long it sits there.
const REMEMBER_DAYS = 7;
const REMEMBER_MS = REMEMBER_DAYS * 24 * 60 * 60 * 1000; const REMEMBER_MS = REMEMBER_DAYS * 24 * 60 * 60 * 1000;
interface RememberedEntry { interface RememberedEntry {

View File

@@ -49,7 +49,7 @@ const ru = {
"auth.subtitle": "Личные финансы", "auth.subtitle": "Личные финансы",
"auth.usernameLabel": "Логин", "auth.usernameLabel": "Логин",
"auth.passwordLabel": "Пароль", "auth.passwordLabel": "Пароль",
"auth.rememberMe": "Запомнить меня на 30 дней", "auth.rememberMe": "Запомнить меня на 7 дней",
"auth.submitButton": "Войти", "auth.submitButton": "Войти",
"auth.submitting": "Вход…", "auth.submitting": "Вход…",
"auth.errorInvalidCredentials": "Неверный логин или пароль", "auth.errorInvalidCredentials": "Неверный логин или пароль",
@@ -567,7 +567,7 @@ const en: Record<keyof typeof ru, string> = {
"auth.subtitle": "Personal finance", "auth.subtitle": "Personal finance",
"auth.usernameLabel": "Username", "auth.usernameLabel": "Username",
"auth.passwordLabel": "Password", "auth.passwordLabel": "Password",
"auth.rememberMe": "Remember me for 30 days", "auth.rememberMe": "Remember me for 7 days",
"auth.submitButton": "Sign in", "auth.submitButton": "Sign in",
"auth.submitting": "Signing in…", "auth.submitting": "Signing in…",
"auth.errorInvalidCredentials": "Incorrect username or password", "auth.errorInvalidCredentials": "Incorrect username or password",

View File

@@ -2,14 +2,14 @@ import { AlertThresholdsCard } from "@/components/settings/AlertThresholdsCard";
import { BackupCard } from "@/components/settings/BackupCard"; import { BackupCard } from "@/components/settings/BackupCard";
import { CurrencyCard } from "@/components/settings/CurrencyCard"; import { CurrencyCard } from "@/components/settings/CurrencyCard";
import { PreferencesCard } from "@/components/settings/PreferencesCard"; import { PreferencesCard } from "@/components/settings/PreferencesCard";
import { useHealth } from "@/hooks/useHealth"; import { useAppSettings } from "@/hooks/useSettings";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
export function SettingsPage() { export function SettingsPage() {
// Purely informational — if /api/health hasn't answered yet (or is // Purely informational — if the settings request hasn't answered yet (or is
// unreachable), just show nothing rather than a loading/error state for // unreachable), just show nothing rather than a loading/error state for
// one line of fine print. // one line of fine print.
const { data: health } = useHealth(); const { data: appSettings } = useAppSettings();
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -17,9 +17,9 @@ export function SettingsPage() {
<CurrencyCard /> <CurrencyCard />
<AlertThresholdsCard /> <AlertThresholdsCard />
<BackupCard /> <BackupCard />
{health?.version && ( {appSettings?.app_version && (
<p className="text-center text-xs text-text-muted"> <p className="text-center text-xs text-text-muted">
{t("settings.version", { version: health.version })} {t("settings.version", { version: appSettings.app_version })}
</p> </p>
)} )}
</div> </div>

View File

@@ -565,10 +565,8 @@ export interface AppSettings {
risky_allocation_threshold_percent: number; risky_allocation_threshold_percent: number;
idle_cash_threshold_amount: string; idle_cash_threshold_amount: string;
idle_cash_threshold_days: number; idle_cash_threshold_days: number;
} /** Read-only: the running backend release. Comes back here rather than
* from /api/health, which is served without auth. */
export interface HealthStatus { app_version: string;
status: string;
version: string;
} }