From 87f3cda5adc785a9298f9649c99c39aa8e7e04a3 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 27 Oct 2026 13:39:38 +0100 Subject: [PATCH] fix(dms): apierror.Normalize verbirgt 5xx-Texte (QA-15 F-14) dms/internal/apierror gab bei Status >= 500 den vom Handler geschriebenen Text unveraendert an den Aufrufer (z. B. DB-/Pfadfehler). Angleichung an internal/apierror: 5xx liefert "Interner Fehler" mit correlation_id, der Originaltext geht nur ins Server-Log. 4xx unveraendert. Co-Authored-By: Claude Sonnet 5.5 --- dms/internal/apierror/apierror.go | 41 +++++++++++++++++++++----- dms/internal/apierror/apierror_test.go | 2 +- dms/internal/apierror/leak_test.go | 39 ++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 dms/internal/apierror/leak_test.go diff --git a/dms/internal/apierror/apierror.go b/dms/internal/apierror/apierror.go index 6b21809b..9db9abe3 100644 --- a/dms/internal/apierror/apierror.go +++ b/dms/internal/apierror/apierror.go @@ -3,7 +3,10 @@ package apierror import ( "bytes" + "crypto/rand" + "encoding/hex" "encoding/json" + "log" "net/http" "strings" ) @@ -45,8 +48,9 @@ func CodeForStatus(status int) string { type body struct { Error struct { - Code string `json:"code"` - Message string `json:"message"` + Code string `json:"code"` + Message string `json:"message"` + CorrelationID string `json:"correlation_id,omitempty"` } `json:"error"` } @@ -59,13 +63,22 @@ func Write(w http.ResponseWriter, status int, code, message string) { _ = json.NewEncoder(w).Encode(b) } +func newCorrelationID() string { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return "korrelations-id-nicht-verfuegbar" + } + return hex.EncodeToString(buf) +} + // Normalize schreibt jede Fehlerantwort (Status >= 400) im Core-Format um; -// Erfolgsantworten laufen ungepuffert durch. +// Erfolgsantworten laufen ungepuffert durch. 5xx-Texte werden nie an den +// Aufrufer gegeben (Interna-Leck, QA-15 F-14), nur mit Korrelations-ID geloggt. func Normalize(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rec := &recorder{ResponseWriter: w} next.ServeHTTP(rec, r) - rec.finish() + rec.finish(r) }) } @@ -106,13 +119,27 @@ func (r *recorder) Flush() { func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } -func (r *recorder) finish() { +func (r *recorder) finish(req *http.Request) { if !r.buffered { return } - out := map[string]any{} - message := strings.TrimSpace(r.buf.String()) code := CodeForStatus(r.status) + rawMessage := strings.TrimSpace(r.buf.String()) + + if r.status >= 500 { + corrID := newCorrelationID() + log.Printf("apierror: 5xx %s %s status=%d korrelations-id=%s ursprung=%q", req.Method, req.URL.Path, r.status, corrID, rawMessage) + out := body{} + out.Error.Code = code + out.Error.Message = "Interner Fehler" + out.Error.CorrelationID = corrID + encoded, _ := json.Marshal(out) + r.flush(append(encoded, '\n')) + return + } + + out := map[string]any{} + message := rawMessage var parsed map[string]any if json.Unmarshal(r.buf.Bytes(), &parsed) == nil && parsed != nil { diff --git a/dms/internal/apierror/apierror_test.go b/dms/internal/apierror/apierror_test.go index a9ae5bb1..02c544ca 100644 --- a/dms/internal/apierror/apierror_test.go +++ b/dms/internal/apierror/apierror_test.go @@ -43,7 +43,7 @@ func TestNormalizeRewritesLegacyErrorShapes(t *testing.T) { _, _ = w.Write([]byte(`{"error":"ungueltiger wert"}`)) }, 422, "unprocessable", "ungueltiger wert"}, {"leerer body", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(403) }, 403, "forbidden", "Forbidden"}, - {"5xx", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", 500) }, 500, "internal_error", "boom"}, + {"5xx", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", 500) }, 500, "internal_error", "Interner Fehler"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/dms/internal/apierror/leak_test.go b/dms/internal/apierror/leak_test.go new file mode 100644 index 00000000..a375d18c --- /dev/null +++ b/dms/internal/apierror/leak_test.go @@ -0,0 +1,39 @@ +package apierror + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNormalize5xxHidesInternalsAndSetsCorrelationID(t *testing.T) { + const secret = "pq: connection refused to db-internal:5432 /var/lib/dms/secret.key" + rec := httptest.NewRecorder() + Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, secret, http.StatusInternalServerError) + })).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) + + if strings.Contains(rec.Body.String(), "db-internal") || strings.Contains(rec.Body.String(), "secret.key") { + t.Fatalf("5xx-Interna im Body: %q", rec.Body.String()) + } + e, _ := decode(t, rec)["error"].(map[string]any) + if id, _ := e["correlation_id"].(string); id == "" { + t.Errorf("correlation_id fehlt: %v", e) + } +} + +func TestNormalize4xxKeepsMessageWithoutCorrelationID(t *testing.T) { + rec := httptest.NewRecorder() + Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "dokument nicht gefunden", http.StatusNotFound) + })).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) + + e, _ := decode(t, rec)["error"].(map[string]any) + if _, has := e["correlation_id"]; has { + t.Errorf("4xx darf keine correlation_id tragen: %v", e) + } + if msg, _ := e["message"].(string); msg != "dokument nicht gefunden" { + t.Errorf("message = %q", msg) + } +} -- 2.47.3