package httpapi import ( "context" "log/slog" "net/http" "slices" "strconv" "time" "github.com/google/uuid" "github.com/syncova/syncova/packages/platform/logging" ) // HTTP-Header, die Syncova für Nachvollziehbarkeit auswertet bzw. setzt. const ( // headerCorrelationID trägt die vom Aufrufer vorgegebene Correlation ID (SYNCOVA_API.md). headerCorrelationID = "X-Correlation-ID" // headerRequestID trägt die serverseitig vergebene Request-ID. headerRequestID = "X-Request-ID" ) // requestIDContextKeyType ist ein privater Typ für den Context-Schlüssel der Request-ID. type requestIDContextKeyType struct{} // requestIDContextKey speichert die Request-ID im Request-Context. var requestIDContextKey = requestIDContextKeyType{} // Middleware ist ein Dekorator für einen HTTP-Handler. type Middleware func(http.Handler) http.Handler // Chain verkettet Middlewares so, dass die zuerst genannte außen liegt. // // Die Reihenfolge ist sicherheitsrelevant: Recovery und Correlation ID müssen // außen liegen, damit auch Fehler innerer Schichten erfasst werden. func Chain(finalHandler http.Handler, middlewares ...Middleware) http.Handler { // Rückwärts anwenden, damit middlewares[0] die äußerste Schicht bildet. for middlewareIndex := len(middlewares) - 1; middlewareIndex >= 0; middlewareIndex-- { finalHandler = middlewares[middlewareIndex](finalHandler) } return finalHandler } // RequestIDFromContext liest die Request-ID aus dem Context. // Ohne gesetzte ID liefert die Funktion eine leere Zeichenkette. func RequestIDFromContext(currentContext context.Context) string { requestID, isPresent := currentContext.Value(requestIDContextKey).(string) if !isPresent { return "" } return requestID } // CorrelationMiddleware vergibt Request-ID und Correlation ID für jeden Request. // // Die Request-ID wird immer serverseitig erzeugt; die Correlation ID darf der // Aufrufer vorgeben, um eine Operation über Systemgrenzen hinweg zu verfolgen // (PROMPT.md §50). Ein ungültiger Vorgabewert wird verworfen statt übernommen, // damit keine fremden Zeichenketten in die Logs gelangen. func CorrelationMiddleware() Middleware { return func(nextHandler http.Handler) http.Handler { return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { requestID := uuid.NewString() // Nur eine syntaktisch gültige UUID wird als Correlation ID übernommen. correlationID := requestID if providedCorrelationID := request.Header.Get(headerCorrelationID); providedCorrelationID != "" { if _, parseError := uuid.Parse(providedCorrelationID); parseError == nil { correlationID = providedCorrelationID } } enrichedContext := context.WithValue(request.Context(), requestIDContextKey, requestID) enrichedContext = logging.ContextWithCorrelationID(enrichedContext, correlationID) // Beide IDs gehen an den Aufrufer zurück, damit er einen Vorfall // gegenüber dem Betreiber eindeutig benennen kann. responseWriter.Header().Set(headerRequestID, requestID) responseWriter.Header().Set(headerCorrelationID, correlationID) nextHandler.ServeHTTP(responseWriter, request.WithContext(enrichedContext)) }) } } // SecurityHeadersMiddleware setzt defensive Antwort-Header (PROMPT.md §45). func SecurityHeadersMiddleware() Middleware { return func(nextHandler http.Handler) http.Handler { return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { responseHeaders := responseWriter.Header() // Verhindert, dass Browser den Inhaltstyp erraten. responseHeaders.Set("X-Content-Type-Options", "nosniff") // Die API liefert ausschließlich JSON und wird nie eingebettet. responseHeaders.Set("X-Frame-Options", "DENY") // Keine Referrer-Weitergabe an fremde Ziele. responseHeaders.Set("Referrer-Policy", "no-referrer") // Eine restriktive CSP, da API-Antworten kein aktives Material enthalten. responseHeaders.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") // Antworten der Control Plane dürfen nicht zwischengespeichert werden. responseHeaders.Set("Cache-Control", "no-store") nextHandler.ServeHTTP(responseWriter, request) }) } } // CORSMiddleware erlaubt Cross-Origin-Zugriff ausschließlich für benannte Herkünfte. // // Eine leere Liste bedeutet: kein Cross-Origin-Zugriff. Es gibt bewusst keine // Wildcard-Unterstützung (PROMPT.md §45). func CORSMiddleware(allowedOrigins []string) Middleware { return func(nextHandler http.Handler) http.Handler { return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { requestOrigin := request.Header.Get("Origin") isAllowedOrigin := requestOrigin != "" && slices.Contains(allowedOrigins, requestOrigin) if isAllowedOrigin { responseHeaders := responseWriter.Header() responseHeaders.Set("Access-Control-Allow-Origin", requestOrigin) responseHeaders.Set("Access-Control-Allow-Credentials", "true") responseHeaders.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, "+headerCorrelationID+", Idempotency-Key") responseHeaders.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") responseHeaders.Set("Access-Control-Max-Age", "600") // Antwort hängt von der Herkunft ab; ohne Vary wären Caches unsicher. responseHeaders.Add("Vary", "Origin") } // Preflight-Anfragen werden hier abschließend beantwortet. if request.Method == http.MethodOptions { if isAllowedOrigin { responseWriter.WriteHeader(http.StatusNoContent) } else { responseWriter.WriteHeader(http.StatusForbidden) } return } nextHandler.ServeHTTP(responseWriter, request) }) } } // BodyLimitMiddleware begrenzt die Größe eines Request-Bodys (PROMPT.md §45). func BodyLimitMiddleware(maxRequestBodyBytes int64) Middleware { return func(nextHandler http.Handler) http.Handler { return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { // MaxBytesReader bricht das Lesen ab, sobald das Limit überschritten wird, // statt den gesamten Body erst zu puffern. request.Body = http.MaxBytesReader(responseWriter, request.Body, maxRequestBodyBytes) nextHandler.ServeHTTP(responseWriter, request) }) } } // RecoveryMiddleware fängt Panics und verwandelt sie in eine kontrollierte Antwort. // // PROMPT.md §51 verbietet unkontrollierte Ausnahmen: ein Programmierfehler darf // den Dienst nicht beenden und keine internen Details ausliefern. func RecoveryMiddleware(baseLogger *slog.Logger) Middleware { return func(nextHandler http.Handler) http.Handler { return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { defer func() { panicValue := recover() if panicValue == nil { return } // http.ErrAbortHandler ist der dokumentierte Weg, eine Antwort // bewusst abzubrechen, und kein Fehlerfall. if panicValue == http.ErrAbortHandler { panic(panicValue) } requestLogger := logging.WithContext(request.Context(), baseLogger) requestLogger.Error("panic im request-handler abgefangen", slog.Any("panic", panicValue), slog.String("path", request.URL.Path), ) WriteError(responseWriter, request, requestLogger, NewInternalError(nil)) }() nextHandler.ServeHTTP(responseWriter, request) }) } } // statusCapturingResponseWriter merkt sich Statuscode und Antwortgröße für das Zugriffslog. type statusCapturingResponseWriter struct { http.ResponseWriter // statusCode ist der tatsächlich gesendete HTTP-Status. statusCode int // writtenBytes zählt die Größe des Antwortkörpers. writtenBytes int } // WriteHeader merkt sich den Statuscode und reicht ihn weiter. func (capturingWriter *statusCapturingResponseWriter) WriteHeader(statusCode int) { capturingWriter.statusCode = statusCode capturingWriter.ResponseWriter.WriteHeader(statusCode) } // Write zählt die geschriebenen Bytes und reicht sie weiter. func (capturingWriter *statusCapturingResponseWriter) Write(responseBytes []byte) (int, error) { // Ohne vorherigen WriteHeader gilt implizit 200. if capturingWriter.statusCode == 0 { capturingWriter.statusCode = http.StatusOK } bytesWritten, writeError := capturingWriter.ResponseWriter.Write(responseBytes) capturingWriter.writtenBytes += bytesWritten return bytesWritten, writeError } // AccessLogMiddleware protokolliert jeden Request strukturiert (PROMPT.md §49). func AccessLogMiddleware(baseLogger *slog.Logger) Middleware { return func(nextHandler http.Handler) http.Handler { return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { requestStartTime := time.Now() capturingWriter := &statusCapturingResponseWriter{ResponseWriter: responseWriter} nextHandler.ServeHTTP(capturingWriter, request) // Ein Handler, der nichts schreibt, hat faktisch 200 geliefert. if capturingWriter.statusCode == 0 { capturingWriter.statusCode = http.StatusOK } // Die Query-Zeichenkette wird bewusst nicht geloggt: sie könnte // versehentlich sensible Werte enthalten (PROMPT.md §45). logging.WithContext(request.Context(), baseLogger).Info("http request", slog.String("method", request.Method), slog.String("path", request.URL.Path), slog.Int("status_code", capturingWriter.statusCode), slog.Int("response_bytes", capturingWriter.writtenBytes), slog.String("duration_ms", strconv.FormatFloat(time.Since(requestStartTime).Seconds()*1000, 'f', 2, 64)), ) }) } }