diff --git a/cmd/account-api/main.go b/cmd/account-api/main.go index a4b69b0e..c0a8501f 100644 --- a/cmd/account-api/main.go +++ b/cmd/account-api/main.go @@ -17,10 +17,10 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/audit" "gitea.perlbach24.de/scripte/nexarch/internal/auth" "gitea.perlbach24.de/scripte/nexarch/internal/authtoken" - "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/lockout" "gitea.perlbach24.de/scripte/nexarch/internal/moduletoken" "gitea.perlbach24.de/scripte/nexarch/internal/rbac" diff --git a/cmd/auditlog-devserver/main.go b/cmd/auditlog-devserver/main.go index 469f0eec..51faef16 100644 --- a/cmd/auditlog-devserver/main.go +++ b/cmd/auditlog-devserver/main.go @@ -18,10 +18,10 @@ import ( "net/http" "os" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/audit" "gitea.perlbach24.de/scripte/nexarch/internal/auditadmin" "gitea.perlbach24.de/scripte/nexarch/internal/db" - "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/svcauth" ) diff --git a/cmd/core/main.go b/cmd/core/main.go index 7281eb7b..baec667a 100644 --- a/cmd/core/main.go +++ b/cmd/core/main.go @@ -5,10 +5,10 @@ import ( "log" "net/http" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/audit" "gitea.perlbach24.de/scripte/nexarch/internal/config" "gitea.perlbach24.de/scripte/nexarch/internal/db" - "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/moduledirectory" "gitea.perlbach24.de/scripte/nexarch/internal/svcauth" "gitea.perlbach24.de/scripte/nexarch/internal/tenant" diff --git a/cmd/kek-api/main.go b/cmd/kek-api/main.go index bca2cde3..44d8ec4b 100644 --- a/cmd/kek-api/main.go +++ b/cmd/kek-api/main.go @@ -13,8 +13,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/flag" - "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/kek" "gitea.perlbach24.de/scripte/nexarch/internal/moduleregistry" "gitea.perlbach24.de/scripte/nexarch/internal/tenant" diff --git a/cmd/licadmin-devserver/main.go b/cmd/licadmin-devserver/main.go index c2e7ce98..8c0dd4bf 100644 --- a/cmd/licadmin-devserver/main.go +++ b/cmd/licadmin-devserver/main.go @@ -16,10 +16,10 @@ import ( "os" "time" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/adminapi" "gitea.perlbach24.de/scripte/nexarch/internal/db" "gitea.perlbach24.de/scripte/nexarch/internal/flag" - "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/license" "gitea.perlbach24.de/scripte/nexarch/internal/svcauth" "gitea.perlbach24.de/scripte/nexarch/internal/usage" diff --git a/cmd/metrics-devserver/main.go b/cmd/metrics-devserver/main.go index fbff7338..a5e5eed2 100644 --- a/cmd/metrics-devserver/main.go +++ b/cmd/metrics-devserver/main.go @@ -10,8 +10,8 @@ import ( "net/http" "os" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/db" - "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/metrics" ) diff --git a/cmd/notify-api/main.go b/cmd/notify-api/main.go index 4e948ad1..f945a1fc 100644 --- a/cmd/notify-api/main.go +++ b/cmd/notify-api/main.go @@ -12,7 +12,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" - "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/notify" "gitea.perlbach24.de/scripte/nexarch/internal/notifyapi" "gitea.perlbach24.de/scripte/nexarch/internal/notifyprefs" diff --git a/cmd/notifyprefs-api/main.go b/cmd/notifyprefs-api/main.go index 9fa1ce70..f1dab784 100644 --- a/cmd/notifyprefs-api/main.go +++ b/cmd/notifyprefs-api/main.go @@ -18,8 +18,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/auth" - "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/moduletrust" "gitea.perlbach24.de/scripte/nexarch/internal/notifyprefs" ) diff --git a/cmd/policy-api/main.go b/cmd/policy-api/main.go index 23919e0d..9b6fa2ea 100644 --- a/cmd/policy-api/main.go +++ b/cmd/policy-api/main.go @@ -15,8 +15,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/flag" - "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/policy" "gitea.perlbach24.de/scripte/nexarch/internal/policyapi" ) diff --git a/cmd/rbac-admin-api/main.go b/cmd/rbac-admin-api/main.go index f711e32f..7ede42ef 100644 --- a/cmd/rbac-admin-api/main.go +++ b/cmd/rbac-admin-api/main.go @@ -18,8 +18,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/auth" - "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/moduletrust" "gitea.perlbach24.de/scripte/nexarch/internal/rbac" ) diff --git a/cmd/resync-api/main.go b/cmd/resync-api/main.go index bd452b9e..0cf05faf 100644 --- a/cmd/resync-api/main.go +++ b/cmd/resync-api/main.go @@ -13,9 +13,9 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/audit" "gitea.perlbach24.de/scripte/nexarch/internal/flag" - "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/moduleregistry" "gitea.perlbach24.de/scripte/nexarch/internal/resync" "gitea.perlbach24.de/scripte/nexarch/internal/usage" diff --git a/cmd/secheaders-devserver/main.go b/cmd/secheaders-devserver/main.go index 67c45714..5d15c10d 100644 --- a/cmd/secheaders-devserver/main.go +++ b/cmd/secheaders-devserver/main.go @@ -10,7 +10,7 @@ import ( "net/http" "os" - "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/secheaders" ) diff --git a/cmd/statuspage-devserver/main.go b/cmd/statuspage-devserver/main.go index 4fc0ad21..20af6017 100644 --- a/cmd/statuspage-devserver/main.go +++ b/cmd/statuspage-devserver/main.go @@ -11,8 +11,8 @@ import ( "strconv" "time" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/db" - "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/statuspage" ) diff --git a/cmd/tenantadmin-devserver/main.go b/cmd/tenantadmin-devserver/main.go index 65394811..f579ddbc 100644 --- a/cmd/tenantadmin-devserver/main.go +++ b/cmd/tenantadmin-devserver/main.go @@ -12,9 +12,9 @@ import ( "net/http" "os" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/audit" "gitea.perlbach24.de/scripte/nexarch/internal/db" - "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/svcauth" "gitea.perlbach24.de/scripte/nexarch/internal/tenant" "gitea.perlbach24.de/scripte/nexarch/internal/tenantadmin" diff --git a/go.mod b/go.mod index bcc954e4..9d17d8d7 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 toolchain go1.25.13 require ( + gitea.perlbach24.de/scripte/nexarch/common v0.1.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.9.2 github.com/prometheus/client_golang v1.24.1 diff --git a/go.sum b/go.sum index c44b284a..5cd640e5 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +gitea.perlbach24.de/scripte/nexarch/common v0.1.0 h1:biNzFr2LOMZC7b1leY3smQtrMmSMp6IW80OvR4LSjLY= +gitea.perlbach24.de/scripte/nexarch/common v0.1.0/go.mod h1:CiEImG++QMt1xZqSMeDJhPiB7sfWTLZOwjS6OY/Iguc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= diff --git a/internal/alerting/evaluator.go b/internal/alerting/evaluator.go index 41edb764..60954dbc 100644 --- a/internal/alerting/evaluator.go +++ b/internal/alerting/evaluator.go @@ -7,8 +7,8 @@ import ( "strings" "time" - dto "github.com/prometheus/client_model/go" "github.com/jackc/pgx/v5/pgxpool" + dto "github.com/prometheus/client_model/go" "gitea.perlbach24.de/scripte/nexarch/internal/notify" ) diff --git a/internal/apierror/apierror.go b/internal/apierror/apierror.go deleted file mode 100644 index 4872e81d..00000000 --- a/internal/apierror/apierror.go +++ /dev/null @@ -1,191 +0,0 @@ -// Package apierror erzwingt das Core-Fehlerformat (API-01) auf allen -// HTTP-Antworten und schliesst QA-15 F-14: 116 Stellen in Core, DMS, Archive -// und Mail betteten err.Error() direkt in Antworten ein, darunter 500er mit -// Datenbank- und Dateipfadtexten. Statt jede einzelne Aufrufstelle -// anzufassen, schreibt Normalize JEDE Fehlerantwort am aeussersten Rand -// (httpharden.ListenAndServe) um: 4xx-Meldungen bleiben inhaltlich -// erhalten (fachlich, keine Interna), 5xx-Antworten werden PRINZIPIELL -// durch eine allgemeine Meldung mit Korrelations-ID ersetzt — der -// urspruengliche Text (der die Interna enthalten haben KANN) geht nur ins -// Log, niemals in die Antwort. Erweitert dasselbe Grundmuster wie -// dms/internal/apierror (INT-01, dort das "Vorbild" laut API-20). -package apierror - -import ( - "bytes" - "crypto/rand" - "encoding/hex" - "encoding/json" - "log" - "net/http" - "strings" -) - -func CodeForStatus(status int) string { - switch status { - case http.StatusBadRequest: - return "invalid_request" - case http.StatusUnauthorized: - return "unauthenticated" - case http.StatusForbidden: - return "forbidden" - case http.StatusNotFound: - return "not_found" - case http.StatusMethodNotAllowed: - return "method_not_allowed" - case http.StatusConflict: - return "conflict" - case http.StatusGone: - return "gone" - case http.StatusRequestEntityTooLarge: - return "payload_too_large" - case http.StatusUnsupportedMediaType: - return "unsupported_media_type" - case http.StatusUnprocessableEntity: - return "unprocessable" - case http.StatusLocked: - return "locked" - case http.StatusTooManyRequests: - return "rate_limited" - case http.StatusServiceUnavailable: - return "unavailable" - } - if status >= 500 { - return "internal_error" - } - return "invalid_request" -} - -type body struct { - Error struct { - Code string `json:"code"` - Message string `json:"message"` - CorrelationID string `json:"correlation_id,omitempty"` - } `json:"error"` -} - -func Write(w http.ResponseWriter, status int, code, message string) { - var b body - b.Error.Code = code - b.Error.Message = message - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(b) -} - -func newCorrelationID() string { - buf := make([]byte, 8) - if _, err := rand.Read(buf); err != nil { - // crypto/rand-Ausfall ist praktisch nie beobachtbar; eine feste - // Ersatz-ID ist besser als ein Absturz in einem Fehlerpfad, der - // selbst schon einen Fehler meldet. - return "korrelations-id-nicht-verfuegbar" - } - return hex.EncodeToString(buf) -} - -// Normalize schreibt jede Fehlerantwort (Status >= 400) im Core-Format um. -// 4xx: der vom Handler geschriebene Text bleibt als "message" erhalten (er -// ist fachlich, kein Interna-Leck). 5xx (Akzeptanzkriterium 1): der Text -// wird verworfen, NIE an den Aufrufer weitergegeben — stattdessen eine -// allgemeine Meldung mit Korrelations-ID; derselbe Text landet mitsamt -// Korrelations-ID im Server-Log (Prozess-stderr, wie ueberall sonst in -// diesem Projekt). -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(r) - }) -} - -type recorder struct { - http.ResponseWriter - status int - buffered bool - buf bytes.Buffer -} - -func (r *recorder) WriteHeader(status int) { - if r.status != 0 { - return - } - r.status = status - if status >= 400 { - r.buffered = true - return - } - r.ResponseWriter.WriteHeader(status) -} - -func (r *recorder) Write(p []byte) (int, error) { - if r.status == 0 { - r.WriteHeader(http.StatusOK) - } - if r.buffered { - return r.buf.Write(p) - } - return r.ResponseWriter.Write(p) -} - -func (r *recorder) Flush() { - if f, ok := r.ResponseWriter.(http.Flusher); ok && !r.buffered { - f.Flush() - } -} - -func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } - -func (r *recorder) finish(req *http.Request) { - if !r.buffered { - return - } - 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 { - switch e := parsed["error"].(type) { - case string: - message = e - delete(parsed, "error") - out = parsed - case map[string]any: - if c, _ := e["code"].(string); c != "" { - if m, _ := e["message"].(string); m != "" { - r.flush(r.buf.Bytes()) - return - } - } - } - } - if message == "" { - message = http.StatusText(r.status) - } - out["error"] = map[string]string{"code": code, "message": message} - encoded, _ := json.Marshal(out) - r.flush(append(encoded, '\n')) -} - -func (r *recorder) flush(payload []byte) { - h := r.ResponseWriter.Header() - h.Set("Content-Type", "application/json") - h.Del("Content-Length") - h.Set("X-Content-Type-Options", "nosniff") - r.ResponseWriter.WriteHeader(r.status) - _, _ = r.ResponseWriter.Write(payload) -} diff --git a/internal/apierror/apierror_test.go b/internal/apierror/apierror_test.go deleted file mode 100644 index e26d1cdb..00000000 --- a/internal/apierror/apierror_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package apierror - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func decode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { - t.Helper() - var m map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { - t.Fatalf("keine json-antwort: %q (%v)", rec.Body.String(), err) - } - return m -} - -func errOf(t *testing.T, m map[string]any) map[string]any { - t.Helper() - e, ok := m["error"].(map[string]any) - if !ok { - t.Fatalf("error ist kein objekt: %v", m) - } - return e -} - -// Akzeptanzkriterium 2: fachliche 4xx-Meldungen bleiben verstaendlich. -func TestNormalize_4xxKeepsHandlerMessage(t *testing.T) { - cases := []struct { - name string - handler http.HandlerFunc - status int - wantCode string - wantMsg string - }{ - {"http.Error klartext", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "dokument nicht gefunden", 404) }, 404, "not_found", "dokument nicht gefunden"}, - {"json string", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(422) - _, _ = 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"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - rec := httptest.NewRecorder() - Normalize(c.handler).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - if rec.Code != c.status { - t.Fatalf("status = %d", rec.Code) - } - if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { - t.Errorf("content-type = %q", ct) - } - e := errOf(t, decode(t, rec)) - if e["code"] != c.wantCode || e["message"] != c.wantMsg { - t.Errorf("code=%v msg=%v, erwartet %q/%q", e["code"], e["message"], c.wantCode, c.wantMsg) - } - if _, has := e["correlation_id"]; has { - t.Errorf("4xx sollte keine korrelations-id tragen: %v", e) - } - }) - } -} - -// Akzeptanzkriterium 1: 5xx-Antworten enthalten NIE den Handler-Text -// (der die Interna leaken kann), nur eine allgemeine Meldung mit -// Korrelations-ID — egal, was der Handler geschrieben hat. -func TestNormalize_5xxNeverLeaksHandlerText(t *testing.T) { - cases := []struct { - name string - handler http.HandlerFunc - }{ - {"http.Error mit db-fehler", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, `pq: connection to server at "10.0.0.5", port 5432 failed`, 500) - }}, - {"http.Error mit dateipfad", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "open /var/lib/nexarch/secrets/tenant.key: permission denied", 502) - }}, - {"json mit interna", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(503) - _, _ = w.Write([]byte(`{"error":"dial tcp 127.0.0.1:5432: connect: connection refused"}`)) - }}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - rec := httptest.NewRecorder() - Normalize(c.handler).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - body := rec.Body.String() - for _, leak := range []string{"pq:", "10.0.0.5", "/var/lib/nexarch", "permission denied", "127.0.0.1", "dial tcp"} { - if strings.Contains(body, leak) { - t.Errorf("5xx-antwort enthaelt interna %q: %s", leak, body) - } - } - e := errOf(t, decode(t, rec)) - if e["message"] != "Interner Fehler" { - t.Errorf("message = %v, erwartet generische meldung", e["message"]) - } - corrID, _ := e["correlation_id"].(string) - if corrID == "" { - t.Fatalf("keine korrelations-id in der antwort: %v", e) - } - if len(corrID) < 8 { - t.Errorf("korrelations-id zu kurz/vorhersagbar: %q", corrID) - } - }) - } -} - -// Zwei Anfragen mit demselben 5xx-Fehler bekommen unterschiedliche -// Korrelations-IDs (sonst waere die ID im Log nicht eindeutig zuordenbar). -func TestNormalize_5xxCorrelationIDsAreUnique(t *testing.T) { - handler := func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", 500) } - var ids []string - for i := 0; i < 5; i++ { - rec := httptest.NewRecorder() - Normalize(http.HandlerFunc(handler)).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - e := errOf(t, decode(t, rec)) - id, _ := e["correlation_id"].(string) - for _, seen := range ids { - if seen == id { - t.Fatalf("korrelations-id doppelt vergeben: %q", id) - } - } - ids = append(ids, id) - } -} - -func TestNormalize_KeepsExtraFieldsOn4xx(t *testing.T) { - rec := httptest.NewRecorder() - Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(409) - _, _ = w.Write([]byte(`{"error":"duplikat","documentId":"d1"}`)) - })).ServeHTTP(rec, httptest.NewRequest("POST", "/x", nil)) - m := decode(t, rec) - if m["documentId"] != "d1" { - t.Errorf("zusatzfeld verloren: %v", m) - } - if errOf(t, m)["code"] != "conflict" { - t.Errorf("code = %v", errOf(t, m)["code"]) - } - - rec = httptest.NewRecorder() - Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Write(w, 401, "unauthenticated", "nicht angemeldet") - })).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - e := errOf(t, decode(t, rec)) - if e["code"] != "unauthenticated" || e["message"] != "nicht angemeldet" { - t.Errorf("core-format veraendert: %v", e) - } -} - -func TestNormalize_SuccessPassesThroughUnbuffered(t *testing.T) { - rec := httptest.NewRecorder() - Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/pdf") - _, _ = w.Write([]byte("%PDF-1.7")) - })).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - if rec.Code != 200 || rec.Body.String() != "%PDF-1.7" || rec.Header().Get("Content-Type") != "application/pdf" { - t.Errorf("erfolgsantwort veraendert: %d %q %q", rec.Code, rec.Body.String(), rec.Header().Get("Content-Type")) - } -} - -func TestNormalize_MuxNotFoundAndMethodNotAllowed(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("GET /a", func(w http.ResponseWriter, r *http.Request) {}) - h := Normalize(mux) - for _, c := range []struct { - method, path string - status int - code string - }{{"GET", "/nope", 404, "not_found"}, {"POST", "/a", 405, "method_not_allowed"}} { - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil)) - if rec.Code != c.status { - t.Fatalf("%s %s: status %d", c.method, c.path, rec.Code) - } - if errOf(t, decode(t, rec))["code"] != c.code { - t.Errorf("%s %s: code %v", c.method, c.path, errOf(t, decode(t, rec))["code"]) - } - } -} diff --git a/internal/archtest/errorleak_test.go b/internal/archtest/errorleak_test.go index 5be28df9..eed1ddf9 100644 --- a/internal/archtest/errorleak_test.go +++ b/internal/archtest/errorleak_test.go @@ -36,7 +36,7 @@ func moduleRoot(t *testing.T) string { // aber eigenstaendigen Go-Module (eigene go.mod, eigene Regeln/Ausnahmen - // DMS hat mit INT-01/INT-15 bereits ein eigenes Muster, siehe API-21-Ticket // "Nicht Bestandteil dieser Kachel"). -var exempt = []string{"internal/apierror", "internal/httpharden", "archive", "dms", "mail"} +var exempt = []string{"archive", "dms", "mail"} // TestNoErrorTextLeaksIntoHTTPResponses ist Akzeptanzkriterium 1: kein // err.Error() direkt in einer HTTP-Antwort, ausser ueber apierror. diff --git a/internal/httpharden/harden.go b/internal/httpharden/harden.go deleted file mode 100644 index 5cf7d8a0..00000000 --- a/internal/httpharden/harden.go +++ /dev/null @@ -1,119 +0,0 @@ -// Package httpharden liefert gehaertete http.Server-Grundeinstellungen -// (Timeouts, Header-Limit) und ein Body-Limit fuer alle Dienste (QA-15 F-05). -// Dieselbe Datei existiert in archive/internal/httpharden und -// dms/internal/httpharden, weil die Module getrennte go.mod-Dateien haben. -package httpharden - -import ( - "log" - "net/http" - "os" - "strconv" - "time" - - "gitea.perlbach24.de/scripte/nexarch/internal/apierror" -) - -const ( - DefaultReadHeaderTimeout = 10 * time.Second - DefaultReadTimeout = 30 * time.Second - DefaultWriteTimeout = 60 * time.Second - DefaultIdleTimeout = 120 * time.Second - DefaultMaxHeaderBytes = 1 << 20 - DefaultMaxBodyBytes = 1 << 20 - - EnvReadHeaderTimeout = "NEXARCH_HTTP_READ_HEADER_TIMEOUT_SECONDS" - EnvReadTimeout = "NEXARCH_HTTP_READ_TIMEOUT_SECONDS" - EnvWriteTimeout = "NEXARCH_HTTP_WRITE_TIMEOUT_SECONDS" - EnvIdleTimeout = "NEXARCH_HTTP_IDLE_TIMEOUT_SECONDS" - EnvMaxBodyBytes = "NEXARCH_HTTP_MAX_BODY_BYTES" -) - -// Options weicht vom Standard ab. Nullwerte bedeuten Standard bzw. Env-Wert; -// StreamingBodies/StreamingResponses setzen ReadTimeout/WriteTimeout auf 0 -// (unbegrenzt), damit lange Uploads und Downloads nicht abbrechen. Schutz -// gegen langsame Header bleibt ueber ReadHeaderTimeout und IdleTimeout. -type Options struct { - StreamingBodies bool - StreamingResponses bool -} - -func envSeconds(name string, def time.Duration) time.Duration { - v := os.Getenv(name) - if v == "" { - return def - } - n, err := strconv.Atoi(v) - if err != nil || n < 1 || n > 3600 { - log.Fatalf("%s muss eine ganze Zahl zwischen 1 und 3600 sein, war %q", name, v) - } - return time.Duration(n) * time.Second -} - -// MaxBodyBytes liest die Body-Obergrenze (Standard 1 MiB, Env -// NEXARCH_HTTP_MAX_BODY_BYTES, 1 KiB bis 1 GiB). -func MaxBodyBytes() int64 { - v := os.Getenv(EnvMaxBodyBytes) - if v == "" { - return DefaultMaxBodyBytes - } - n, err := strconv.ParseInt(v, 10, 64) - if err != nil || n < 1<<10 || n > 1<<30 { - log.Fatalf("%s muss zwischen 1024 und 1073741824 liegen, war %q", EnvMaxBodyBytes, v) - } - return n -} - -// NewServer baut einen http.Server mit gehaerteten Timeouts. -func NewServer(addr string, h http.Handler, opt Options) *http.Server { - s := &http.Server{ - Addr: addr, - Handler: h, - ReadHeaderTimeout: envSeconds(EnvReadHeaderTimeout, DefaultReadHeaderTimeout), - ReadTimeout: envSeconds(EnvReadTimeout, DefaultReadTimeout), - WriteTimeout: envSeconds(EnvWriteTimeout, DefaultWriteTimeout), - IdleTimeout: envSeconds(EnvIdleTimeout, DefaultIdleTimeout), - MaxHeaderBytes: DefaultMaxHeaderBytes, - } - if opt.StreamingBodies { - s.ReadTimeout = 0 - } - if opt.StreamingResponses { - s.WriteTimeout = 0 - } - return s -} - -// ListenAndServe ersetzt http.ListenAndServe: JSON-API mit Body-Limit und -// (QA-15 F-14) apierror.Normalize aussen um jeden Handler — jeder der ueber -// diesen einen Einstiegspunkt laufenden Core-Dienste bekommt damit -// automatisch die Korrelations-ID-Vernebelung von 5xx-Antworten, ohne dass -// jede einzelne Aufrufstelle angefasst werden muss. -func ListenAndServe(addr string, h http.Handler) error { - return NewServer(addr, apierror.Normalize(LimitBody(MaxBodyBytes(), h)), Options{}).ListenAndServe() -} - -// ListenAndServeStreaming fuer Dienste mit grossen Uploads/Downloads: keine -// globale Body-Grenze und keine Lese-/Schreib-Gesamtzeit, nur Header-Timeout. -// apierror.Normalize puffert NUR Fehlerantworten (Status >= 400) — der -// Streaming-Erfolgspfad (grosse Downloads) bleibt unangetastet. -func ListenAndServeStreaming(addr string, h http.Handler) error { - return NewServer(addr, apierror.Normalize(h), Options{StreamingBodies: true, StreamingResponses: true}).ListenAndServe() -} - -// LimitBody begrenzt Request-Bodies. Ein Content-Length ueber dem Limit wird -// vor dem Handler mit 413 abgewiesen, chunked Bodies ueber MaxBytesReader. -func LimitBody(max int64, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.ContentLength > max { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusRequestEntityTooLarge) - _, _ = w.Write([]byte(`{"error":{"code":"payload_too_large","message":"Anfrage zu gross"}}`)) - return - } - if r.Body != nil && r.Body != http.NoBody { - r.Body = http.MaxBytesReader(w, r.Body, max) - } - next.ServeHTTP(w, r) - }) -} diff --git a/internal/httpharden/harden_test.go b/internal/httpharden/harden_test.go deleted file mode 100644 index 4edd2481..00000000 --- a/internal/httpharden/harden_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package httpharden - -import ( - "bufio" - "io" - "net" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -func TestLimitBody_ContentLengthOverLimitIs413(t *testing.T) { - called := false - h := LimitBody(10, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })) - req := httptest.NewRequest("POST", "/", strings.NewReader(strings.Repeat("a", 11))) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusRequestEntityTooLarge || called { - t.Fatalf("code=%d called=%v", rec.Code, called) - } -} - -func TestLimitBody_ChunkedOverLimitFailsRead(t *testing.T) { - var readErr error - h := LimitBody(10, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, readErr = io.ReadAll(r.Body) - })) - req := httptest.NewRequest("POST", "/", strings.NewReader(strings.Repeat("a", 50))) - req.ContentLength = -1 - h.ServeHTTP(httptest.NewRecorder(), req) - if readErr == nil { - t.Fatal("lesen ueber dem Limit muss fehlschlagen") - } -} - -func TestLimitBody_UnderLimitPasses(t *testing.T) { - var got string - h := LimitBody(10, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - b, _ := io.ReadAll(r.Body) - got = string(b) - })) - h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/", strings.NewReader("abc"))) - if got != "abc" { - t.Fatalf("got %q", got) - } -} - -func TestNewServer_DefaultsAndStreaming(t *testing.T) { - s := NewServer(":0", nil, Options{}) - if s.ReadHeaderTimeout == 0 || s.ReadTimeout == 0 || s.WriteTimeout == 0 || s.IdleTimeout == 0 { - t.Fatalf("Timeouts muessen gesetzt sein: %+v", s) - } - st := NewServer(":0", nil, Options{StreamingBodies: true, StreamingResponses: true}) - if st.ReadTimeout != 0 || st.WriteTimeout != 0 || st.ReadHeaderTimeout == 0 || st.IdleTimeout == 0 { - t.Fatalf("Streaming: nur Lese-/Schreib-Gesamtzeit 0: %+v", st) - } -} - -func TestNewServer_EnvOverride(t *testing.T) { - t.Setenv(EnvReadHeaderTimeout, "3") - if got := NewServer(":0", nil, Options{}).ReadHeaderTimeout; got != 3*time.Second { - t.Fatalf("got %v", got) - } -} - -func TestMaxBodyBytes_EnvOverride(t *testing.T) { - if MaxBodyBytes() != DefaultMaxBodyBytes { - t.Fatal("Standard") - } - t.Setenv(EnvMaxBodyBytes, "2048") - if MaxBodyBytes() != 2048 { - t.Fatal("Env") - } -} - -// Slowloris: Verbindung sendet unvollstaendige Header, der Server trennt nach ReadHeaderTimeout. -func TestSlowHeadersAreClosed(t *testing.T) { - t.Setenv(EnvReadHeaderTimeout, "1") - srv := NewServer("127.0.0.1:0", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), Options{}) - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - go srv.Serve(ln) - defer srv.Close() - conn, err := net.Dial("tcp", ln.Addr().String()) - if err != nil { - t.Fatal(err) - } - defer conn.Close() - if _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\nX-Slow: a")); err != nil { - t.Fatal(err) - } - conn.SetReadDeadline(time.Now().Add(5 * time.Second)) - start := time.Now() - _, err = bufio.NewReader(conn).ReadByte() - if err == nil { - t.Log("Server antwortete (z. B. 408)") - } - if time.Since(start) > 4*time.Second { - t.Fatalf("Verbindung wurde nicht nach dem Header-Timeout beendet (%v)", time.Since(start)) - } -} diff --git a/internal/migrationtenant/migrate_test.go b/internal/migrationtenant/migrate_test.go index 2677144c..ff64b767 100644 --- a/internal/migrationtenant/migrate_test.go +++ b/internal/migrationtenant/migrate_test.go @@ -79,10 +79,10 @@ func TestMigrateApproved(t *testing.T) { // Fuenf synthetische, freigegebene Tenant-Zusammenfuehrungen und ein // bewusst NICHT freigegebener Eintrag (Akzeptanzkriterium 2). type synthEntry struct { - id string - slug string - name string - approved bool + id string + slug string + name string + approved bool } suffix := testdb.Suffix() entries := make([]synthEntry, 0, 6) diff --git a/internal/moduletrust/moduletrust_test.go b/internal/moduletrust/moduletrust_test.go index 5cd532a5..a5554412 100644 --- a/internal/moduletrust/moduletrust_test.go +++ b/internal/moduletrust/moduletrust_test.go @@ -210,5 +210,8 @@ func (w *captureWriter) Header() http.Header { } return w.header } -func (w *captureWriter) Write(p []byte) (int, error) { w.body = append(w.body, p...); return len(p), nil } -func (w *captureWriter) WriteHeader(statusCode int) {} +func (w *captureWriter) Write(p []byte) (int, error) { + w.body = append(w.body, p...) + return len(p), nil +} +func (w *captureWriter) WriteHeader(statusCode int) {} diff --git a/internal/mtls/ca.go b/internal/mtls/ca.go index 08972fcf..40133f45 100644 --- a/internal/mtls/ca.go +++ b/internal/mtls/ca.go @@ -32,8 +32,8 @@ import ( // eine Rotation bereits ausgestellte Leaf-Zertifikate nicht ungueltig macht // (Akzeptanzkriterium 3: Rotation ohne Ausfallzeit). type Authority struct { - current caGeneration - trustPool *x509.CertPool + current caGeneration + trustPool *x509.CertPool generations []caGeneration } diff --git a/internal/oidc/client.go b/internal/oidc/client.go index f3bf4ac1..d47af6c8 100644 --- a/internal/oidc/client.go +++ b/internal/oidc/client.go @@ -19,8 +19,8 @@ import ( ) var ( - ErrClientNotFound = errors.New("oidc: client nicht gefunden") - ErrInvalidClientAuth = errors.New("oidc: ungueltige client-authentifizierung") + ErrClientNotFound = errors.New("oidc: client nicht gefunden") + ErrInvalidClientAuth = errors.New("oidc: ungueltige client-authentifizierung") ErrRedirectURINotAllowed = errors.New("oidc: redirect_uri ist fuer diesen client nicht registriert") ) diff --git a/internal/openapi/openapi.go b/internal/openapi/openapi.go index 7c7ee76d..d6a9c900 100644 --- a/internal/openapi/openapi.go +++ b/internal/openapi/openapi.go @@ -43,9 +43,9 @@ type ExampleRequest struct { // (Akzeptanzkriterium 3), ohne eine vollstaendige OpenAPI-Bibliothek zu // integrieren. type Document struct { - OpenAPI string `json:"openapi"` - Info Info `json:"info"` - Paths map[string]PathItem `json:"paths"` + OpenAPI string `json:"openapi"` + Info Info `json:"info"` + Paths map[string]PathItem `json:"paths"` } type Info struct { @@ -56,8 +56,8 @@ type Info struct { type PathItem map[string]Operation type Operation struct { - Summary string `json:"summary"` - Description string `json:"description,omitempty"` + Summary string `json:"summary"` + Description string `json:"description,omitempty"` Responses map[string]Response `json:"responses"` } diff --git a/internal/pflichttestgate/gate.go b/internal/pflichttestgate/gate.go index 3229dea3..4887ac76 100644 --- a/internal/pflichttestgate/gate.go +++ b/internal/pflichttestgate/gate.go @@ -41,7 +41,7 @@ func isSensitive(file string) bool { // Pflichttest-Regel und gibt für jedes betroffene Package ohne begleitende Testdatei eine Violation // zurück. Ein leeres Ergebnis bedeutet: Gate besteht. func CheckDiff(changedFiles []string) []Violation { - sensitiveByPkg := map[string]string{} // package -> erste sensible Datei (fürs Reporting) + sensitiveByPkg := map[string]string{} // package -> erste sensible Datei (fürs Reporting) testTouchedPkgs := map[string]bool{} for _, f := range changedFiles { diff --git a/internal/secheaders/secheaders.go b/internal/secheaders/secheaders.go index a15ebfdc..27c60a52 100644 --- a/internal/secheaders/secheaders.go +++ b/internal/secheaders/secheaders.go @@ -21,11 +21,11 @@ type Config struct { // ContentSecurityPolicy wird unveraendert als Header-Wert gesendet. ContentSecurityPolicy string // HSTSMaxAge bestimmt die Gueltigkeitsdauer, die Browser sich merken. - HSTSMaxAge time.Duration + HSTSMaxAge time.Duration HSTSIncludeSubdomains bool - FrameOptions string - ReferrerPolicy string - ContentTypeNoSniff bool + FrameOptions string + ReferrerPolicy string + ContentTypeNoSniff bool } // DefaultConfig liefert die dokumentierte Baseline: restriktive CSP ohne diff --git a/internal/statuspage/statuspage.go b/internal/statuspage/statuspage.go index 6dbb002a..e6e6802d 100644 --- a/internal/statuspage/statuspage.go +++ b/internal/statuspage/statuspage.go @@ -17,7 +17,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" - "gitea.perlbach24.de/scripte/nexarch/internal/apierror" + "gitea.perlbach24.de/scripte/nexarch/common/apierror" "gitea.perlbach24.de/scripte/nexarch/internal/httpclient" ) diff --git a/internal/tenant/onboarding.go b/internal/tenant/onboarding.go index 5e79b291..015304ba 100644 --- a/internal/tenant/onboarding.go +++ b/internal/tenant/onboarding.go @@ -19,7 +19,7 @@ var ErrMissingField = errors.New("tenant: pflichtfeld fehlt") // Tenant per Deprovision wieder vollstaendig entfernt (Saga-Kompensation, // da CREATE DATABASE keine echte cross-database Transaktion erlaubt). type OnboardingService struct { - provisioner *Provisioner + provisioner *Provisioner // tenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der // die frisch angelegte Tenant-Datenbank initialisiert wird, bevor der // erste Administrator angelegt werden kann. diff --git a/internal/tenantadmin/tenantadmin.go b/internal/tenantadmin/tenantadmin.go index cd0c7237..44a4ff33 100644 --- a/internal/tenantadmin/tenantadmin.go +++ b/internal/tenantadmin/tenantadmin.go @@ -106,10 +106,10 @@ func (h *Handler) ListTenants(ctx context.Context, superadminID, search, statusF // TenantDetail buendelt Stammdaten und Einstellungen fuer die Detailansicht. type TenantDetail struct { - ID string `json:"id"` - Slug string `json:"slug"` - Name string `json:"name"` - Status string `json:"status"` + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` Settings tenantsettings.Settings `json:"settings"` } diff --git a/internal/tenantapproval/approved_test.go b/internal/tenantapproval/approved_test.go index 36884513..fe369816 100644 --- a/internal/tenantapproval/approved_test.go +++ b/internal/tenantapproval/approved_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "gitea.perlbach24.de/scripte/nexarch/internal/testdb" "gitea.perlbach24.de/scripte/nexarch/internal/tenantmatch" + "gitea.perlbach24.de/scripte/nexarch/internal/testdb" ) // MAP-03 konsumiert ausschliesslich Approved() -- dieser Test stellt sicher, diff --git a/internal/tenantapproval/store_test.go b/internal/tenantapproval/store_test.go index 940c7f88..fa8828dd 100644 --- a/internal/tenantapproval/store_test.go +++ b/internal/tenantapproval/store_test.go @@ -8,8 +8,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" - "gitea.perlbach24.de/scripte/nexarch/internal/testdb" "gitea.perlbach24.de/scripte/nexarch/internal/tenantmatch" + "gitea.perlbach24.de/scripte/nexarch/internal/testdb" ) func setupSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool) { diff --git a/internal/tenantimportdirapi/tenantimportdirapi.go b/internal/tenantimportdirapi/tenantimportdirapi.go index bdd5eb81..f4d55ea2 100644 --- a/internal/tenantimportdirapi/tenantimportdirapi.go +++ b/internal/tenantimportdirapi/tenantimportdirapi.go @@ -10,7 +10,7 @@ import ( "errors" "net/http" - "gitea.perlbach24.de/scripte/nexarch/internal/apierror" + "gitea.perlbach24.de/scripte/nexarch/common/apierror" "gitea.perlbach24.de/scripte/nexarch/internal/tenantimportdir" )