package calendar import ( "io" "net/http" "strconv" "strings" "time" "github.com/emersion/go-webdav/caldav" "aether/internal/auth" "aether/pkg/middleware" ) // Brute-force-Schutz für /caldav/, aus denselben Gründen wie beim // WebDAV-Endpunkt: Basic Auth läuft am normalen Login-Rate-Limit vorbei. const ( davMaxFailures = 10 davWindow = 15 * time.Minute davLockout = 15 * time.Minute ) // Handler serves CalDAV at /caldav/ with HTTP Basic Auth (App-Passwörter). type Handler struct { authStore *auth.Store store *Store dav *caldav.Handler limiter *middleware.FailureLimiter } func NewHandler(authStore *auth.Store, store *Store) *Handler { return &Handler{ authStore: authStore, store: store, dav: &caldav.Handler{Backend: NewBackend(store), Prefix: "/caldav"}, 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 == "" { challenge(w) return } // Nur App-Passwörter, aus demselben Grund wie bei /dav/: das // Kontopasswort öffnet auch den Admin-Bereich. user, err := h.authStore.VerifyAppPassword(r.Context(), username, password) if err != nil { h.limiter.Fail(clientIP) challenge(w) return } h.limiter.Reset(clientIP) // Manche Clients folgen dem 308-Redirect von /.well-known/caldav mit // GET statt PROPFIND, oder ein Mensch öffnet die Adresse zum Testen im // Browser. Ohne diese Abkürzung würde das an GetCalendarObject // durchgereicht, das für Sammlungen (Principal, Home-Set, Kalender) mit // 404 antwortet – ein Client, der so den Dienst prüft, bräche die // Einrichtung ab, bevor er je ein PROPFIND schickt. if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !strings.HasSuffix(r.URL.Path, ".ics") { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) if r.Method == http.MethodGet { io.WriteString(w, "Dies ist eine CalDAV-Sammlung. Mit einem CalDAV-Client verbinden (PROPFIND), nicht mit dem Browser.\n") } return } // Ein Pfad, der auf einen *anderen* Benutzer zeigt, ist ein Fehler und // muss auch so aussehen. Ohne diese Prüfung antwortet go-webdav mit // einem leeren 207: Der Client sieht dann einen Kalenderbestand ohne // Kalender und meldet „nichts gefunden", statt „falsche Adresse" – die // stille Variante desselben Fehlers, die bei der Einrichtung Stunden // kostet. if !pathBelongsTo(r.URL.Path, user.Username) { http.Error(w, "Not Found", http.StatusNotFound) return } r = r.WithContext(contextWithUser(r.Context(), user)) // Drei Wege gehen an der Bibliothek vorbei, weil sie sie nicht oder // falsch bedient – siehe propfind.go und collections.go. switch r.Method { case "PROPFIND": h.servePropfind(w, r, user) return case "MKCALENDAR": h.handleMkcalendar(w, r, user) return case "PROPPATCH": h.handleProppatch(w, r, user) return case http.MethodDelete: // Nur das Löschen eines *Kalenders* gehört uns; einen Termin löscht // die Bibliothek weiterhin selbst. if h.handleDeleteCalendar(w, r, user) { return } } h.dav.ServeHTTP(w, r) } // ServeWellKnown beantwortet /.well-known/caldav – der Pfad, mit dem // Clients wie DAVx5 die automatische Einrichtung starten (RFC 6764). Er // liegt außerhalb von /caldav/, deshalb braucht es diese eigene Route statt // caldav.Handler's eingebauter Behandlung, die nur den exakten Pfad // "/.well-known/caldav" innerhalb des eigenen Präfix kennt. func (h *Handler) ServeWellKnown(w http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() if !ok || username == "" || password == "" { challenge(w) return } user, err := h.authStore.VerifyAppPassword(r.Context(), username, password) if err != nil { challenge(w) return } http.Redirect(w, r, "/caldav/principals-"+user.Username+"/", http.StatusPermanentRedirect) } // pathBelongsTo erkennt Pfade, die auf einen anderen Benutzer zeigen. // Bewusst nur ablehnen, was sicher fremd ist: Alles Unbekannte geht weiter // an go-webdav, damit diese Prüfung keine Pfade abschneidet, die sie gar // nicht kennt. func pathBelongsTo(p, username string) bool { rest := strings.Trim(strings.TrimPrefix(p, davPrefix), "/") if rest == "" { return true } seg := strings.Split(rest, "/") switch { case strings.HasPrefix(seg[0], "principals-"): return strings.EqualFold(strings.TrimPrefix(seg[0], "principals-"), username) case seg[0] == "calendars" && len(seg) >= 2: return strings.EqualFold(seg[1], username) } return true } func challenge(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Basic realm="Aether CalDAV - use an app password, not your account password"`) http.Error(w, "Unauthorized - CalDAV requires an app password (Profile -> App passwords)", http.StatusUnauthorized) }