Der bisherige Selbsttest prueft die JSON-Schnittstelle, also den Weg der Weboberflaeche. Der CalDAV-Weg -- der, den DAVx5 und Thunderbird gehen -- war nie automatisch geprueft, obwohl er ueber voellig anderen Code laeuft. Ein gruener Lauf des einen sagt nichts ueber den anderen. scripts/caldav-selbsttest.py geht ihn jetzt ab: Anmeldung (auch die Regel, dass das Kontopasswort hier nicht gilt), Auffinden ueber .well-known samt der Umleitung, der Clients mit GET folgen, Kalenderliste, PUT/GET/DELETE, If-Match, calendar-query mit Zeitfenster, calendar-multiget, beide Richtungen zwischen Web und CalDAV (Ganztagsdatum mit exklusivem DTEND, Terminfarbe als CSS3-Name, RRULE, EXDATE) und das Umhaengen aus Client-Sicht. Dabei gefunden und behoben: Ein Pfad, der auf einen *anderen* Benutzer zeigt, wurde mit einem leeren 207 beantwortet. Kein Datenleck -- die Antwort ist leer, und der Backend nimmt den angemeldeten Benutzer, nicht den aus dem Pfad. Aber der Client sieht dann einen Kalenderbestand ohne Kalender und meldet "nichts gefunden" statt "falsche Adresse": die stille Variante genau des Fehlers, der bei der Einrichtung schon einmal Stunden gekostet hat. Jetzt 404. Bewusst nur ablehnen, was sicher fremd ist; alles Unbekannte geht weiter an go-webdav. Zwei Luecken bleiben und werden vom Skript als Hinweis gemeldet, weil go-webdav v0.7.0 nur einen festen Satz Eigenschaften beantwortet und keinen Haken bietet, eigene dazuzulegen: getctag fehlt (Clients listen bei jedem Abgleich alles auf, statt billig nach Aenderungen zu fragen) und calendar-color fehlt (der Kalender erscheint im Client in dessen Vorgabefarbe -- Terminfarben sind nicht betroffen, die stehen im .ics). 39 Pruefungen gruen, JSON-Selbsttest weiter 36 gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
134 lines
4.6 KiB
Go
134 lines
4.6 KiB
Go
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
|
||
dav *caldav.Handler
|
||
limiter *middleware.FailureLimiter
|
||
}
|
||
|
||
func NewHandler(authStore *auth.Store, store *Store) *Handler {
|
||
return &Handler{
|
||
authStore: authStore,
|
||
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
|
||
}
|
||
|
||
h.dav.ServeHTTP(w, r.WithContext(contextWithUser(r.Context(), user)))
|
||
}
|
||
|
||
// 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)
|
||
}
|