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

@@ -12,13 +12,16 @@ RUN apk add --no-cache openssl
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.d/20-basic-auth.sh /docker-entrypoint.d/20-basic-auth.sh
COPY docker-entrypoint.d/25-allowed-hosts.sh /docker-entrypoint.d/25-allowed-hosts.sh
# sed strips any stray CR: cloned on Windows with core.autocrlf=true the
# script arrives as CRLF, the kernel reads the shebang as "/bin/sh\r", and
# scripts arrive as CRLF, the kernel reads the shebang as "/bin/sh\r", and
# nginx dies on boot with "not found" (exit 127). .gitattributes fixes this
# at the repository level; this line covers checkouts that predate it.
RUN sed -i 's/\r$//' /docker-entrypoint.d/20-basic-auth.sh \
&& chmod +x /docker-entrypoint.d/20-basic-auth.sh \
&& touch /etc/nginx/basic-auth.conf
# The empty .conf files matter: nginx.conf includes both unconditionally, and
# an include of a missing file is a startup error.
RUN sed -i 's/\r$//' /docker-entrypoint.d/20-basic-auth.sh /docker-entrypoint.d/25-allowed-hosts.sh \
&& chmod +x /docker-entrypoint.d/20-basic-auth.sh /docker-entrypoint.d/25-allowed-hosts.sh \
&& touch /etc/nginx/basic-auth.conf /etc/nginx/allowed-hosts.conf
EXPOSE 80

View File

@@ -0,0 +1,45 @@
#!/bin/sh
# Runs before nginx starts, same convention as 20-basic-auth.sh next door.
#
# Writes the Host allow-list that nginx.conf includes. This is what stops DNS
# rebinding: a page on the open internet can point its own hostname at
# 127.0.0.1, wait for the DNS TTL to expire, and then have the victim's own
# browser issue same-origin requests to whatever is listening there. The
# browser's origin checks don't help — as far as it's concerned the request
# really is same-origin — but the Host header still carries the attacker's
# domain, so refusing hostnames we don't expect closes it.
#
# AURUM_ALLOWED_HOSTS is a space- or comma-separated list. Default covers
# local access only; add your own hostname when you serve Aurum under one
# (see .env.example). "*" disables the check entirely.
set -eu
FRAGMENT=/etc/nginx/allowed-hosts.conf
HOSTS="${AURUM_ALLOWED_HOSTS:-localhost 127.0.0.1}"
if [ "$HOSTS" = "*" ]; then
: > "$FRAGMENT"
echo "[aurum] Host header check disabled (AURUM_ALLOWED_HOSTS=*)." >&2
return 0 2>/dev/null || exit 0
fi
# "a,b c" -> "a|b|c", with regex metacharacters in each entry escaped so an
# IP's dots can't match any character.
PATTERN="$(printf '%s' "$HOSTS" \
| tr ',' ' ' \
| tr -s ' ' '\n' \
| sed '/^$/d' \
| sed 's/[].[^$*\/+?(){}|\\]/\\&/g' \
| paste -sd '|' -)"
cat > "$FRAGMENT" <<EOF
# Generated at container start by docker-entrypoint.d/25-allowed-hosts.sh.
# \$host is the Host header without the port, so entries here are bare
# hostnames. "if" is normally best avoided in nginx, but a bare "return"
# inside it is one of the two documented-safe uses.
if (\$host !~* '^(${PATTERN})\$') {
return 421 "Aurum: unexpected Host header '\$host'. Add it to AURUM_ALLOWED_HOSTS in .env (see .env.example), or set that variable to * to disable this check.\n";
}
EOF
echo "[aurum] Host header allow-list: ${HOSTS}" >&2

View File

@@ -17,6 +17,11 @@ server {
# vulnerable to X" into a lookup instead of a probe.
server_tokens off;
# Rejects requests whose Host header isn't one we expect (DNS rebinding —
# see docker-entrypoint.d/25-allowed-hosts.sh, which generates this and
# explains the attack). Empty file when the check is switched off.
include /etc/nginx/allowed-hosts.conf;
# 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

View File

@@ -2,7 +2,7 @@ import { type ReactNode, useEffect, useState } from "react";
import { Logo } from "@/components/layout/Logo";
import { LoginScreen } from "@/components/auth/LoginScreen";
import { NoAuthModal } from "@/components/auth/NoAuthModal";
import { checkCredentials, useAuthHeader } from "@/lib/auth";
import { checkCredentials, useAuthHeader, whenAuthRestored } from "@/lib/auth";
// "not-configured" and "authenticated" both render `children` the same way,
// but they must stay distinct: only "not-configured" means this instance has
@@ -30,13 +30,20 @@ export function LoginGate({ children }: { children: ReactNode }) {
return;
}
let cancelled = false;
checkCredentials(null).then((result) => {
if (cancelled) return;
// A network/backend error here isn't an auth problem — don't block
// the user behind a login screen for an unrelated outage, let the
// app's own per-page error states (e.g. dashboard.errorLoading) explain it.
setProbe(result === "unauthorized" ? "required" : "not-configured");
});
// Wait for the encrypted "remember me" credential to be read back before
// concluding anything: it arrives asynchronously (IndexedDB + WebCrypto),
// and probing first would show the login screen for a moment to someone
// who is, in fact, already logged in. When it does arrive it sets the
// header, which re-runs this effect down the authenticated path above.
void whenAuthRestored()
.then(() => (cancelled ? null : checkCredentials(null)))
.then((result) => {
if (cancelled || result === null) return;
// A network/backend error here isn't an auth problem — don't block
// the user behind a login screen for an unrelated outage, let the
// app's own per-page error states (e.g. dashboard.errorLoading) explain it.
setProbe(result === "unauthorized" ? "required" : "not-configured");
});
return () => {
cancelled = true;
};

View File

@@ -3,7 +3,7 @@ import { Logo } from "@/components/layout/Logo";
import { Button } from "@/components/ui/Button";
import { Card, CardContent } from "@/components/ui/Card";
import { Input, Label } from "@/components/ui/Input";
import { buildBasicAuthHeader, checkCredentials, setCredentials } from "@/lib/auth";
import { buildBasicAuthHeader, checkCredentials, isRememberSupported, setCredentials } from "@/lib/auth";
import { useTranslation } from "@/lib/i18n";
type Status = "idle" | "submitting" | "invalid" | "unreachable";
@@ -19,13 +19,18 @@ export function LoginScreen() {
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(false);
const [status, setStatus] = useState<Status>("idle");
// Remembering encrypts the credential with WebCrypto, which browsers only
// expose in a secure context — over plain HTTP to a LAN address there's no
// safe way to persist it, so the option is hidden rather than offered and
// quietly ignored. See lib/credentialVault.ts.
const canRemember = isRememberSupported();
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setStatus("submitting");
const result = await checkCredentials(buildBasicAuthHeader(username, password));
if (result === "ok") {
setCredentials(username, password, remember);
setCredentials(username, password, canRemember && remember);
return;
}
setStatus(result === "unauthorized" ? "invalid" : "unreachable");
@@ -67,15 +72,19 @@ export function LoginScreen() {
/>
</div>
<label className="flex items-center gap-2 text-xs text-text-muted">
<input
type="checkbox"
checked={remember}
onChange={(event) => setRemember(event.target.checked)}
className="h-3.5 w-3.5 accent-text-primary"
/>
{t("auth.rememberMe")}
</label>
{canRemember ? (
<label className="flex items-center gap-2 text-xs text-text-muted">
<input
type="checkbox"
checked={remember}
onChange={(event) => setRemember(event.target.checked)}
className="h-3.5 w-3.5 accent-text-primary"
/>
{t("auth.rememberMe")}
</label>
) : (
<p className="text-xs text-text-muted">{t("auth.sessionOnlyHint")}</p>
)}
{status === "invalid" && <p className="text-sm text-danger">{t("auth.errorInvalidCredentials")}</p>}
{status === "unreachable" && <p className="text-sm text-danger">{t("auth.errorUnreachable")}</p>}

View File

@@ -1,5 +1,7 @@
import { useSyncExternalStore } from "react";
import { forgetCredential, isVaultAvailable, recallCredential, rememberCredential } from "@/lib/credentialVault";
/**
* Client-side mirror of the HTTP Basic Auth credentials Aurum's own login
* screen collects (see components/auth/LoginScreen.tsx), so every fetch can
@@ -9,45 +11,31 @@ import { useSyncExternalStore } from "react";
* this only avoids ever triggering that native prompt, by never letting an
* unauthenticated request happen without us attaching the header ourselves.
*
* Two storage tiers, chosen at login time by the "remember me" checkbox:
* - sessionStorage (default): gone as soon as the tab closes.
* - localStorage, with an explicit expiry stamped into the stored value:
* survives closing the tab/browser, but only for REMEMBER_DAYS — an
* unbounded "stay logged in forever" is too much for a finance app.
* The expiry is only checked when this module loads (i.e. on page load/
* reload) — a tab left open across the expiry moment without reloading
* keeps working until its next reload or a 401 forces a fresh check.
* Two tiers, chosen by the "remember me" checkbox:
* - sessionStorage (default): plain, but gone the moment the browser closes.
* - the encrypted vault (lib/credentialVault.ts): survives a restart for
* REMEMBER_DAYS. It used to be plain localStorage, which meant the
* instance password sat on disk recoverable with a single atob() — the
* vault encrypts it under a key the browser refuses to hand back.
*
* The vault needs a secure context, so on plain HTTP to anything but
* localhost the remember tier is simply unavailable (isRememberSupported()) —
* the login screen hides the checkbox rather than silently not remembering.
*/
const SESSION_KEY = "aurum:basicAuth";
const REMEMBER_KEY = "aurum:basicAuth:remember";
// 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.
// Where the pre-vault "remember me" tier kept its plaintext copy. Read never,
// deleted always: an install upgrading past that version would otherwise
// leave the password sitting on disk for up to a week with nothing left to
// clear it.
const LEGACY_REMEMBER_KEY = "aurum:basicAuth:remember";
const REMEMBER_DAYS = 7;
const REMEMBER_MS = REMEMBER_DAYS * 24 * 60 * 60 * 1000;
interface RememberedEntry {
header: string;
expiresAt: number; // epoch ms
}
function readRemembered(): string | null {
function forgetLegacyRememberedCredentials(): void {
try {
const raw = localStorage.getItem(REMEMBER_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<RememberedEntry>;
if (typeof parsed.header !== "string" || typeof parsed.expiresAt !== "number") {
localStorage.removeItem(REMEMBER_KEY);
return null;
}
if (Date.now() >= parsed.expiresAt) {
localStorage.removeItem(REMEMBER_KEY);
return null;
}
return parsed.header;
localStorage.removeItem(LEGACY_REMEMBER_KEY);
} catch {
return null;
// storage unavailable — nothing was stored there either
}
}
@@ -59,11 +47,13 @@ function readSession(): string | null {
}
}
function readStored(): string | null {
return readRemembered() ?? readSession();
export function isRememberSupported(): boolean {
return isVaultAvailable();
}
let currentHeader: string | null = readStored();
forgetLegacyRememberedCredentials();
let currentHeader: string | null = readSession();
const listeners = new Set<() => void>();
function notify(): void {
@@ -74,6 +64,25 @@ export function getAuthHeader(): string | null {
return currentHeader;
}
/** Reading the vault is async (IndexedDB + WebCrypto), unlike the
* sessionStorage tier that's already resolved by the time this module
* finishes loading. LoginGate awaits this before deciding anything, so a
* remembered login doesn't flash the login screen on its way in. Started
* eagerly at module load rather than on demand, so the work overlaps with
* React mounting instead of following it. */
const restored: Promise<void> = (async () => {
if (currentHeader !== null) return;
const remembered = await recallCredential();
// A login that happened while the vault was being read wins — it's newer.
if (remembered === null || currentHeader !== null) return;
currentHeader = remembered;
notify();
})();
export function whenAuthRestored(): Promise<void> {
return restored;
}
// btoa() only handles Latin1 — the UI is bilingual RU/EN, so a Cyrillic
// password has to survive this, not just ASCII ones.
function encodeUtf8Base64(value: string): string {
@@ -89,22 +98,22 @@ export function buildBasicAuthHeader(username: string, password: string): string
return `Basic ${encodeUtf8Base64(`${username}:${password}`)}`;
}
export function setCredentials(username: string, password: string, remember: boolean): void {
export function setCredentials(username: string, password: string, remember = false): void {
currentHeader = buildBasicAuthHeader(username, password);
try {
if (remember) {
const entry: RememberedEntry = { header: currentHeader, expiresAt: Date.now() + REMEMBER_MS };
localStorage.setItem(REMEMBER_KEY, JSON.stringify(entry));
sessionStorage.removeItem(SESSION_KEY);
} else {
sessionStorage.setItem(SESSION_KEY, currentHeader);
localStorage.removeItem(REMEMBER_KEY);
}
sessionStorage.setItem(SESSION_KEY, currentHeader);
} catch {
// storage unavailable (private browsing, storage disabled) — the header
// still works for the rest of this tab's life via the in-memory
// variable above, it just won't survive a refresh.
}
// Fire-and-forget: nothing downstream waits on the write, and a vault that
// refuses to store only costs this login its persistence.
if (remember) {
void rememberCredential(currentHeader, REMEMBER_MS);
} else {
void forgetCredential();
}
notify();
}
@@ -116,10 +125,14 @@ export function clearCredentials(): void {
currentHeader = null;
try {
sessionStorage.removeItem(SESSION_KEY);
localStorage.removeItem(REMEMBER_KEY);
forgetLegacyRememberedCredentials();
} catch {
// ignore — nothing to clean up if storage was never usable
}
// A 401 means the stored credential is wrong or revoked, so the remembered
// copy is wrong too — leaving it would restore the same rejected header on
// the next reload and loop the user straight back to the login screen.
void forgetCredential();
notify();
}

View File

@@ -0,0 +1,135 @@
/**
* Encrypted at-rest storage for the Basic Auth header, so "remember me" can
* survive a browser restart without leaving the instance password readable on
* disk.
*
* How it works: an AES-GCM key is generated with `extractable: false` and
* kept in IndexedDB. The browser will happily store and hand back that
* CryptoKey, but `crypto.subtle.exportKey` refuses to reveal its bytes — so
* page code can decrypt with it and still cannot read it. Alongside it sits
* the ciphertext of the header plus an expiry.
*
* What this actually buys, stated honestly:
* - Someone who copies the profile directory, restores a disk backup, reads
* synced storage, or just opens DevTools gets ciphertext. Previously they
* got `Basic dXNlcjpwYXNz`, which is the password in one `atob()`.
* - It does NOT stop code executing *inside* the page: an XSS can simply
* call decrypt() the same way the app does. That vector is what the
* Content-Security-Policy in frontend/nginx.conf is there to close;
* this and that are two halves of the same answer.
*
* Requires a secure context — `crypto.subtle` is undefined on plain HTTP to
* anything but localhost. That's a real configuration here, not a hypothetical
* (AURUM_BIND_ADDRESS makes LAN access over http:// possible), so callers must
* check isVaultAvailable() and fall back to session-only storage.
*/
const DB_NAME = "aurum-auth";
const DB_VERSION = 1;
const STORE = "vault";
const KEY_ID = "wrapping-key";
const BLOB_ID = "remembered-credential";
interface StoredBlob {
iv: Uint8Array;
ciphertext: ArrayBuffer;
expiresAt: number; // epoch ms
}
export function isVaultAvailable(): boolean {
return (
typeof indexedDB !== "undefined" &&
typeof crypto !== "undefined" &&
typeof crypto.subtle !== "undefined" &&
typeof crypto.subtle.generateKey === "function"
);
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE)) {
request.result.createObjectStore(STORE);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function runRequest<T>(store: IDBObjectStore, request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
store.transaction.onabort = () => reject(store.transaction.error);
});
}
async function withStore<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => Promise<T>): Promise<T> {
const db = await openDb();
try {
return await run(db.transaction(STORE, mode).objectStore(STORE));
} finally {
db.close();
}
}
/** Reuses the existing key when there is one: regenerating it on every login
* would strand any ciphertext already written under the old key. */
async function getOrCreateKey(): Promise<CryptoKey> {
const existing = await withStore("readonly", (store) => runRequest<CryptoKey | undefined>(store, store.get(KEY_ID)));
if (existing) return existing;
// extractable: false is the whole point — see the module comment.
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
await withStore("readwrite", (store) => runRequest(store, store.put(key, KEY_ID)));
return key;
}
export async function rememberCredential(header: string, ttlMs: number): Promise<void> {
if (!isVaultAvailable()) return;
try {
const key = await getOrCreateKey();
// A fresh IV per encryption: reusing one with AES-GCM under the same key
// is what breaks the mode outright.
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(header));
const blob: StoredBlob = { iv, ciphertext, expiresAt: Date.now() + ttlMs };
await withStore("readwrite", (store) => runRequest(store, store.put(blob, BLOB_ID)));
} catch {
// Storage denied or unavailable (private window, quota, disabled) —
// login still works for this session, it just won't be remembered.
}
}
export async function recallCredential(): Promise<string | null> {
if (!isVaultAvailable()) return null;
try {
const blob = await withStore("readonly", (store) => runRequest<StoredBlob | undefined>(store, store.get(BLOB_ID)));
if (!blob) return null;
if (Date.now() >= blob.expiresAt) {
await forgetCredential();
return null;
}
// Copied into a fresh view: structured clone hands the IV back typed over
// ArrayBufferLike, which doesn't satisfy WebCrypto's BufferSource.
const iv = new Uint8Array(blob.iv);
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, await getOrCreateKey(), blob.ciphertext);
return new TextDecoder().decode(plaintext);
} catch {
// Tampered ciphertext, a key that no longer matches, or storage trouble.
// Nothing recoverable either way — drop it and fall back to the login
// screen rather than leaving a blob around that can never be read.
await forgetCredential();
return null;
}
}
export async function forgetCredential(): Promise<void> {
if (typeof indexedDB === "undefined") return;
try {
await withStore("readwrite", (store) => runRequest(store, store.delete(BLOB_ID)));
} catch {
// ignore — nothing usable to clean up
}
}

View File

@@ -50,6 +50,7 @@ const ru = {
"auth.usernameLabel": "Логин",
"auth.passwordLabel": "Пароль",
"auth.rememberMe": "Запомнить меня на 7 дней",
"auth.sessionOnlyHint": "Вход сохраняется до закрытия браузера. Запоминание надолго требует HTTPS или localhost.",
"auth.submitButton": "Войти",
"auth.submitting": "Вход…",
"auth.errorInvalidCredentials": "Неверный логин или пароль",
@@ -568,6 +569,7 @@ const en: Record<keyof typeof ru, string> = {
"auth.usernameLabel": "Username",
"auth.passwordLabel": "Password",
"auth.rememberMe": "Remember me for 7 days",
"auth.sessionOnlyHint": "You stay signed in until you close the browser. Staying signed in for longer needs HTTPS or localhost.",
"auth.submitButton": "Sign in",
"auth.submitting": "Signing in…",
"auth.errorInvalidCredentials": "Incorrect username or password",