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 +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>
<Input
id="asset-notes"
maxLength={2000}
value={form.notes}
onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))}
/>

View File

@@ -485,8 +485,11 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
<div>
<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
id="notes"
maxLength={2000}
value={form.notes}
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 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;
interface RememberedEntry {

View File

@@ -49,7 +49,7 @@ const ru = {
"auth.subtitle": "Личные финансы",
"auth.usernameLabel": "Логин",
"auth.passwordLabel": "Пароль",
"auth.rememberMe": "Запомнить меня на 30 дней",
"auth.rememberMe": "Запомнить меня на 7 дней",
"auth.submitButton": "Войти",
"auth.submitting": "Вход…",
"auth.errorInvalidCredentials": "Неверный логин или пароль",
@@ -567,7 +567,7 @@ const en: Record<keyof typeof ru, string> = {
"auth.subtitle": "Personal finance",
"auth.usernameLabel": "Username",
"auth.passwordLabel": "Password",
"auth.rememberMe": "Remember me for 30 days",
"auth.rememberMe": "Remember me for 7 days",
"auth.submitButton": "Sign in",
"auth.submitting": "Signing in…",
"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 { CurrencyCard } from "@/components/settings/CurrencyCard";
import { PreferencesCard } from "@/components/settings/PreferencesCard";
import { useHealth } from "@/hooks/useHealth";
import { useAppSettings } from "@/hooks/useSettings";
import { t } from "@/lib/i18n";
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
// one line of fine print.
const { data: health } = useHealth();
const { data: appSettings } = useAppSettings();
return (
<div className="space-y-5">
@@ -17,9 +17,9 @@ export function SettingsPage() {
<CurrencyCard />
<AlertThresholdsCard />
<BackupCard />
{health?.version && (
{appSettings?.app_version && (
<p className="text-center text-xs text-text-muted">
{t("settings.version", { version: health.version })}
{t("settings.version", { version: appSettings.app_version })}
</p>
)}
</div>

View File

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