672 lines
24 KiB
Go
672 lines
24 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"munin/internal/auth"
|
|
"munin/internal/store"
|
|
"munin/internal/workspace"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
ctx := withSession(r.Context(), sess)
|
|
|
|
// Ohne gewählten Workspace (Plattform-Admin vor der Workspace-Auswahl)
|
|
// gibt es nichts zu öffnen — solche Sitzungen dürfen ausschließlich die
|
|
// Workspace-Auswahl aufrufen, siehe requireWorkspacePicked.
|
|
if sess.WorkspaceID != "" {
|
|
ws, err := s.workspaces.Get(sess.WorkspaceID)
|
|
if err != nil {
|
|
s.serverError(w, "Workspace konnte nicht geöffnet werden", err)
|
|
return
|
|
}
|
|
ctx = withWorkspace(ctx, ws)
|
|
}
|
|
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// sessionFromSubject baut aus dem Session-Subject die vollständige Sitzung.
|
|
//
|
|
// Ein Subject hat eine von vier Formen:
|
|
//
|
|
// - "platform/<fingerabdruck>" — Plattform-Admin, noch kein Workspace
|
|
// gewählt. Der Fingerabdruck muss zum aktuellen ADMIN_TOKEN passen
|
|
// (siehe auth.PlatformSubject).
|
|
// - "platform/<fingerabdruck>:<workspace-id>" — Plattform-Admin, mit
|
|
// gewähltem Workspace.
|
|
// - "wsadmin:<workspace-id>:<zugangs-id>" — Workspace-Admin, fest an seinen
|
|
// Workspace gebunden (AUSBAUSTUFEN.md 4.8).
|
|
// - "key:<workspace-id>:<key-id>" — Projekt-Zugang, immer an genau einen
|
|
// Workspace gebunden (den seines Keys).
|
|
//
|
|
// Die Workspace-ID steht direkt im (signierten, also fälschungssicheren)
|
|
// Subject — so lässt sich jede Anfrage ohne Umweg über die Kontroll-Datenbank
|
|
// der richtigen Workspace-Datei zuordnen.
|
|
//
|
|
// Sitzungen aus der Zeit vor dem Fingerabdruck ("platform" ohne Zusatz) passen
|
|
// zu keiner Form mehr und enden. Der Plattform-Admin meldet sich einmal neu an.
|
|
func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, bool) {
|
|
if fingerabdruck, workspaceID, ok := auth.ParsePlatformSubject(subject); ok {
|
|
// Mit einem anderen ADMIN_TOKEN entstanden: Das Token wurde seitdem
|
|
// ersetzt, die Sitzung gilt nicht mehr. Vergleich in konstanter Zeit,
|
|
// damit die Antwortzeit nichts über den richtigen Abdruck verrät.
|
|
if subtle.ConstantTimeCompare([]byte(fingerabdruck), []byte(s.adminFingerprint)) != 1 {
|
|
return Session{}, false
|
|
}
|
|
|
|
if workspaceID == "" {
|
|
return Session{
|
|
Subject: subject,
|
|
IsPlatformAdmin: true,
|
|
Role: auth.RoleAdmin,
|
|
}, true
|
|
}
|
|
|
|
ws, err := s.control.WorkspaceByID(r.Context(), workspaceID)
|
|
if err != nil {
|
|
return Session{}, false
|
|
}
|
|
return Session{
|
|
Subject: subject,
|
|
IsPlatformAdmin: true,
|
|
WorkspaceID: workspaceID,
|
|
WorkspaceName: ws.Name,
|
|
Role: auth.RoleAdmin,
|
|
}, true
|
|
}
|
|
|
|
if rest, ok := strings.CutPrefix(subject, auth.SubjectWorkspaceAdminPrefix); ok {
|
|
return s.workspaceAdminSession(r, subject, rest)
|
|
}
|
|
|
|
rest, ok := auth.KeyIDFromSubject(subject)
|
|
if !ok {
|
|
return Session{}, false
|
|
}
|
|
workspaceID, keyID, ok := strings.Cut(rest, ":")
|
|
if !ok {
|
|
return Session{}, false
|
|
}
|
|
|
|
ws, err := s.workspaces.Get(workspaceID)
|
|
if err != nil {
|
|
return Session{}, false
|
|
}
|
|
|
|
// Zurückgezogen oder abgelaufen: Die Sitzung endet bei der nächsten
|
|
// Anfrage, nicht erst beim Ablauf des Cookies.
|
|
resolved, err := ws.Store.APIKeyByID(r.Context(), keyID)
|
|
if err != nil || !resolved.Valid(time.Now()) {
|
|
return Session{}, false
|
|
}
|
|
|
|
project, err := ws.Store.ProjectByID(r.Context(), resolved.ProjectID)
|
|
if err != nil {
|
|
return Session{}, false
|
|
}
|
|
|
|
return Session{
|
|
Subject: subject,
|
|
WorkspaceID: workspaceID,
|
|
Key: resolved,
|
|
Project: project,
|
|
Role: auth.Role(resolved.Role),
|
|
}, true
|
|
}
|
|
|
|
// workspaceAdminSession baut die Sitzung eines Workspace-Admins aus dem Rest
|
|
// seines Subjects ("<workspace-id>:<zugangs-id>").
|
|
//
|
|
// Wie bei den Keys wird der Zugang bei jedem Request frisch nachgesehen: Ein
|
|
// zurückgezogener Zugang beendet die Sitzung sofort, ebenso ein gelöschter
|
|
// Workspace (dessen Zugänge fallen per ON DELETE CASCADE mit weg).
|
|
//
|
|
// Der Vergleich der Workspace-IDs ist eine zweite Sicherung: Das Subject ist
|
|
// signiert und damit nicht fälschbar, aber die Bindung an den Workspace soll
|
|
// nicht allein davon abhängen, dass es beim Anmelden richtig gebildet wurde.
|
|
func (s *Server) workspaceAdminSession(r *http.Request, subject, rest string) (Session, bool) {
|
|
workspaceID, adminID, ok := strings.Cut(rest, ":")
|
|
if !ok {
|
|
return Session{}, false
|
|
}
|
|
|
|
admin, err := s.control.WorkspaceAdminByID(r.Context(), adminID)
|
|
if err != nil || admin.Revoked() || admin.WorkspaceID != workspaceID {
|
|
return Session{}, false
|
|
}
|
|
|
|
ws, err := s.control.WorkspaceByID(r.Context(), workspaceID)
|
|
if err != nil {
|
|
return Session{}, false
|
|
}
|
|
|
|
// Nebenbei die letzte Nutzung vermerken — eine Sitzung gilt 30 Tage, der
|
|
// Login allein sagt also wenig darüber, ob der Zugang noch gebraucht wird.
|
|
_ = s.control.TouchWorkspaceAdmin(r.Context(), admin.ID, admin.LastUsedAt)
|
|
|
|
return Session{
|
|
Subject: subject,
|
|
IsWorkspaceAdmin: true,
|
|
WorkspaceID: workspaceID,
|
|
WorkspaceName: ws.Name,
|
|
WorkspaceAdmin: admin,
|
|
}, 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
|
|
}
|
|
|
|
ws, ok := WorkspaceFrom(r)
|
|
if !ok {
|
|
// Plattform-Admin ohne gewählten Workspace: Projektseiten gibt es
|
|
// erst, nachdem einer gewählt wurde.
|
|
http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
slug := r.PathValue("project")
|
|
project, err := ws.Store.ProjectBySlug(r.Context(), sess.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)))
|
|
})
|
|
}
|
|
|
|
// requireWorkspace lässt nur Sitzungen mit gewähltem Workspace durch.
|
|
//
|
|
// Betrifft nur den Plattform-Admin: Projekt- und Workspace-Admin-Sitzungen
|
|
// haben ihren Workspace immer schon im Subject (siehe sessionFromSubject). Der
|
|
// Plattform-Admin
|
|
// dagegen kann sich frisch anmelden, ohne dass klar ist, welche Abteilung er
|
|
// verwalten will — dafür ist /admin/workspaces da.
|
|
func (s *Server) requireWorkspace(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := WorkspaceFrom(r); !ok {
|
|
http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
}
|
|
|
|
// requireProjectManagement lässt nur Sitzungen durch, die Projekte verwalten
|
|
// dürfen: Plattform-Admin und Workspace-Admin.
|
|
func (s *Server) requireProjectManagement(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sess, ok := SessionFrom(r)
|
|
if !ok || !sess.CanManageProjects() {
|
|
http.Error(w, "Nur für Plattform- und Workspace-Admins verfügbar", http.StatusForbidden)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// requireImport lässt nur Sitzungen durch, die importieren dürfen (siehe
|
|
// Session.CanImport).
|
|
func (s *Server) requireImport(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sess, ok := SessionFrom(r)
|
|
if !ok || !sess.CanImport() {
|
|
http.Error(w, "Für den Import wird die Rolle \"admin\" benötigt.", 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)
|
|
})
|
|
}
|
|
|
|
// requireProjectDownload lässt nur Sitzungen durch, die ein Projekt als ZIP
|
|
// herunterladen dürfen (siehe Session.CanDownloadProject).
|
|
func (s *Server) requireProjectDownload(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sess, ok := SessionFrom(r)
|
|
if !ok || !sess.CanDownloadProject() {
|
|
http.Error(w, "Für den Download des ganzen Projekts 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 <base href>, 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)
|
|
})
|
|
}
|
|
|
|
// BodyLimits sind die Höchstgrößen eines Request-Bodys je Art von Route.
|
|
type BodyLimits struct {
|
|
Web int64 // Formulare der Weboberfläche
|
|
API int64 // MCP-Aufrufe unter /mcp
|
|
Import int64 // Hochladen eines ZIP-Archivs (siehe handlers_import.go)
|
|
}
|
|
|
|
// LimitBodies begrenzt den Body wie LimitBody, wählt die Grenze aber nach der
|
|
// Route.
|
|
//
|
|
// # Warum eine Stelle statt LimitBody je Route?
|
|
//
|
|
// Zwei ineinander geschachtelte MaxBytesReader begrenzen immer auf den
|
|
// KLEINEREN Wert. Ein großzügiges Limit an einer einzelnen Route unter einem
|
|
// strengen Limit für alles wirkt deshalb nie. Genau so war MCP früher
|
|
// verdrahtet: MAX_API_BODY_BYTES stand auf 4 MB, das äußere Limit von 1 MB
|
|
// schnitt trotzdem ab. Hier wird die Grenze einmal außen gewählt, bevor
|
|
// irgendetwas anderes den Body anfasst.
|
|
func LimitBodies(next http.Handler, limits BodyLimits) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
grenze := limits.Web
|
|
switch {
|
|
case r.URL.Path == "/mcp":
|
|
grenze = limits.API
|
|
case r.Method == http.MethodPost && istImportPfad(r.URL.Path):
|
|
grenze = limits.Import
|
|
}
|
|
if r.Body != nil {
|
|
r.Body = http.MaxBytesReader(w, r.Body, grenze)
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// istImportPfad erkennt "/p/{projekt}/import". Der Mux hat zu diesem
|
|
// Zeitpunkt noch nicht entschieden, welche Route passt, deshalb wird der Pfad
|
|
// hier von Hand zerlegt: genau drei Teile, der erste "p", der letzte "import".
|
|
func istImportPfad(pfad string) bool {
|
|
teile := strings.Split(strings.Trim(pfad, "/"), "/")
|
|
return len(teile) == 3 && teile[0] == "p" && teile[2] == "import"
|
|
}
|
|
|
|
// ── 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) {
|
|
hash := auth.HashKey(key)
|
|
|
|
// Erster Schritt: welcher Workspace? Erst danach lässt sich die
|
|
// richtige Datei überhaupt ansprechen (siehe ControlStore in
|
|
// internal/store/control.go und AUSBAUSTUFEN.md 4.4).
|
|
workspaceID, err := s.control.ResolveKeyWorkspace(r.Context(), hash)
|
|
if err == nil {
|
|
var ws *workspace.Workspace
|
|
ws, err = s.workspaces.Get(workspaceID)
|
|
if err == nil {
|
|
var resolved store.ResolvedKey
|
|
resolved, err = ws.Store.ResolveKeyHash(r.Context(), hash)
|
|
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.
|
|
_ = ws.Store.TouchAPIKey(r.Context(), resolved.Key.ID, resolved.Key.LastUsedAt)
|
|
|
|
sess := Session{
|
|
Subject: auth.SubjectKeyPrefix + workspaceID + ":" + resolved.Key.ID,
|
|
WorkspaceID: workspaceID,
|
|
Key: resolved.Key,
|
|
Project: resolved.Project,
|
|
Role: auth.Role(resolved.Key.Role),
|
|
}
|
|
|
|
ctx := withSession(r.Context(), sess)
|
|
ctx = withProject(ctx, resolved.Project)
|
|
ctx = withWorkspace(ctx, ws)
|
|
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)
|
|
|
|
// Ein ungültiger Key lässt sich keinem Workspace zuordnen — es gibt
|
|
// also keine passende Datei für einen Audit-Eintrag. Das Protokoll
|
|
// verzeichnet nur Vorgänge innerhalb eines Workspace; ein anonymer
|
|
// Fehlversuch gehört ins allgemeine Server-Log.
|
|
log.Printf("Ungültiger API-Key von %s", 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 die Prüfungen /live, /ready (und /healthz). Das
|
|
// erzeugt nur Rauschen; die Prüfungen laufen alle paar Sekunden. Ein
|
|
// Ausfall der Datenbank steht trotzdem im Log, siehe meldeBereitschaft.
|
|
func LogRequests(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/static/") || istPruefpfad(r.URL.Path) {
|
|
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)
|
|
})
|
|
}
|
|
|
|
// istPruefpfad erkennt die Prüfungen für Container und Load-Balancer.
|
|
func istPruefpfad(pfad string) bool {
|
|
return pfad == "/live" || pfad == "/ready" || pfad == "/healthz"
|
|
}
|