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>
96 lines
3.5 KiB
Python
96 lines
3.5 KiB
Python
from datetime import date
|
||
|
||
from conftest import login_ready, new_user
|
||
|
||
|
||
def test_users_are_isolated(admin):
|
||
# Admin legt ein Konto an.
|
||
acc = admin.post("/api/accounts", json={"name": "AdminKonto", "currency": "EUR"}).json()
|
||
|
||
# Frischen Nutzer anlegen und getrennt einloggen.
|
||
username, password = new_user(admin)
|
||
other, _ = login_ready(username, password)
|
||
|
||
# Sieht keine fremden Konten/Buchungen.
|
||
assert other.get("/api/accounts").json() == []
|
||
assert other.get("/api/transactions").json() == []
|
||
# Zugriff auf fremdes Konto -> 404 (nicht 403: existiert für ihn schlicht nicht).
|
||
assert other.get(f"/api/accounts/{acc['id']}").status_code == 404
|
||
# Buchung auf fremdes Konto -> 404.
|
||
r = other.post("/api/transactions", json={
|
||
"type": "expense", "date": str(date.today()), "account_id": acc["id"], "amount_cents": 100,
|
||
})
|
||
assert r.status_code == 404
|
||
# Eigene Startkategorien sind vorhanden.
|
||
assert len(other.get("/api/categories").json()) >= 6
|
||
# Kein Admin: darf keine Nutzer listen.
|
||
assert other.get("/api/users").status_code == 403
|
||
|
||
|
||
def test_kein_pfadwechsel_aus_dem_auslieferungs_ordner(client, tmp_path):
|
||
"""Diese Route liegt VOR jeder Anmeldepruefung. Ohne Begrenzung liefert
|
||
"/../../etc/passwd" genau das aus – der Pfad kommt roh aus der Adresse,
|
||
und "Path / '../x'" verlaesst den Ordner klaglos."""
|
||
from app import main
|
||
|
||
if not main.DIST_DIR.exists():
|
||
import pytest
|
||
pytest.skip("kein gebautes Frontend vorhanden")
|
||
|
||
geheim = main.DIST_DIR.parent.parent / "PRUEF-GEHEIM.txt"
|
||
geheim.write_text("streng geheim", encoding="utf-8")
|
||
try:
|
||
for pfad in ("/../../PRUEF-GEHEIM.txt",
|
||
"/%2e%2e%2f%2e%2e%2fPRUEF-GEHEIM.txt",
|
||
"/../../backend/app/config.py"):
|
||
r = client.get(pfad)
|
||
assert "streng geheim" not in r.text, f"{pfad} gab die Datei heraus"
|
||
assert "APP_DATA_DIR" not in r.text, f"{pfad} gab Quelltext heraus"
|
||
finally:
|
||
geheim.unlink(missing_ok=True)
|
||
|
||
|
||
def test_echte_dateien_werden_weiterhin_ausgeliefert(client):
|
||
"""Die Begrenzung darf die Auslieferung nicht kaputtmachen."""
|
||
from app import main
|
||
|
||
if not (main.DIST_DIR / "index.html").exists():
|
||
import pytest
|
||
pytest.skip("kein gebautes Frontend vorhanden")
|
||
assert client.get("/").status_code == 200
|
||
assert "<!doctype html" in client.get("/irgendeine/spa/route").text.lower()
|
||
|
||
|
||
def test_imap_prueft_das_zertifikat(monkeypatch):
|
||
"""Ohne eigenen Kontext prueft imaplib das Zertifikat NICHT – die
|
||
Verschluesselung schuetzt dann vor niemandem, der sich dazwischenhaengt,
|
||
und das Postfach-Passwort geht mit."""
|
||
import ssl
|
||
|
||
from app.services import mail_import
|
||
|
||
gesehen = {}
|
||
|
||
class FakeSSL:
|
||
def __init__(self, host, port, ssl_context=None, **kw):
|
||
gesehen["context"] = ssl_context
|
||
|
||
def login(self, *a, **kw):
|
||
return "OK", []
|
||
|
||
def __getattr__(self, name):
|
||
return lambda *a, **kw: ("OK", [])
|
||
|
||
monkeypatch.setattr(mail_import.imaplib, "IMAP4_SSL", FakeSSL)
|
||
monkeypatch.setattr(mail_import.geheimnis, "entschluesseln", lambda x: "pw")
|
||
|
||
class Quelle:
|
||
password_enc, host, port, security = "x", "imap.example", 993, "ssl"
|
||
username = "wer@example.com"
|
||
|
||
mail_import._verbinden(Quelle())
|
||
kontext = gesehen["context"]
|
||
assert kontext is not None, "ohne Kontext prueft imaplib nichts"
|
||
assert kontext.verify_mode == ssl.CERT_REQUIRED
|
||
assert kontext.check_hostname is True
|