security(sharing): Freigabelink prüft die Prüfsumme, Gruppenordner teilbar
Beim Lesen von internal/sharing fiel eine ungleiche Zusage auf: Der angemeldete Download prüft seit dem 12.09. die SHA-256-Summe beim Ausliefern und lässt die Verbindung fallen, wenn sie nicht stimmt. Der öffentliche Freigabelink lief durch ein blankes io.Copy – ungeprüft. Ausgerechnet der Weg nach draussen. Wer die Datei angemeldet holt, ist der Eigentümer und merkt, wenn etwas kaputt ist; wer einen Link bekommt, merkt es nicht. In CLAUDE.md steht das unter „nicht verhandelbar": beim Upload berechnen, beim Download verifizieren. Die Regel galt an einer von zwei Stellen. Die Mechanik liegt jetzt in files/verified.go und wird von beiden gerufen. Nachgewiesen mit gekipptem Bit auf der Platte: Über den Freigabelink kommt die Datei ebenfalls nicht mehr vollständig an. Gruppenordner: Create übergab user.ID statt des aufgelösten Prinzipals – eine Datei im Gruppenordner liess sich nicht verlinken, auch nicht vom Mitglied. Marcs Entscheidung: Das soll gehen, der Empfänger sieht ohnehin nur die Datei, nie den Ordner darum. Verlangt wird Schreibrecht: Einen Link zu erzeugen heisst, die Datei öffentlich zu machen; wer sie nur geliehen lesen darf, soll sie nicht weiterreichen können (403). expires_in_hours hatte keine Obergrenze. time.Duration rechnet in Nanosekunden; ab etwa 2,6 Mio Stunden läuft int64 über, die Dauer wird negativ, und der Link ist abgelaufen in dem Moment, in dem er entsteht – ohne Fehlermeldung. Jetzt zehn Jahre, darüber 400. Dazu X-Content-Type-Options: nosniff, das der angemeldete Weg längst setzt. Sechs Go-Tests für internal/sharing, unter Mutation nachgewiesen: Eigentümerprüfung entfernt, Ablaufprüfung entfernt und Token verkürzt machen jeweils genau den zugehörigen Test rot. Selbsttests erweitert: sharing 24 → 31 Prüfungen, pruefsumme 14 → 16. Geprüft: go build/vet sauber, alle Go-Pakete grün, 241 Prüfungen der sieben Selbsttest-Suiten grün, 48 Vitest-Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a7528d7365
commit
6fab2388ca
9 files changed
+628
-74
No files matched your search
@@ -92,6 +92,25 @@ def herunterladen(file_id):
|
||||
return False, None, type(e).__name__ + ": " + str(e)[:80]
|
||||
|
||||
|
||||
def oeffentlich_holen(token):
|
||||
"""Dasselbe ueber den Freigabelink – ohne Anmeldung.
|
||||
|
||||
Bis zum 25.09.2026 lief hier ein blankes io.Copy: Wer die Datei ueber
|
||||
einen Link bekam, bekam sie ungeprueft. Ausgerechnet auf dem Weg, auf dem
|
||||
sie das Haus verlaesst.
|
||||
"""
|
||||
req = urllib.request.Request(BASE + f"/share/{token}/download",
|
||||
data=b"{}", method="POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.build_opener().open(req) as r:
|
||||
erwartet = int(r.headers.get("Content-Length", "-1"))
|
||||
daten = r.read()
|
||||
return (erwartet < 0 or len(daten) == erwartet), daten, ""
|
||||
except Exception as e:
|
||||
return False, None, type(e).__name__ + ": " + str(e)[:80]
|
||||
|
||||
|
||||
def blobpfad(name):
|
||||
"""Wo der Blob liegt.
|
||||
|
||||
@@ -118,9 +137,12 @@ assert st == 200, f"Anmeldung fehlgeschlagen: {st}"
|
||||
CSRF = {"X-CSRF-Token": login["csrf_token"]}
|
||||
|
||||
angelegt = []
|
||||
geteilt = []
|
||||
|
||||
|
||||
def aufraeumen():
|
||||
for lid in geteilt:
|
||||
api("DELETE", f"/api/v1/shares/{lid}", None, CSRF)
|
||||
for fid in angelegt:
|
||||
api("DELETE", f"/api/v1/files/{fid}", None, CSRF)
|
||||
st, papier = api("GET", "/api/v1/trash", None, CSRF)
|
||||
@@ -168,6 +190,19 @@ try:
|
||||
check("und was ankam, ist kuerzer als angekuendigt", len(daten) < groesse,
|
||||
f"{len(daten)} von {groesse}")
|
||||
|
||||
# 5. Und jetzt derselbe Weg nach draussen: ueber einen Freigabelink.
|
||||
# Die Datei ist dieselbe und immer noch kaputt – die Zusage muss
|
||||
# hier genauso gelten.
|
||||
st, link = api("POST", f"/api/v1/files/{datei['id']}/share", {}, CSRF)
|
||||
if st == 201 and link:
|
||||
geteilt.append(link["id"])
|
||||
voll, daten, fehler = oeffentlich_holen(link["token"])
|
||||
check("auch ueber den Freigabelink kommt sie NICHT vollstaendig an",
|
||||
not voll,
|
||||
f"{len(daten or b'')} von {groesse} Bytes – heil ausgeliefert!")
|
||||
else:
|
||||
check("Freigabelink angelegt", False, st)
|
||||
|
||||
# 4. Der Server muss es gesagt haben.
|
||||
log = logzeilen(marke)
|
||||
if log:
|
||||
|
||||
@@ -72,6 +72,20 @@ def ruf(opener, method, path, body=None, headers=None, roh=False):
|
||||
return e.code, None
|
||||
|
||||
|
||||
def mit_kopfzeilen(method, path, body=None):
|
||||
"""Wie `ruf`, gibt aber die Antwortkopfzeilen zurueck. Gebraucht fuer
|
||||
nosniff – das steht nirgends im Rumpf."""
|
||||
data = json.dumps(body).encode() if body is not None else b""
|
||||
req = urllib.request.Request(BASE + path, data=data, method=method,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.build_opener().open(req) as r:
|
||||
r.read()
|
||||
return r.status, dict(r.headers)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, dict(e.headers)
|
||||
|
||||
|
||||
def anmelden(user, passwort):
|
||||
o = sitzung()
|
||||
st, d = ruf(o, "POST", "/api/v1/auth/login", {"username": user, "password": passwort})
|
||||
@@ -98,6 +112,11 @@ def hochladen(opener, csrf, name, inhalt: bytes, folder_id=None):
|
||||
print(f"Freigabe-Selbsttest gegen {BASE}\n")
|
||||
|
||||
admin, ADMIN_CSRF = anmelden(USER, PASSWORT)
|
||||
# **Achtung:** `/auth/me` dreht den CSRF-Token weiter (siehe TESTING.md). Wer
|
||||
# danach den alten weiterbenutzt, bekommt 403 – also den neuen uebernehmen.
|
||||
_, ich = ruf(admin, "GET", "/api/v1/auth/me")
|
||||
ADMIN_ID = ich["user"]["id"]
|
||||
ADMIN_CSRF = {"X-CSRF-Token": ich["csrf_token"]}
|
||||
|
||||
# ── Zweitnutzer: nur mit ihm lässt sich prüfen, dass fremde Dateien fremd
|
||||
# bleiben. Ein Test, der nur das eigene Konto kennt, kann das nicht.
|
||||
@@ -112,11 +131,15 @@ FREMD_ID = fremd["id"]
|
||||
eigen = None
|
||||
fremd_datei = None
|
||||
links = []
|
||||
gruppen = []
|
||||
dateien = []
|
||||
|
||||
|
||||
def aufraeumen():
|
||||
for lid in links:
|
||||
ruf(admin, "DELETE", f"/api/v1/shares/{lid}", None, ADMIN_CSRF)
|
||||
for did in dateien:
|
||||
ruf(admin, "DELETE", f"/api/v1/files/{did}", None, ADMIN_CSRF)
|
||||
if eigen:
|
||||
ruf(admin, "DELETE", f"/api/v1/files/{eigen['id']}", None, ADMIN_CSRF)
|
||||
# Löschen heißt Papierkorb – ein Test, der nur so weit aufräumt, hinterlässt
|
||||
@@ -126,6 +149,8 @@ def aufraeumen():
|
||||
if f.get("name", "").startswith("selbsttest-"):
|
||||
ruf(admin, "DELETE", f"/api/v1/trash/{f['id']}", None, ADMIN_CSRF)
|
||||
ruf(admin, "DELETE", f"/api/v1/admin/users/{FREMD_ID}", None, ADMIN_CSRF)
|
||||
for gid in gruppen:
|
||||
ruf(admin, "DELETE", f"/api/v1/admin/groups/{gid}", None, ADMIN_CSRF)
|
||||
|
||||
|
||||
try:
|
||||
@@ -222,6 +247,69 @@ try:
|
||||
st, _ = ruf(None, "POST", f"/share/{fluechtig['token']}/download", {})
|
||||
check("Link auf gelöschte Datei liefert nichts", st in (404, 410), st)
|
||||
|
||||
# ── Seit dem 25.09.2026 ────────────────────────────────────────────────
|
||||
print("\n── Gruppenordner, Grenzen, Kopfzeilen ──")
|
||||
|
||||
# **Gruppendatei teilbar.** Vorher übergab Create `user.ID` statt des
|
||||
# aufgelösten Prinzipals – eine Datei im Gruppenordner liess sich nicht
|
||||
# verlinken, auch nicht vom Mitglied. Die Antwort lautete „file not found".
|
||||
gname = "selbsttest-gruppe-" + uuid.uuid4().hex[:6]
|
||||
st, gruppe = ruf(admin, "POST", "/api/v1/admin/groups",
|
||||
{"name": gname, "description": "Selbsttest"}, ADMIN_CSRF)
|
||||
if st in (200, 201):
|
||||
gruppen.append(gruppe["id"])
|
||||
ruf(admin, "POST", f"/api/v1/admin/groups/{gruppe['id']}/members",
|
||||
{"user_id": ADMIN_ID}, ADMIN_CSRF)
|
||||
# Der Wurzelordner der Gruppe taucht in der normalen Ordnerliste auf.
|
||||
_, wurzeln = ruf(admin, "GET", "/api/v1/files")
|
||||
gordner = next((f for f in (wurzeln or {}).get("folders", [])
|
||||
if f.get("group_id")), None)
|
||||
check("Gruppenordner ist da", gordner is not None)
|
||||
if gordner:
|
||||
gdatei = hochladen(admin, ADMIN_CSRF, "selbsttest-gruppe.txt",
|
||||
b"in der gruppe", gordner["id"])
|
||||
dateien.append(gdatei["id"])
|
||||
st, glink = ruf(admin, "POST", f"/api/v1/files/{gdatei['id']}/share",
|
||||
{}, ADMIN_CSRF)
|
||||
check("Datei im Gruppenordner laesst sich verlinken", st == 201, st)
|
||||
if st == 201:
|
||||
links.append(glink["id"])
|
||||
st, _ = ruf(None, "GET", f"/share/{glink['token']}")
|
||||
check("und der Link funktioniert oeffentlich", st == 200, st)
|
||||
|
||||
# Frische Datei: Die weiter oben ist inzwischen geloescht – die Abschnitte
|
||||
# davor pruefen genau das.
|
||||
frisch = hochladen(admin, ADMIN_CSRF, "selbsttest-frist.txt", b"frist")
|
||||
dateien.append(frisch["id"])
|
||||
|
||||
# **Obergrenze fuer den Ablauf.** Ohne sie laeuft time.Duration ueber, die
|
||||
# Frist wird negativ, und der Link ist tot in dem Moment, in dem er
|
||||
# entsteht – ohne Fehlermeldung.
|
||||
st, _ = ruf(admin, "POST", f"/api/v1/files/{frisch['id']}/share",
|
||||
{"expires_in_hours": 9_000_000_000}, ADMIN_CSRF)
|
||||
check("unsinnige Ablauffrist wird abgelehnt (400)", st == 400, st)
|
||||
|
||||
st, kurz = ruf(admin, "POST", f"/api/v1/files/{frisch['id']}/share",
|
||||
{"expires_in_hours": 24}, ADMIN_CSRF)
|
||||
check("vernuenftige Ablauffrist geht durch", st == 201, st)
|
||||
if st == 201:
|
||||
links.append(kurz["id"])
|
||||
st, kopf = mit_kopfzeilen("POST", f"/share/{kurz['token']}/download")
|
||||
check("oeffentlicher Download setzt nosniff",
|
||||
kopf.get("X-Content-Type-Options") == "nosniff", dict(kopf))
|
||||
|
||||
# **Nur-Lese-Freigabe darf nicht weiterverteilen.** Wer eine Datei geliehen
|
||||
# bekommt, soll sie nicht oeffentlich machen koennen.
|
||||
st, _ = ruf(admin, "POST", f"/api/v1/files/{frisch['id']}/acl",
|
||||
{"subject_id": FREMD_ID, "subject_type": "user",
|
||||
"permission": "read"}, ADMIN_CSRF)
|
||||
if st in (200, 201):
|
||||
fremd_sitzung, FREMD_CSRF = anmelden(fremd_name, fremd_pw)
|
||||
st, _ = ruf(fremd_sitzung, "POST", f"/api/v1/files/{frisch['id']}/share",
|
||||
{}, FREMD_CSRF)
|
||||
check("nur geliehen gelesen -> kein eigener Freigabelink (403)",
|
||||
st == 403, st)
|
||||
|
||||
# Zum Schluss, mit Absicht: Diese Probe sperrt die eigene IP für 15
|
||||
# Minuten. Stünde sie weiter oben, fielen alle folgenden Passwortprüfungen
|
||||
# in die selbst gestellte Falle.
|
||||
|
||||
Reference in new issue
Block a user