fix: Ohne Anmeldung liessen sich beliebige Dateien vom Server lesen
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>
This commit is contained in:
+24
-3
@@ -54,6 +54,8 @@ 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
|
||||
@@ -66,6 +68,17 @@ async def lifespan(app: FastAPI):
|
||||
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).
|
||||
@@ -263,7 +276,15 @@ if DIST_DIR.exists():
|
||||
# /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")
|
||||
candidate = DIST_DIR / full_path
|
||||
if full_path and candidate.is_file():
|
||||
return FileResponse(candidate)
|
||||
# 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"})
|
||||
|
||||
Reference in New Issue
Block a user