package web import ( "errors" "fmt" "log" "net/http" "strconv" "strings" "time" "munin/internal/auth" "munin/internal/store" ) // requireSession prüft den Session-Cookie und legt die Sitzung im Context ab. // // Reihenfolge der Prüfungen: // // 1. Signatur und Ablaufdatum des Cookies // 2. Gibt es den zugehörigen Key noch, und ist er nicht zurückgezogen? // 3. Bei schreibenden Requests: stimmt das CSRF-Token? // // Schritt 2 ist der Grund, warum ein zurückgezogener Key sofort wirkt: Die // Sitzung trägt nur die Key-ID, die Gültigkeit wird bei jedem Request frisch in // der Datenbank nachgesehen. func (s *Server) requireSession(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(auth.SessionCookieName) if err != nil { s.redirectToLogin(w, r) return } subject, ok := s.signer.VerifySession(cookie.Value) if !ok { s.redirectToLogin(w, r) return } sess, ok := s.sessionFromSubject(r, subject) if !ok { s.redirectToLogin(w, r) return } if !s.checkCSRF(w, r, subject) { return } next.ServeHTTP(w, r.WithContext(withSession(r.Context(), sess))) }) } // sessionFromSubject baut aus dem Session-Subject die vollständige Sitzung. func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, bool) { if subject == auth.SubjectPlatformAdmin { return Session{ Subject: subject, IsPlatformAdmin: true, Role: auth.RoleAdmin, }, true } keyID, ok := auth.KeyIDFromSubject(subject) if !ok { return Session{}, false } resolved, err := s.store.APIKeyByID(r.Context(), keyID) if err != nil || resolved.Revoked() { return Session{}, false } project, err := s.store.ProjectByID(r.Context(), resolved.ProjectID) if err != nil { return Session{}, false } return Session{ Subject: subject, Key: resolved, Project: project, Role: auth.Role(resolved.Role), }, true } // checkCSRF prüft bei schreibenden Requests das CSRF-Token. // // GET, HEAD und OPTIONS gelten als sichere Methoden — sie sollen nichts // verändern und brauchen kein Token. Alles andere muss eines mitschicken. func (s *Server) checkCSRF(w http.ResponseWriter, r *http.Request, subject string) bool { switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: return true } if !s.signer.ValidCSRFToken(auth.CSRFTokenFromRequest(r), subject) { http.Error(w, "Ungültiges oder fehlendes CSRF-Token — bitte die Seite neu laden und erneut versuchen.", http.StatusForbidden) return false } return true } // redirectToLogin schickt den Browser zum Anmeldeformular. // // Bei einem schreibenden Request wäre eine Weiterleitung irreführend: Der Browser // würde das POST als GET wiederholen und der Nutzer stünde ohne Erklärung vor dem // Formular. Deshalb dort ein klarer Statuscode. func (s *Server) redirectToLogin(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet, http.MethodHead: http.Redirect(w, r, "/login", http.StatusSeeOther) default: http.Error(w, "Sitzung abgelaufen — bitte neu anmelden.", http.StatusUnauthorized) } } // requireProject löst den Projekt-Bezeichner aus der URL auf und prüft den Zugriff. // // Alle Projektseiten liegen unter /p/{project}/… — dieser eine Ort entscheidet // damit für sämtliche Projektrouten, ob jemand hineindarf. func (s *Server) requireProject(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, ok := SessionFrom(r) if !ok { s.redirectToLogin(w, r) return } slug := r.PathValue("project") project, err := s.store.ProjectBySlug(r.Context(), s.workspaceID, slug) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return } if err != nil { s.serverError(w, "Projekt konnte nicht geladen werden", err) return } // Ein fremdes Projekt wird als "nicht vorhanden" beantwortet, nicht als // "verboten". Sonst ließe sich über die Statuscodes herausfinden, welche // Projekte es sonst noch gibt. if !sess.CanAccessProject(project.ID) { http.NotFound(w, r) return } next.ServeHTTP(w, r.WithContext(withProject(r.Context(), project))) }) } // requirePlatformAdmin lässt nur den Plattform-Admin durch. func (s *Server) requirePlatformAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, ok := SessionFrom(r) if !ok || !sess.IsPlatformAdmin { http.Error(w, "Nur für den Plattform-Admin verfügbar", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // requireWrite lässt nur Sitzungen durch, die Einträge verändern dürfen. func (s *Server) requireWrite(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, ok := SessionFrom(r) if !ok || !sess.CanWrite() { http.Error(w, "Dieser Zugang darf nur lesen.", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // requireKeyManagement lässt nur Sitzungen durch, die Keys verwalten dürfen. func (s *Server) requireKeyManagement(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, ok := SessionFrom(r) if !ok || !sess.CanManageKeys() { http.Error(w, "Für die Key-Verwaltung wird die Rolle \"admin\" benötigt.", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // ── Allgemeine Schutzschichten ─────────────────────────────────────────────── // SecurityHeaders setzt bei jeder Antwort die Schutz-Header des Browsers. // // Diese Header sind Anweisungen an den Browser: Der Server kann damit Angriffe // entschärfen, die er selbst gar nicht sieht, weil sie erst im Browser des // Besuchers stattfinden. func SecurityHeaders(next http.Handler, enableHSTS bool) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() // Die Content-Security-Policy ist der wichtigste dieser Header: Sie legt // fest, woher der Browser überhaupt etwas laden und ausführen darf. // // Für uns ist sie die zweite Verteidigungslinie gegen eingeschleustes // JavaScript. Die erste ist, dass goldmark kein Roh-HTML durchlässt // (siehe internal/entry). Sollte das je gelockert werden, verhindert // diese Regel trotzdem die Ausführung. // // default-src 'self' alles nur von der eigenen Adresse // img-src … data: https: Bilder dürfen auch extern eingebunden werden // object-src 'none' keine Plugin-Einbettungen // base-uri 'none' verbietet , das sonst alle // relativen Links der Seite umbiegen könnte // form-action 'self' Formulare können nur an uns selbst senden // frame-ancestors 'none' die Seite darf in keinem fremden Rahmen // stecken (Schutz vor Clickjacking) h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: https:; style-src 'self'; "+ "script-src 'self'; object-src 'none'; base-uri 'none'; "+ "form-action 'self'; frame-ancestors 'none'") // Verbietet dem Browser, den Inhaltstyp zu erraten. Ohne diesen Header // könnte eine als Text ausgelieferte Datei, die zufällig wie HTML // aussieht, als HTML ausgeführt werden. h.Set("X-Content-Type-Options", "nosniff") // Schutz vor Clickjacking für Browser, die frame-ancestors noch nicht // kennen. Doppelt hält besser und kostet nichts. h.Set("X-Frame-Options", "DENY") // Beim Klick auf einen externen Link soll die vollständige interne URL // nicht als Herkunft mitgehen — Projekt- und Eintragsnamen können // verraten, woran gearbeitet wird. h.Set("Referrer-Policy", "same-origin") // Der Browser soll gar nicht erst nach Kamera, Mikrofon und Standort fragen. h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") // HSTS weist den Browser an, diese Adresse künftig nur noch über HTTPS // aufzurufen. Der Header darf nur gesetzt werden, wenn HTTPS wirklich // funktioniert: Ein Browser, der ihn einmal gesehen hat, weigert sich für // die angegebene Dauer, die Seite über http zu laden — auf einer Adresse // ohne Zertifikat sperrt man sich damit selbst aus. if enableHSTS { h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") } next.ServeHTTP(w, r) }) } // LimitBody begrenzt, wie viele Bytes ein Request-Body haben darf. // // Ohne Begrenzung kann ein einzelner Aufruf beliebig viel Speicher belegen. Das // muss nicht einmal böswillig sein — ein Agent in einer Schleife oder eine // versehentlich falsche Datei reichen aus. // // http.MaxBytesReader ist genau dafür gebaut: Er ersetzt den Body durch eine // Variante, die nach maxBytes einen Fehler liefert statt weiterzulesen. func LimitBody(next http.Handler, maxBytes int64) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Body != nil { r.Body = http.MaxBytesReader(w, r.Body, maxBytes) } next.ServeHTTP(w, r) }) } // ── API-Zugang (MCP) ───────────────────────────────────────────────────────── // APIAuth prüft den Bearer-Key von MCP- und API-Aufrufen und legt die Sitzung im // Context ab. // // Wichtig ist die Reihenfolge: erst den Key prüfen, dann die IP-Sperre. // Andersherum wäre naheliegender, hätte aber einen unangenehmen Nebeneffekt — // Deploy-Skript, MCP-Client und Browser eines Büros teilen sich nach außen oft // eine Adresse, und ein einziger falsch konfigurierter Client würde alle anderen // mitsperren. Gegen das Durchprobieren hilft die Reihenfolge trotzdem: Ein // Angreifer hat per Definition keinen gültigen Key und landet im unteren Zweig. func (s *Server) APIAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip := auth.ClientIP(r) key := "" if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") { key = strings.TrimPrefix(header, "Bearer ") } if auth.LooksLikeKey(key) { resolved, err := s.store.ResolveKeyHash(r.Context(), auth.HashKey(key)) if err == nil { s.limiter.Reset(ip) // Nebenbei vermerken, dass der Key benutzt wurde — damit man in // der Verwaltung sieht, welche Keys noch aktiv sind. Ein Fehler // dabei ist unerheblich und darf den Request nicht scheitern lassen. _ = s.store.TouchAPIKey(r.Context(), resolved.Key.ID, resolved.Key.LastUsedAt) sess := Session{ Subject: auth.SubjectKeyPrefix + resolved.Key.ID, Key: resolved.Key, Project: resolved.Project, Role: auth.Role(resolved.Key.Role), } ctx := withSession(r.Context(), sess) ctx = withProject(ctx, resolved.Project) next.ServeHTTP(w, r.WithContext(ctx)) return } } if allowed, retryAfter := s.limiter.Allow(ip); !allowed { sekunden := int(retryAfter.Seconds()) + 1 w.Header().Set("Retry-After", strconv.Itoa(sekunden)) http.Error(w, fmt.Sprintf("Zu viele fehlgeschlagene Versuche. Bitte in %d Sekunden erneut versuchen.", sekunden), http.StatusTooManyRequests) return } s.limiter.RecordFailure(ip) s.store.LogAudit(r.Context(), store.AuditEvent{ Actor: "unbekannt", Action: store.ActionAuthFailure, Source: store.SourceMCP, IP: ip, }) http.Error(w, "Ungültiger oder fehlender API-Key", http.StatusUnauthorized) }) } // ── Zugriffsprotokoll ──────────────────────────────────────────────────────── // statusSchreiber merkt sich den Statuscode einer Antwort. // // http.ResponseWriter verrät ihn nicht — man kann ihn setzen, aber nicht lesen. // Deshalb dieser dünne Mantel, der WriteHeader abfängt. type statusSchreiber struct { http.ResponseWriter status int bytes int } func (w *statusSchreiber) WriteHeader(code int) { w.status = code w.ResponseWriter.WriteHeader(code) } func (w *statusSchreiber) Write(b []byte) (int, error) { // Wer direkt schreibt, ohne WriteHeader aufzurufen, erzeugt implizit ein 200. if w.status == 0 { w.status = http.StatusOK } n, err := w.ResponseWriter.Write(b) w.bytes += n return n, err } // Unwrap gibt den darunterliegenden ResponseWriter zurück. // // http.ResponseController nutzt diese Methode, um an Fähigkeiten des // eingewickelten Schreibers heranzukommen. Der MCP-Transport geht diesen Weg für // seine gestreamten Antworten — ohne Unwrap bliebe dort alles im Puffer hängen. func (w *statusSchreiber) Unwrap() http.ResponseWriter { return w.ResponseWriter } // Flush reicht das Leeren des Puffers durch. // // Unwrap deckt nur Aufrufer ab, die http.ResponseController verwenden. Die // ältere und weiter verbreitete Schreibweise ist eine direkte Typzusicherung: // // if f, ok := w.(http.Flusher); ok { f.Flush() } // // Die schlägt bei einem eingewickelten Schreiber fehl, wenn er die Methode nicht // selbst anbietet — und zwar lautlos: Der Aufrufer nimmt an, dass Leeren eben // nicht unterstützt wird, und schreibt weiter in den Puffer. Solche Fehler // bemerkt man erst im Betrieb, deshalb hier ausdrücklich. func (w *statusSchreiber) Flush() { if f, ok := w.ResponseWriter.(http.Flusher); ok { f.Flush() } } // LogRequests schreibt zu jeder Anfrage eine Zeile ins Serverlog. // // # Warum das nötig ist // // Das Audit-Log hält fest, wer etwas geändert hat — es sagt aber nichts über // Lesezugriffe, Fehlerantworten oder Antwortzeiten. Ohne Zugriffsprotokoll sieht // man weder, dass ein Agent in einer Schleife hängt, noch dass eine Seite // langsam geworden ist, noch dass jemand Pfade abklappert. // // # Was bewusst nicht protokolliert wird // // - Die Query-Zeichenkette. Dort stehen Suchbegriffe, und die sind Inhalt, // kein Betriebsdatum. Der Pfad allein reicht, um zu sehen, was aufgerufen wurde. // - Statische Dateien und der Healthcheck. Beides erzeugt nur Rauschen; der // Healthcheck läuft alle 30 Sekunden. func LogRequests(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/healthz" { next.ServeHTTP(w, r) return } start := time.Now() schreiber := &statusSchreiber{ResponseWriter: w} // Halter für den Namen des Zugangs mitgeben. Die Anmelde-Middleware // weiter innen trägt ihn ein — siehe aktorHalter in context.go. halter := &aktorHalter{} r = r.WithContext(withAktorHalter(r.Context(), halter)) next.ServeHTTP(schreiber, r) wer := halter.name if wer == "" { wer = "—" // nicht angemeldet oder abgewiesen } status := schreiber.status if status == 0 { status = http.StatusOK // Handler hat nichts geschrieben } log.Printf("%d %-6s %-44s %8s %s", status, r.Method, r.URL.Path, time.Since(start).Round(time.Microsecond), wer) }) }