Compare commits

..
2 Commits
Author SHA1 Message Date
marcandClaude Opus 5 fce8053d09 fix(web): Updates kommen an, ohne den Cache von Hand zu leeren
Nach einem Update sah man die alte Anwendung -- und wer dann einen
Bereich anklickte, der erst beim Klick nachgeladen wird (Kalender,
PDF-Betrachter), bekam entweder gar nichts oder einen Fehler, der nach
einem Programmfehler aussieht ("e is not iterable"). Nachgemessen: Der
Browser lud index-BlqR-CVf.js, ausgeliefert wurde index-arO1lebV.js.

Ursache: Der Server schickte fuer index.html **gar kein**
Cache-Control. Fehlt es, schaetzt der Browser anhand von Last-Modified
selbst, wie lange die Antwort wohl gilt -- bei einer Datei, die seit
Tagen unveraendert war, sind das schnell Stunden. Er benutzte also
weiter die alte Seite, die auf Bausteine zeigt, die es nach dem Update
nicht mehr gibt. Auch die Fetches des Service Workers laufen durch
diesen Cache, weshalb selbst dessen "Netz zuerst" nicht half.

Jetzt die uebliche Zweiteilung, die sich aus den Dateinamen ergibt:

  /assets/…   public, max-age=31536000, immutable
              Der Inhalts-Hash steht im Namen. Anderer Inhalt = anderer
              Name, also darf der Browser sie fuer immer behalten.

  alles sonst no-cache
              index.html, sw.js, das Manifest. "no-cache" heisst nicht
              "nicht speichern", sondern "vor dem Benutzen rueckfragen" --
              mit ETag antwortet der Server meist mit 304 und ein paar
              Byte.

Dazu ein Sicherheitsnetz fuer den Fall, dass doch einmal ein alter
Baustein angefordert wird: vite:preloadError laedt die Seite einmal neu.
Einmal, per sessionStorage-Merkmal -- laege der Fehler woanders, drehte
sich die Seite sonst endlos im Kreis.

Das ist der Unterschied zwischen "ich leere eben den Cache" und einem
Menschen, der am Handy vor einer Seite sitzt, auf der nichts passiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:31:59 +02:00
marcandClaude Opus 5 ec8f162fd5 security(sharing): Selbsttest fuer Freigabelinks, drei Befunde behoben
Freigabelinks sind der einzige Teil von Aether, den man ohne Anmeldung
erreicht -- und hatten als einziger Bereich keinen einzigen Test. Der
neue scripts/sharing-selbsttest.py geht sie mit 24 Pruefungen ab, mit
einem eigens angelegten Zweitnutzer, ohne den sich "fremde Dateien
bleiben fremd" gar nicht pruefen laesst.

Beim ersten Lauf sofort drei Befunde:

1. Der Download-Zaehler blieb auf 0. Die Zaehlung lief in einer Goroutine
   mit dem Kontext der Anfrage -- der wird abgebrochen, sobald die
   Antwort draussen ist, und die Zaehlung verlor das Rennen fast immer.
   Jetzt context.WithoutCancel mit eigener Frist.

2. owner_id, file_id und die Link-Kennung standen in der oeffentlichen
   Antwort. Das ganze Datenbankobjekt durchzureichen war bequem, gab aber
   die Kennung des Besitzers an jeden heraus, der den Link hat: Zwei
   Links desselben Besitzers liessen sich einander zuordnen, ohne dass
   irgendwer angemeldet ist. Jetzt nur noch, was die oeffentliche Seite
   braucht.

3. Passwoerter liessen sich unbegrenzt durchprobieren. Der oeffentliche
   Freigabepfad lag ausserhalb jeder Ratenbegrenzung, und weil jeder
   Versuch Argon2 rechnet, war das zugleich ein billiger Weg, den Server
   zu beschaeftigen. Jetzt dieselbe Fehlversuchssperre wie bei /dav und
   /caldav: zehn Fehlschlaege in 15 Minuten, dann 15 Minuten Pause.

Die Sperre hat eine Folge fuers Testen selbst: Nach einem Lauf ist die
eigene IP 15 Minuten fuer passwortgeschuetzte Links gesperrt. Deshalb
steht die Probe am Ende, und der Test erkennt zu Beginn, ob eine Sperre
aus einem frueheren Lauf noch steht, statt reihenweise Fehlschlaege zu
melden, die keine sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:31:39 +02:00
7 changed files with 384 additions and 8 deletions

No files matched your search

+40
View File
@@ -95,6 +95,46 @@ Dazu eine Ungenauigkeit ohne Folgen: Ein PUT, das einen vorhandenen Termin
überschreibt, antwortet mit `201 Created` statt `204 No Content`. Das kommt überschreibt, antwortet mit `201 Created` statt `204 No Content`. Das kommt
aus go-webdav; Clients prüfen auf 2xx. aus go-webdav; Clients prüfen auf 2xx.
## Freigabelinks: Selbsttest per Skript
Freigabelinks sind der **einzige** Teil von Aether, den man ohne Anmeldung
erreicht. Ein Fehler darin ist kein Anzeigefehler, sondern eine offene Tür.
python3 scripts/sharing-selbsttest.py
24 Prüfungen: anlegen nur angemeldet und nur für **eigene** Dateien (auch der
Admin kommt nicht an fremde), Tokenlänge, öffentlicher Abruf ohne Anmeldung,
Herunterladen byte-genau, Passwortschutz (ohne, falsch, richtig),
Download-Zähler, Widerrufen (danach ist der Link tot), ein Link auf eine
gelöschte Datei liefert nichts, und die Sperre nach wiederholten
Fehlversuchen.
Der Test legt einen Zweitnutzer an – ohne ihn lässt sich „fremde Dateien
bleiben fremd" gar nicht prüfen – und räumt ihn samt Dateien wieder weg.
**Nach einem Lauf ist die eigene IP 15 Minuten gesperrt**, für
passwortgeschützte Freigabelinks (nicht fürs Anmelden). Das ist der Preis der
letzten Prüfung, die genau diese Sperre nachweist. Ein zweiter Lauf innerhalb
der Frist bricht früh ab und sagt es. Beim Entwickeln hilft ein
`docker restart aether-app` – die Sperre liegt im Arbeitsspeicher.
### Was dieser Test beim ersten Lauf gefunden hat
Drei Dinge, alle behoben – hier notiert, weil sie zeigen, wonach man in
diesem Bereich sucht:
- **Der Download-Zähler blieb auf 0.** Die Zählung lief in einer Goroutine
mit dem Kontext der Anfrage – der wird abgebrochen, sobald die Antwort
draußen ist, und die Zählung verlor das Rennen fast immer.
- **`owner_id` stand in der öffentlichen Antwort.** Das ganze
Datenbankobjekt durchzureichen war bequem, gab aber die Kennung des
Besitzers an jeden heraus, der den Link hat: Zwei Links desselben
Besitzers ließen sich einander zuordnen, ohne dass irgendwer angemeldet
ist.
- **Passwörter ließen sich unbegrenzt durchprobieren.** Der öffentliche
Freigabepfad lag außerhalb jeder Ratenbegrenzung; jeder Versuch rechnet
Argon2, war also zugleich ein billiger Weg, den Server zu beschäftigen.
## Notfall: Migration hängt fest ("Dirty database version") ## Notfall: Migration hängt fest ("Dirty database version")
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
+28
View File
@@ -69,6 +69,7 @@ func spaHandler(root string) http.Handler {
} }
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil { if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil {
setStaticCaching(w, upath)
fs.ServeHTTP(w, r) fs.ServeHTTP(w, r)
return return
} }
@@ -81,10 +82,37 @@ func spaHandler(root string) http.Handler {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
setStaticCaching(w, "/index.html")
http.ServeFile(w, r, index) http.ServeFile(w, r, index)
}) })
} }
// setStaticCaching entscheidet, was der Browser behalten darf.
//
// Ohne diese Kopfzeilen setzt er selbst etwas fest: Fehlt Cache-Control,
// schätzt er anhand von Last-Modified, wie lange eine Antwort wohl gilt.
// Für index.html hieß das, dass ein Nutzer nach einem Update tagelang die
// alte Anwendung sehen konnte – die dann Bausteine nachlädt, die es auf dem
// Server nicht mehr gibt. Sichtbar wird das nicht als "veraltet", sondern
// als rätselhafter Fehler mitten in der Anwendung.
//
// Die Aufteilung ist die übliche und ergibt sich aus den Dateinamen selbst:
//
// - /assets/… trägt einen Inhalts-Hash im Namen. Ändert sich der Inhalt,
// ändert sich der Name. Solche Dateien darf der Browser für immer
// behalten – "immutable" spart ihm sogar die Rückfrage.
// - Alles mit festem Namen (index.html, sw.js, das Manifest) muss bei
// jedem Aufruf nachgefragt werden. "no-cache" heißt nicht "nicht
// speichern", sondern "vor dem Benutzen rückfragen": Mit ETag antwortet
// der Server dann meist nur mit 304 und ein paar Byte.
func setStaticCaching(w http.ResponseWriter, upath string) {
if strings.HasPrefix(upath, "/assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
return
}
w.Header().Set("Cache-Control", "no-cache")
}
func main() { func main() {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
+42 -3
View File
@@ -1,11 +1,13 @@
package sharing package sharing
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -19,15 +21,29 @@ import (
"aether/pkg/response" "aether/pkg/response"
) )
// Ein öffentlicher Endpunkt mit Passwort braucht dieselbe Bremse wie /dav
// und /caldav: Ohne sie kann man Passwörter unbegrenzt durchprobieren – und
// weil jeder Versuch Argon2 rechnet, ist das zugleich ein billiger Weg, den
// Server zu beschäftigen.
const (
shareMaxFailures = 10
shareWindow = 15 * time.Minute
shareLockout = 15 * time.Minute
)
type Handler struct { type Handler struct {
store *Store store *Store
fileStore *files.Store fileStore *files.Store
storage storage.Storage storage storage.Storage
auditor *audit.Logger auditor *audit.Logger
limiter *middleware.FailureLimiter
} }
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger) *Handler { func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger) *Handler {
return &Handler{store: store, fileStore: fileStore, storage: st, auditor: auditor} return &Handler{
store: store, fileStore: fileStore, storage: st, auditor: auditor,
limiter: middleware.NewFailureLimiter(shareMaxFailures, shareWindow, shareLockout),
}
} }
// POST /api/v1/files/{id}/share // POST /api/v1/files/{id}/share
@@ -147,8 +163,16 @@ func (h *Handler) GetPublicInfo(w http.ResponseWriter, r *http.Request) {
response.Error(w, http.StatusNotFound, "file not found") response.Error(w, http.StatusNotFound, "file not found")
return return
} }
// Nur, was die öffentliche Seite wirklich braucht. Das ganze
// Datenbankobjekt durchzureichen war bequem, gab aber owner_id und die
// internen Kennungen an jeden heraus, der den Link hat: Zwei Links
// desselben Besitzers ließen sich so einander zuordnen, ohne dass
// irgendwer angemeldet ist.
response.JSON(w, http.StatusOK, map[string]any{ response.JSON(w, http.StatusOK, map[string]any{
"link": link, "has_password": link.HasPassword,
"expires_at": link.ExpiresAt,
"download_count": link.DownloadCount,
"created_at": link.CreatedAt,
"file_name": f.Name, "file_name": f.Name,
"file_size": f.Size, "file_size": f.Size,
"mime_type": f.MimeType, "mime_type": f.MimeType,
@@ -169,15 +193,23 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
} }
if link.HasPassword { if link.HasPassword {
clientIP := middleware.RealIP(r)
if erlaubt, retryAfter := h.limiter.Allowed(clientIP); !erlaubt {
w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())+1))
response.Error(w, http.StatusTooManyRequests, "too many failed attempts")
return
}
var body struct { var body struct {
Password string `json:"password"` Password string `json:"password"`
} }
_ = json.NewDecoder(r.Body).Decode(&body) _ = json.NewDecoder(r.Body).Decode(&body)
ok, _ := auth.VerifyPassword(body.Password, passwordHash) ok, _ := auth.VerifyPassword(body.Password, passwordHash)
if !ok { if !ok {
h.limiter.Fail(clientIP)
response.Error(w, http.StatusUnauthorized, "incorrect password") response.Error(w, http.StatusUnauthorized, "incorrect password")
return return
} }
h.limiter.Reset(clientIP)
} }
f, err := h.fileStore.GetFileByID(r.Context(), link.FileID) f, err := h.fileStore.GetFileByID(r.Context(), link.FileID)
@@ -193,7 +225,14 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
} }
defer rc.Close() defer rc.Close()
go h.store.IncrementDownloads(r.Context(), token) // context.WithoutCancel, nicht r.Context(): Der Request-Kontext wird
// abgebrochen, sobald die Antwort draußen ist – die Zählung verlor das
// Rennen fast immer und der Zähler blieb auf 0 stehen.
zaehlCtx, abbrechen := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
go func() {
defer abbrechen()
h.store.IncrementDownloads(zaehlCtx, token)
}()
w.Header().Set("Content-Type", f.MimeType) w.Header().Set("Content-Type", f.MimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name)) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
+7 -1
View File
@@ -90,8 +90,14 @@ export interface CreateShareRequest {
expires_in_hours?: number expires_in_hours?: number
} }
/** Was ein Freigabelink **ohne Anmeldung** über sich preisgibt. Bewusst
* nicht der ganze ShareLink: Besitzer und interne Kennungen gehen niemanden
* etwas an, der bloß den Link hat. */
export interface PublicShareInfo { export interface PublicShareInfo {
link: ShareLink has_password: boolean
expires_at?: string | null
download_count: number
created_at: string
file_name: string file_name: string
file_size: number file_size: number
mime_type: string mime_type: string
+26
View File
@@ -5,6 +5,32 @@ import App from './App'
// @ts-ignore // @ts-ignore
import './index.css' import './index.css'
/**
* Sicherheitsnetz für den Fall, dass der Browser noch eine alte Fassung der
* Anwendung im Speicher hat und einen Baustein nachlädt, den es auf dem
* Server nicht mehr gibt.
*
* Die Bausteine tragen einen Hash im Namen; nach einem Update sind die alten
* fort. Wer die Seite offen liegen lässt und dann auf einen Bereich klickt,
* der erst dann geladen wird (der Kalender, der PDF-Betrachter), bekommt sonst
* einen Fehler, der nach einem Programmfehler aussieht – oder es passiert
* schlicht nichts.
*
* Einmal neu laden holt die neue Fassung. Das `sessionStorage`-Merkmal
* verhindert dabei das Schlimmere: Läge der Fehler doch woanders, drehte sich
* die Seite sonst endlos im Kreis.
*/
window.addEventListener('vite:preloadError', () => {
if (sessionStorage.getItem('aether-reloaded-for-update')) return
sessionStorage.setItem('aether-reloaded-for-update', '1')
window.location.reload()
})
window.addEventListener('load', () => {
// Erfolgreich geladen: Die Sperre darf wieder fallen, sonst hilft sie beim
// nächsten Update nicht mehr.
setTimeout(() => sessionStorage.removeItem('aether-reloaded-for-update'), 5000)
})
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
+4 -4
View File
@@ -24,7 +24,7 @@ export default function SharePage({ token }: Props) {
sharesApi.getInfo(token) sharesApi.getInfo(token)
.then(data => { .then(data => {
setInfo(data) setInfo(data)
setNeedsPassword(data.link.has_password) setNeedsPassword(data.has_password)
}) })
.catch(() => setError('This link is not valid or has expired.')) .catch(() => setError('This link is not valid or has expired.'))
}, [token]) }, [token])
@@ -118,8 +118,8 @@ export default function SharePage({ token }: Props) {
</h2> </h2>
<p style={{ margin: '0 0 24px', fontSize: 13, color: 'var(--color-text-muted)' }}> <p style={{ margin: '0 0 24px', fontSize: 13, color: 'var(--color-text-muted)' }}>
{formatBytes(info.file_size)} {formatBytes(info.file_size)}
{info.link.expires_at && ( {info.expires_at && (
<span> · Expires {new Date(info.link.expires_at).toLocaleDateString()}</span> <span> · Expires {new Date(info.expires_at).toLocaleDateString()}</span>
)} )}
</p> </p>
@@ -167,7 +167,7 @@ export default function SharePage({ token }: Props) {
</button> </button>
<p style={{ margin: '16px 0 0', fontSize: 11, color: 'var(--color-text-muted)' }}> <p style={{ margin: '16px 0 0', fontSize: 11, color: 'var(--color-text-muted)' }}>
Shared via Aether · Downloaded {info.link.download_count}× Shared via Aether · Downloaded {info.download_count}×
</p> </p>
</> </>
)} )}
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Selbsttest der öffentlichen Freigabelinks.
python3 scripts/sharing-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT]
Freigabelinks sind der **einzige** Teil von Aether, den man ohne Anmeldung
erreicht. Ein Fehler darin ist deshalb kein Anzeigefehler, sondern eine
offene Tür – und genau deshalb gibt es diesen Test.
Er legt einen Zweitnutzer, einen Ordner und Testdateien an und räumt am Ende
alles wieder weg. Vorhandene Dateien werden nicht angefasst.
**Nach einem Lauf ist die eigene IP 15 Minuten gesperrt** – für
passwortgeschützte Freigabelinks, nicht fürs Anmelden. Das ist der Preis der
letzten Prüfung, die genau diese Sperre nachweist. Ein zweiter Lauf innerhalb
der Frist bricht deshalb früh ab und sagt es.
"""
import base64
import http.cookiejar
import json
import sys
import urllib.error
import urllib.request
import uuid
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090").rstrip("/")
USER = sys.argv[2] if len(sys.argv) > 2 else "admin"
PASSWORT = sys.argv[3] if len(sys.argv) > 3 else "admin123"
ok_count = 0
fail = []
hinweise = []
def check(name, cond, detail=""):
global ok_count
if cond:
ok_count += 1
print(f" \033[32mok\033[0m {name}")
else:
fail.append(name)
print(f" \033[31mFEHL\033[0m {name} {detail}")
def hinweis(text):
hinweise.append(text)
print(f" \033[33m!\033[0m {text}")
def sitzung():
"""Eigener Cookie-Topf je Nutzer – sonst überschreibt die zweite
Anmeldung die erste, und der Test prüft am Ende sich selbst."""
jar = http.cookiejar.CookieJar()
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def ruf(opener, method, path, body=None, headers=None, roh=False):
hdrs = dict(headers or {})
data = None
if body is not None:
data = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method)
try:
with (opener or urllib.request.build_opener()).open(req) as r:
payload = r.read()
if roh:
return r.status, payload
text = payload.decode()
return r.status, (json.loads(text).get("data") if text else None)
except urllib.error.HTTPError as e:
return e.code, None
def anmelden(user, passwort):
o = sitzung()
st, d = ruf(o, "POST", "/api/v1/auth/login", {"username": user, "password": passwort})
assert st == 200, f"Anmeldung {user} fehlgeschlagen: {st}"
return o, {"X-CSRF-Token": d["csrf_token"]}
def hochladen(opener, csrf, name, inhalt: bytes, folder_id=None):
"""Multipart von Hand – der Upload nimmt kein JSON."""
grenze = "----aether" + uuid.uuid4().hex
teile = []
if folder_id:
teile.append(f'--{grenze}\r\nContent-Disposition: form-data; name="folder_id"\r\n\r\n{folder_id}\r\n')
kopf = (f'--{grenze}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
f'Content-Type: application/octet-stream\r\n\r\n')
leib = b"".join(t.encode() for t in teile) + kopf.encode() + inhalt + f"\r\n--{grenze}--\r\n".encode()
req = urllib.request.Request(BASE + "/api/v1/files", data=leib, method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={grenze}",
**csrf})
with opener.open(req) as r:
return json.loads(r.read().decode())["data"]
print(f"Freigabe-Selbsttest gegen {BASE}\n")
admin, ADMIN_CSRF = anmelden(USER, PASSWORT)
# ── 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.
fremd_name = "selbsttest-fremd"
fremd_pw = "Fremd!" + uuid.uuid4().hex[:12]
st, fremd = ruf(admin, "POST", "/api/v1/admin/users",
{"username": fremd_name, "display_name": "Selbsttest Fremd",
"password": fremd_pw, "is_admin": False, "quota_bytes": 0}, ADMIN_CSRF)
assert st in (200, 201), f"Zweitnutzer anlegen fehlgeschlagen: {st}"
FREMD_ID = fremd["id"]
eigen = None
fremd_datei = None
links = []
def aufraeumen():
for lid in links:
ruf(admin, "DELETE", f"/api/v1/shares/{lid}", None, ADMIN_CSRF)
if eigen:
ruf(admin, "DELETE", f"/api/v1/files/{eigen['id']}", None, ADMIN_CSRF)
ruf(admin, "DELETE", f"/api/v1/admin/users/{FREMD_ID}", None, ADMIN_CSRF)
try:
INHALT = b"streng geheim " + uuid.uuid4().hex.encode()
eigen = hochladen(admin, ADMIN_CSRF, "selbsttest-freigabe.txt", INHALT)
fremd_op, FREMD_CSRF = anmelden(fremd_name, fremd_pw)
fremd_datei = hochladen(fremd_op, FREMD_CSRF, "selbsttest-fremd.txt", b"gehoert mir")
print("── Anlegen: nur eigene Dateien, nur angemeldet ──")
st, _ = ruf(None, "POST", f"/api/v1/files/{eigen['id']}/share", {})
check("ohne Anmeldung: kein Link", st in (401, 403), st)
# Der Kern: Eine fremde Datei darf sich nicht freigeben lassen – sonst
# macht ein beliebiger Nutzer fremde Daten öffentlich.
st, _ = ruf(fremd_op, "POST", f"/api/v1/files/{eigen['id']}/share", {}, FREMD_CSRF)
check("fremde Datei freigeben: 404", st == 404, st)
st, _ = ruf(admin, "POST", f"/api/v1/files/{fremd_datei['id']}/share", {}, ADMIN_CSRF)
check("auch als Admin nicht: 404", st == 404, st)
st, link = ruf(admin, "POST", f"/api/v1/files/{eigen['id']}/share", {}, ADMIN_CSRF)
check("eigene Datei freigeben (201)", st == 201, st)
links.append(link["id"])
TOKEN = link["token"]
# 24 zufällige Bytes, base64url = 32 Zeichen. Raten ist damit aussichtslos;
# ein kurzer Token wäre die stille Version einer offenen Tür.
check("Token ist lang genug (>= 32 Zeichen)", len(TOKEN) >= 32, len(TOKEN))
print("\n── Öffentlich abrufen ──")
st, info = ruf(None, "GET", f"/share/{TOKEN}")
check("Info ohne Anmeldung abrufbar", st == 200, st)
check("nennt Namen und Größe",
info and info.get("file_name") == "selbsttest-freigabe.txt"
and info.get("file_size") == len(INHALT), info)
# Was der Server öffentlich preisgibt, muss man einmal ausdrücklich prüfen:
# Ein durchgereichtes Datenbankfeld zu viel, und der Hash steht im Netz.
flach = json.dumps(info).lower()
check("kein Passwort-Hash in der Antwort",
"password_hash" not in flach and "$argon2" not in flach, flach[:200])
check("keine Angabe zum Besitzer", "owner_id" not in flach, flach[:200])
st, _ = ruf(None, "GET", "/share/" + "x" * 32)
check("erfundener Token: 404", st == 404, st)
st, daten = ruf(None, "POST", f"/share/{TOKEN}/download", {}, roh=True)
check("Herunterladen ohne Passwort (200)", st == 200, st)
check("Inhalt stimmt byte-genau", daten == INHALT, daten[:40])
st, info = ruf(None, "GET", f"/share/{TOKEN}")
check("Zähler steht auf 1", info and info["download_count"] == 1,
info["download_count"] if info else None)
print("\n── Mit Passwort ──")
st, geschuetzt = ruf(admin, "POST", f"/api/v1/files/{eigen['id']}/share",
{"password": "geheim123"}, ADMIN_CSRF)
links.append(geschuetzt["id"])
GTOKEN = geschuetzt["token"]
check("Link mit Passwort angelegt", st == 201 and geschuetzt["has_password"] is True, st)
st, _ = ruf(None, "POST", f"/share/{GTOKEN}/download", {})
if st == 429:
# Die Sperre aus einem Lauf vor weniger als 15 Minuten steht noch.
# Das ist richtiges Verhalten des Servers, aber der Rest dieses
# Abschnitts kann so nichts mehr aussagen.
hinweis("IP ist noch gesperrt (Lauf vor weniger als 15 Minuten) – "
"Passwortprüfungen übersprungen, bitte später erneut laufen lassen")
raise SystemExit(1 if fail else 0)
check("ohne Passwort: 401", st == 401, st)
st, _ = ruf(None, "POST", f"/share/{GTOKEN}/download", {"password": "falsch"})
check("falsches Passwort: 401", st == 401, st)
st, daten = ruf(None, "POST", f"/share/{GTOKEN}/download", {"password": "geheim123"}, roh=True)
check("richtiges Passwort: Inhalt", st == 200 and daten == INHALT, st)
print("\n── Widerrufen ──")
st, _ = ruf(fremd_op, "DELETE", f"/api/v1/shares/{link['id']}", None, FREMD_CSRF)
check("fremder Link lässt sich nicht löschen", st == 404, st)
st, _ = ruf(admin, "DELETE", f"/api/v1/shares/{link['id']}", None, ADMIN_CSRF)
check("eigener Link gelöscht", st in (200, 204), st)
links.remove(link["id"])
st, _ = ruf(None, "GET", f"/share/{TOKEN}")
check("danach ist der Link tot (404)", st == 404, st)
st, _ = ruf(None, "POST", f"/share/{TOKEN}/download", {})
check("und das Herunterladen auch", st == 404, st)
print("\n── Gelöschte Datei ──")
st, fluechtig = ruf(admin, "POST", f"/api/v1/files/{eigen['id']}/share", {}, ADMIN_CSRF)
links.append(fluechtig["id"])
ruf(admin, "DELETE", f"/api/v1/files/{eigen['id']}", None, ADMIN_CSRF)
# In den Papierkorb geworfen heißt: für die Öffentlichkeit weg. Ein Link,
# der weiterläuft, während die Datei im Papierkorb liegt, wäre eine
# Freigabe, von der der Besitzer nichts mehr weiß.
st, _ = ruf(None, "POST", f"/share/{fluechtig['token']}/download", {})
check("Link auf gelöschte Datei liefert nichts", st in (404, 410), 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.
print("\n── Durchprobieren von Passwörtern ──")
codes = [ruf(None, "POST", f"/share/{GTOKEN}/download", {"password": f"f{i}"})[0]
for i in range(15)]
check("Sperre greift nach wiederholten Fehlversuchen (429)", 429 in codes,
f"letzte Antworten: {codes[-5:]}")
check("und sie greift erst nach mehreren Versuchen", codes[0] == 401, codes[:3])
finally:
aufraeumen()
print(f"\n{ok_count} bestanden, {len(fail)} fehlgeschlagen")
for h in hinweise:
print(f"Hinweis: {h}")
if fail:
print("Fehlgeschlagen: " + ", ".join(fail))
sys.exit(1 if fail else 0)