Die Auslieferung des Frontends nahm den Pfad roh aus der Adresse: "Path /
'../../x'" verlaesst den Ordner klaglos, und die Route liegt VOR jeder
Anmeldepruefung. Gegen die eigene Instanz nachgestellt – roh und
prozentkodiert kam beides Mal die Datei heraus:
GET /../../GEHEIM.txt -> "geheim"
GET /../../backend/app/config.py -> der Quelltext
Damit war alles lesbar, was der Dienst lesen darf: Quelltext, Konfiguration,
und mit der passenden Zahl von Ebenen die Datenbank samt verschluesseltem
Postfach-Passwort und Bank-Schluessel. Der Pfad wird jetzt aufgeloest und
muss im Auslieferungs-Ordner liegen.
Zweitens prueft der Postfach-Abruf jetzt das Zertifikat. Ohne eigenen
SSL-Kontext tut imaplib das NICHT – die Verschluesselung schuetzt dann vor
niemandem, der sich dazwischenhaengt, und das Postfach-Passwort geht mit.
Gilt fuer beide Wege, SSL und STARTTLS.
Drittens warnt der Start, wenn das Sitzungs-Cookie ohne "Secure" ausgeliefert
wird. Die Vorgabe bleibt bewusst aus: Sie umzustellen sperrt jeden aus, der
Certain im Heimnetz ueber HTTP aufruft – und ein Ausgesperrter saehe die
Ursache nirgends.
Die Befunde stammen aus dem /security-review dieser Sitzung und waren seither
offen. Sie wiegen schwerer, seit die Anwendung fuer die Bankanbindung unter
einer oeffentlichen HTTPS-Adresse erreichbar sein muss.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
291 lines
11 KiB
Python
291 lines
11 KiB
Python
"""FastAPI-Anwendung.
|
||
|
||
Ein einzelner Prozess liefert sowohl die JSON-API unter ``/api/*`` als auch das
|
||
gebaute Frontend (``web/dist``) aus – hinter dem Nginx Proxy Manager des Nutzers.
|
||
Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt.
|
||
"""
|
||
|
||
import logging
|
||
import threading
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
|
||
from fastapi.encoders import jsonable_encoder
|
||
from fastapi.exceptions import RequestValidationError
|
||
from fastapi.responses import JSONResponse
|
||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import FileResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
from .branding import APP_NAME, VERSION, git_sha
|
||
from .config import get_settings
|
||
from .database import SessionLocal
|
||
from .deps import enforce_account_state
|
||
from .migrate import upgrade_to_head
|
||
|
||
log = logging.getLogger(__name__)
|
||
from .routers import (
|
||
accounts,
|
||
bank,
|
||
auth,
|
||
backup,
|
||
budgets,
|
||
categories,
|
||
dashboard,
|
||
documents,
|
||
imports,
|
||
policy,
|
||
inbox,
|
||
mail,
|
||
quick,
|
||
uebertraege,
|
||
rates,
|
||
settings as settings_router,
|
||
transactions,
|
||
users,
|
||
)
|
||
from .seed import ensure_first_admin
|
||
from .services import ecb_rates
|
||
|
||
settings = get_settings()
|
||
|
||
# Pfad zum gebauten Frontend (im Dev von Vite selbst ausgeliefert -> fehlt hier).
|
||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||
DIST_DIR = REPO_ROOT / "web" / "dist"
|
||
# Einmal aufgeloest: Gegen diesen Pfad wird jede Anfrage geprueft.
|
||
DIST_WURZEL = DIST_DIR.resolve()
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
# Schema auf den aktuellen Stand bringen (idempotent).
|
||
upgrade_to_head()
|
||
db = SessionLocal()
|
||
try:
|
||
ensure_first_admin(db)
|
||
finally:
|
||
db.close()
|
||
|
||
# Das Sitzungs-Cookie ohne „Secure" wandert auch über eine unverschlüsselte
|
||
# Verbindung – wer mitliest, ist angemeldet. Die Vorgabe bleibt trotzdem
|
||
# aus: Sie umzustellen sperrt jeden aus, der die App im Heimnetz über HTTP
|
||
# aufruft, und ein Ausgesperrter sieht die Ursache nirgends.
|
||
if not settings.cookie_secure:
|
||
log.warning(
|
||
"APP_COOKIE_SECURE ist aus – das Sitzungs-Cookie geht auch über "
|
||
"unverschlüsselte Verbindungen. Wenn Certain über HTTPS erreichbar "
|
||
"ist (und für die Bankanbindung MUSS es das), setz es auf 1."
|
||
)
|
||
|
||
# EZB-Kurse im HINTERGRUND laden – der Start darf nie darauf warten
|
||
# (langsames/geblocktes Netz oder hängendes DNS würde den Dienst sonst
|
||
# blockieren und der Health-Check nie grün werden).
|
||
if settings.ecb_fetch_on_start:
|
||
def _laden() -> None:
|
||
s = SessionLocal()
|
||
try:
|
||
ecb_rates.ensure_rates_on_startup(s, True)
|
||
finally:
|
||
s.close()
|
||
|
||
threading.Thread(target=_laden, name="ecb-rates", daemon=True).start()
|
||
|
||
# Zahlungs-Mails regelmässig abholen. Wie beim Kursabruf im Hintergrund:
|
||
# Ein langsames oder nicht erreichbares Postfach darf den Dienst nicht
|
||
# blockieren. Fehler landen sichtbar an der Quelle, nicht im Log-Nirwana.
|
||
stopp = threading.Event()
|
||
|
||
def _mails() -> None:
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from .models import MailSource
|
||
from .services import mail_import
|
||
|
||
while not stopp.wait(60):
|
||
s = SessionLocal()
|
||
try:
|
||
jetzt = datetime.now(timezone.utc)
|
||
for quelle in s.query(MailSource).filter(MailSource.is_active.is_(True)):
|
||
faellig = quelle.last_poll_at is None or quelle.last_poll_at + timedelta(
|
||
minutes=quelle.poll_minutes
|
||
) <= jetzt
|
||
if faellig:
|
||
mail_import.abruf_mit_fehlerbehandlung(s, quelle)
|
||
except Exception:
|
||
log.exception("Mail-Abruf fehlgeschlagen")
|
||
finally:
|
||
s.close()
|
||
|
||
threading.Thread(target=_mails, name="mail-abruf", daemon=True).start()
|
||
|
||
# Bankumsätze im selben Takt-Muster. Bewusst ein EIGENER Thread: Ein
|
||
# hängender Bankzugang darf den Mail-Abruf nicht mitreissen.
|
||
#
|
||
# Anders als beim Mail-Abruf entstehen hier BUCHUNGEN. Deshalb steht der
|
||
# Takt je Verbindung auf 0, bis ihn jemand einschaltet – nichts läuft
|
||
# ungefragt los. Gegen doppelte Buchungen sichern die Kennung des Umsatzes,
|
||
# das Startdatum hinter der letzten Buchung und das Übergehen vorgemerkter
|
||
# Umsätze; siehe bank_sync.
|
||
def _bank() -> None:
|
||
from datetime import datetime, timezone
|
||
|
||
from .models import BankConnection, User
|
||
from .services import bank_sync
|
||
|
||
while not stopp.wait(60):
|
||
s = SessionLocal()
|
||
try:
|
||
jetzt = datetime.now(timezone.utc)
|
||
for verbindung in s.query(BankConnection).filter(
|
||
BankConnection.poll_minutes > 0
|
||
):
|
||
if not bank_sync.faellig(verbindung, jetzt):
|
||
continue
|
||
nutzer = s.get(User, verbindung.owner_id)
|
||
if nutzer is None:
|
||
continue
|
||
bank_sync.abruf_mit_fehlerbehandlung(s, nutzer, verbindung)
|
||
except Exception:
|
||
log.exception("Bank-Abruf fehlgeschlagen")
|
||
finally:
|
||
s.close()
|
||
|
||
threading.Thread(target=_bank, name="bank-abruf", daemon=True).start()
|
||
yield
|
||
stopp.set()
|
||
|
||
|
||
app = FastAPI(title=APP_NAME, version=VERSION, lifespan=lifespan)
|
||
|
||
# CORS nur, wenn ausdrücklich konfiguriert (Standard: gleiche Herkunft, aus).
|
||
if settings.cors_origins.strip():
|
||
origins = (
|
||
["*"]
|
||
if settings.cors_origins.strip() == "*"
|
||
else [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||
)
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
@app.middleware("http")
|
||
async def keine_zwischenspeicherung(request, call_next):
|
||
"""API-Antworten nie zwischenspeichern.
|
||
|
||
Ohne Cache-Control darf ein Browser selbst entscheiden, und der Zurueck-
|
||
Knopf zeigt dann Salden von vorhin. Bei Geld ist eine veraltete Anzeige
|
||
schlimmer als eine langsame.
|
||
"""
|
||
antwort = await call_next(request)
|
||
if request.url.path.startswith("/api"):
|
||
antwort.headers["Cache-Control"] = "no-store"
|
||
return antwort
|
||
|
||
|
||
# --- API -------------------------------------------------------------------
|
||
# Die Abhängigkeit hängt am Sammel-Router, nicht an jedem einzelnen Endpunkt:
|
||
# Konten mit offener Pflicht (Passwortwechsel, 2FA-Einrichtung) kommen so an
|
||
# keiner Stelle vorbei, auch nicht an neu hinzukommenden.
|
||
api = APIRouter(prefix="/api", dependencies=[Depends(enforce_account_state)])
|
||
|
||
|
||
@api.get("/health", tags=["meta"])
|
||
def health() -> dict:
|
||
return {"status": "ok"}
|
||
|
||
|
||
@api.get("/version", tags=["meta"])
|
||
def version() -> dict:
|
||
return {"name": APP_NAME, "version": VERSION, "git_sha": git_sha()}
|
||
|
||
|
||
api.include_router(auth.router)
|
||
api.include_router(users.router)
|
||
api.include_router(settings_router.router)
|
||
api.include_router(policy.router)
|
||
api.include_router(accounts.router)
|
||
api.include_router(categories.router)
|
||
api.include_router(transactions.router)
|
||
api.include_router(budgets.router)
|
||
api.include_router(dashboard.router)
|
||
api.include_router(imports.router)
|
||
api.include_router(documents.router)
|
||
api.include_router(backup.router)
|
||
api.include_router(rates.router)
|
||
api.include_router(quick.router)
|
||
api.include_router(inbox.router)
|
||
api.include_router(mail.router)
|
||
api.include_router(bank.router)
|
||
api.include_router(uebertraege.router)
|
||
|
||
# Fehler an den Kurzbefehl-Endpunkten zusätzlich als „message" ausliefern.
|
||
# Der Kurzbefehl auf dem Telefon zeigt genau dieses Feld an; bei einem Fehler
|
||
# steht sonst nur „detail" in der Antwort und die Mitteilung bleibt leer –
|
||
# man sieht dann nicht, DASS etwas schiefging, geschweige denn was.
|
||
def _ist_kurzbefehl(request: Request) -> bool:
|
||
return request.url.path.startswith("/api/quick/")
|
||
|
||
|
||
@app.exception_handler(StarletteHTTPException)
|
||
async def http_fehler(request: Request, exc: StarletteHTTPException):
|
||
inhalt = {"detail": exc.detail}
|
||
if _ist_kurzbefehl(request):
|
||
inhalt["message"] = str(exc.detail)
|
||
inhalt["ok"] = False
|
||
return JSONResponse(status_code=exc.status_code, content=inhalt,
|
||
headers=getattr(exc, "headers", None))
|
||
|
||
|
||
@app.exception_handler(RequestValidationError)
|
||
async def eingabe_fehler(request: Request, exc: RequestValidationError):
|
||
inhalt = {"detail": exc.errors()}
|
||
if _ist_kurzbefehl(request):
|
||
fehlend = [
|
||
".".join(str(t) for t in f.get("loc", ()) if t not in ("body", "query"))
|
||
for f in exc.errors()
|
||
]
|
||
inhalt["message"] = ("Die Anfrage ist unvollständig: "
|
||
+ ", ".join(f for f in fehlend if f)
|
||
+ ". Beim Datei-Kurzbefehl muss der Anfragetext "
|
||
+ "'Formular' sein, mit den Feldern file (Datei) "
|
||
+ "und account_id.")
|
||
inhalt["ok"] = False
|
||
return JSONResponse(status_code=422, content=jsonable_encoder(inhalt))
|
||
|
||
|
||
app.include_router(api)
|
||
|
||
|
||
# --- Frontend (SPA) --------------------------------------------------------
|
||
# Gebaute, gehashte Assets unter /assets aggressiv cachebar ausliefern; alle
|
||
# übrigen (nicht-/api) Pfade auf index.html abbilden, damit Client-seitige
|
||
# Routen wie /accounts direkt aufrufbar sind (verlinkbar).
|
||
if DIST_DIR.exists():
|
||
assets_dir = DIST_DIR / "assets"
|
||
if assets_dir.exists():
|
||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||
|
||
@app.get("/{full_path:path}", include_in_schema=False)
|
||
async def spa(full_path: str):
|
||
# /api-Pfade, die keine echte Route trafen, sind echte 404 (kein SPA).
|
||
if full_path.startswith("api"):
|
||
raise HTTPException(status_code=404, detail="Not Found")
|
||
# Aufgeloest UND geprueft, ob die Datei noch im Auslieferungs-Ordner
|
||
# liegt. Ohne das liefert "/../../etc/passwd" genau das aus: Der Pfad
|
||
# kommt roh aus der Adresse, und "Path / '../x'" verlaesst den Ordner
|
||
# klaglos. Ohne Anmeldung, denn diese Route liegt vor jeder Pruefung.
|
||
try:
|
||
kandidat = (DIST_DIR / full_path).resolve()
|
||
except (OSError, ValueError):
|
||
kandidat = None
|
||
if (full_path and kandidat is not None and kandidat.is_file()
|
||
and kandidat.is_relative_to(DIST_WURZEL)):
|
||
return FileResponse(kandidat)
|
||
return FileResponse(DIST_DIR / "index.html", headers={"Cache-Control": "no-cache"})
|