package webdav import ( "io" "net/http" "strconv" "time" xwebdav "golang.org/x/net/webdav" "aether/internal/acl" "aether/internal/auth" "aether/internal/files" "aether/internal/storage" "aether/pkg/middleware" ) // Brute-force-Schutz für /dav/. Der Endpunkt nutzt Basic Auth und läuft am // Rate-Limit des normalen Logins vorbei – ohne eigene Bremse ließe sich hier // beliebig oft raten. const ( davMaxFailures = 10 davWindow = 15 * time.Minute davLockout = 15 * time.Minute ) // maxChallengeDrain begrenzt, wie viel von einem unauthentifizierten Upload // verworfen wird, um die Verbindung am Leben zu halten – siehe drainForChallenge. const maxChallengeDrain = 4 << 30 // 4 GiB // Handler serves WebDAV at /dav/ with HTTP Basic Auth. type Handler struct { authStore *auth.Store fileStore *files.Store acl *acl.Store storage storage.Storage locks xwebdav.LockSystem limiter *middleware.FailureLimiter } func NewHandler(authStore *auth.Store, fileStore *files.Store, aclStore *acl.Store, st storage.Storage) *Handler { return &Handler{ authStore: authStore, fileStore: fileStore, acl: aclStore, storage: st, locks: xwebdav.NewMemLS(), limiter: middleware.NewFailureLimiter(davMaxFailures, davWindow, davLockout), } } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { clientIP := middleware.RealIP(r) if allowed, retryAfter := h.limiter.Allowed(clientIP); !allowed { w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())+1)) http.Error(w, "Too many failed login attempts", http.StatusTooManyRequests) return } username, password, ok := r.BasicAuth() if !ok || username == "" || password == "" { drainForChallenge(r) challenge(w) return } // Nur App-Passwörter. Das Kontopasswort ist zugleich der Zugang zum // Admin-Bereich – wer es hier errät, bekommt weit mehr als Dateizugriff. // Ein App-Passwort öffnet ausschließlich diesen Endpunkt und lässt sich // einzeln widerrufen, ohne dass jeder andere Client neu eingerichtet werden // muss. Nebeneffekt: ein indizierter Lookup statt eines Argon2-Durchlaufs // bei jeder einzelnen Anfrage (gemessen 0,5 ms statt 22,7 ms). user, err := h.authStore.VerifyAppPassword(r.Context(), username, password) if err != nil { h.limiter.Fail(clientIP) challenge(w) return } h.limiter.Reset(clientIP) quotaHit, denied := false, false dav := &xwebdav.Handler{ FileSystem: newFS(h.fileStore, h.storage, h.acl, user, "aHit, &denied, r.ContentLength), LockSystem: h.locks, Prefix: "/dav", } dav.ServeHTTP(&statusWriter{ResponseWriter: w, quotaHit: "aHit, denied: &denied}, r) } // challenge fordert zur Anmeldung auf. Der Realm-Text nennt ausdrücklich das // App-Passwort: Clients zeigen ihn im Anmeldedialog an, und ohne diesen Hinweis // tippt man dort wieder und wieder sein Kontopasswort ein und versteht nicht, // warum es abgelehnt wird. func challenge(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Basic realm="Aether WebDAV - use an app password, not your account password"`) http.Error(w, "Unauthorized - WebDAV requires an app password (Profile -> App passwords)", http.StatusUnauthorized) } // drainForChallenge liest den Rumpf einer unauthentifizierten Anfrage weg, // bevor die Aufforderung zur Anmeldung rausgeht. // // Klingt verschwenderisch, ist aber der Unterschied zwischen „Upload geht" und // „Upload geht nicht": Manche Clients – KDEs KIO etwa – schicken grundsätzlich // erst ohne Zugangsdaten und wiederholen die Anfrage nach der 401. Dabei senden // sie den Rumpf sofort mit, ohne `Expect: 100-continue` abzuwarten. Antwortet // der Server dann sofort mit 401, kann Go den angefangenen Rumpf nicht mehr // loswerden und schließt die Verbindung. Der Client steckt aber noch mitten im // Schreiben, bekommt einen Verbindungsabbruch und meldet einen nichtssagenden // Fehler, statt die 401 zu lesen und sich anzumelden. // // Wer `Expect: 100-continue` schickt (curl, rclone, Finder), zahlt hier nichts: // Go beantwortet die Aufforderung, bevor auch nur ein Byte des Rumpfes fließt. // Die doppelte Übertragung trifft also nur Clients, die es nicht besser machen. func drainForChallenge(r *http.Request) { if r.ContentLength <= 0 || r.Header.Get("Expect") != "" { return } // Obergrenze, damit eine unauthentifizierte Anfrage den Server nicht // beliebig lange beschäftigen kann. Darüber bleibt es beim Abbruch. _, _ = io.CopyN(io.Discard, r.Body, maxChallengeDrain) } // statusWriter korrigiert Statuscodes, die x/net/webdav pauschal vergibt. // // Die Bibliothek bildet jeden Fehler aus einer Operation auf einen festen Code // ab – ein gescheiterter io.Copy wird zu 405, ein OpenFile-Fehler zu 404. Der // Client zeigt dann „Methode nicht erlaubt" oder „nicht gefunden", obwohl die // Ursache eine ganz andere war. Zwei Fälle lohnen die Korrektur: // // - Kontingent voll → 507 Insufficient Storage (RFC 4918) // - fehlendes Recht → 403 Forbidden // // Ohne die zweite Zuordnung sähe jemand, der in einem nur lesbar freigegebenen // Ordner schreiben will, ein „nicht gefunden" – und suchte den Fehler beim // Pfad statt bei seinen Rechten. type statusWriter struct { http.ResponseWriter quotaHit *bool denied *bool wroteHeader bool } func (w *statusWriter) WriteHeader(code int) { if w.wroteHeader { return } w.wroteHeader = true switch { case *w.quotaHit && code == http.StatusMethodNotAllowed: code = http.StatusInsufficientStorage case *w.denied && (code == http.StatusMethodNotAllowed || code == http.StatusNotFound || code == http.StatusConflict): code = http.StatusForbidden } w.ResponseWriter.WriteHeader(code) }