diff --git a/internal/secheaders/secheaders.go b/internal/secheaders/secheaders.go new file mode 100644 index 0000000..4d91fb4 --- /dev/null +++ b/internal/secheaders/secheaders.go @@ -0,0 +1,119 @@ +// Package secheaders implementiert Core API-08: eine gemeinsame +// Security-Header-Middleware/Bibliothek, die jedes Modul-Frontend (DMS, +// Mail, Workflow, AI, Connect) einbindet, statt Header pro Modul erneut zu +// definieren (Akzeptanzkriterium 3) — dieselbe Konsolidierungslogik wie +// beim zentralen Rate-Limiting (API-03). Die tatsaechliche EINBINDUNG in +// jedes einzelne Modul-Frontend ist ausdruecklich NICHT Teil dieser Kachel +// (siehe Ticket "Nicht Bestandteil"). +package secheaders + +import ( + "fmt" + "net/http" + "strings" + "time" +) + +// Config ist die Baseline-Konfiguration. DefaultConfig() liefert einen +// Ausgangspunkt OHNE 'unsafe-inline'/'unsafe-eval' (Akzeptanzkriterium 1) +// und mit angemessener HSTS-Laufzeit (Akzeptanzkriterium 2). +type Config struct { + // ContentSecurityPolicy wird unveraendert als Header-Wert gesendet. + ContentSecurityPolicy string + // HSTSMaxAge bestimmt die Gueltigkeitsdauer, die Browser sich merken. + HSTSMaxAge time.Duration + HSTSIncludeSubdomains bool + FrameOptions string + ReferrerPolicy string + ContentTypeNoSniff bool +} + +// DefaultConfig liefert die dokumentierte Baseline: restriktive CSP ohne +// unsichere Direktiven, HSTS mit 180 Tagen (empfohlener Mindestwert fuer +// HSTS-Preloading-Listen liegt bei 1 Jahr — 180 Tage ist ein bewusst +// konservativer, aber bereits "angemessener" Ausgangswert, siehe +// Akzeptanzkriterium 2), Clickjacking- und MIME-Sniffing-Schutz. +func DefaultConfig() Config { + return Config{ + ContentSecurityPolicy: strings.Join([]string{ + "default-src 'self'", + "script-src 'self'", + "style-src 'self'", + "img-src 'self' data:", + "object-src 'none'", + "base-uri 'self'", + "frame-ancestors 'none'", + }, "; "), + HSTSMaxAge: 180 * 24 * time.Hour, + HSTSIncludeSubdomains: true, + FrameOptions: "DENY", + ReferrerPolicy: "strict-origin-when-cross-origin", + ContentTypeNoSniff: true, + } +} + +// RequiredHeaders sind die von dieser Baseline gesetzten Header-Namen — die +// Referenzliste fuer CheckCoverage (Akzeptanzkriterium/Pruefung 1). +var RequiredHeaders = []string{ + "Content-Security-Policy", + "Strict-Transport-Security", + "X-Frame-Options", + "Referrer-Policy", + "X-Content-Type-Options", +} + +func (c Config) hstsValue() string { + v := fmt.Sprintf("max-age=%d", int(c.HSTSMaxAge.Seconds())) + if c.HSTSIncludeSubdomains { + v += "; includeSubDomains" + } + return v +} + +// Middleware setzt die Baseline-Header auf JEDER Antwort und erzwingt einen +// HTTP->HTTPS-Redirect, WENN die Anfrage nicht bereits ueber TLS ankam +// (Akzeptanzkriterium 2 / Pruefung 3) — serverseitig durchgesetzt, nicht nur +// dem Browser-HSTS-Cache ueberlassen (der erst nach dem ERSTEN sicheren +// Aufruf greift). +func Middleware(cfg Config, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !isSecure(r) { + target := "https://" + r.Host + r.URL.RequestURI() + http.Redirect(w, r, target, http.StatusMovedPermanently) + return + } + + w.Header().Set("Content-Security-Policy", cfg.ContentSecurityPolicy) + w.Header().Set("Strict-Transport-Security", cfg.hstsValue()) + w.Header().Set("X-Frame-Options", cfg.FrameOptions) + w.Header().Set("Referrer-Policy", cfg.ReferrerPolicy) + if cfg.ContentTypeNoSniff { + w.Header().Set("X-Content-Type-Options", "nosniff") + } + + next(w, r) + } +} + +// isSecure erkennt TLS direkt ODER ueber den ueblichen Reverse-Proxy-Header +// (X-Forwarded-Proto) — der Core-Dienst laeuft in der Praxis hinter einem +// TLS-terminierenden Proxy, sieht r.TLS also nie direkt gesetzt. +func isSecure(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +// CheckCoverage vergleicht die tatsaechlich gesetzten Header einer Antwort +// gegen RequiredHeaders und liefert die fehlenden — der "automatisierte +// Header-Scan" aus Pruefung 1, anwendbar auf jede Antwort eines beliebigen +// Modul-Frontends. +func CheckCoverage(headers http.Header) (missing []string) { + for _, name := range RequiredHeaders { + if headers.Get(name) == "" { + missing = append(missing, name) + } + } + return missing +} diff --git a/internal/secheaders/secheaders_test.go b/internal/secheaders/secheaders_test.go new file mode 100644 index 0000000..f3e5990 --- /dev/null +++ b/internal/secheaders/secheaders_test.go @@ -0,0 +1,113 @@ +package secheaders + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func secureRequest(method, target string) *http.Request { + req := httptest.NewRequest(method, target, nil) + req.Header.Set("X-Forwarded-Proto", "https") + return req +} + +// Akzeptanzkriterium 1: CSP definiert ohne unsafe-inline/unsafe-eval. +func TestDefaultConfig_CSPHasNoUnsafeDirectives(t *testing.T) { + cfg := DefaultConfig() + if strings.Contains(cfg.ContentSecurityPolicy, "unsafe-inline") { + t.Fatal("csp enthaelt 'unsafe-inline', erwartet keine unsicheren direktiven") + } + if strings.Contains(cfg.ContentSecurityPolicy, "unsafe-eval") { + t.Fatal("csp enthaelt 'unsafe-eval', erwartet keine unsicheren direktiven") + } + if !strings.Contains(cfg.ContentSecurityPolicy, "default-src 'self'") { + t.Fatal("erwartet restriktive default-src 'self' als ausgangspunkt") + } +} + +// Akzeptanzkriterium 2: HSTS mit angemessener max-age gesetzt. +func TestMiddleware_SetsHSTSWithSufficientMaxAge(t *testing.T) { + cfg := DefaultConfig() + handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + rec := httptest.NewRecorder() + handler(rec, secureRequest(http.MethodGet, "https://example.test/")) + + hsts := rec.Header().Get("Strict-Transport-Security") + if !strings.Contains(hsts, "max-age=") { + t.Fatalf("hsts-header fehlt max-age: %q", hsts) + } + // "Angemessen" heisst hier: mindestens 30 Tage (2592000s) — deutlich + // ueber dem bekannten Anti-Pattern kurzer/fehlender max-age-Werte. + if cfg.HSTSMaxAge.Seconds() < 2592000 { + t.Fatalf("konfigurierte hsts-max-age = %v, erwartet mindestens 30 tage", cfg.HSTSMaxAge) + } + if !strings.Contains(hsts, "includeSubDomains") { + t.Fatalf("erwartet includeSubDomains im hsts-header: %q", hsts) + } +} + +// Akzeptanzkriterium 3 + Pruefung 1: EINE gemeinsame Middleware setzt ALLE +// Baseline-Header vollstaendig — automatisiert ueberprueft mit CheckCoverage. +func TestMiddleware_ProducesFullHeaderCoverage(t *testing.T) { + cfg := DefaultConfig() + handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + rec := httptest.NewRecorder() + handler(rec, secureRequest(http.MethodGet, "https://example.test/")) + + if missing := CheckCoverage(rec.Header()); len(missing) != 0 { + t.Fatalf("erwartet vollstaendige header-abdeckung, fehlen: %v", missing) + } +} + +// Pruefung 1 (Kehrseite): CheckCoverage erkennt fehlende Header +// zuverlaessig — Voraussetzung dafuer, dass ein automatisierter Scan +// gegen ein Modul-Frontend OHNE eingebundene Middleware ueberhaupt etwas +// meldet. +func TestCheckCoverage_DetectsMissingHeadersOnBareHandler(t *testing.T) { + bareHandler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } + + rec := httptest.NewRecorder() + bareHandler(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + missing := CheckCoverage(rec.Header()) + if len(missing) != len(RequiredHeaders) { + t.Fatalf("erwartet %d fehlende header bei unbehandeltem handler, habe %d: %v", + len(RequiredHeaders), len(missing), missing) + } +} + +// Akzeptanzkriterium 2 + Pruefung 3: ein Aufruf ueber (simuliertes) HTTP +// wird server-seitig auf HTTPS umgeleitet, statt sich allein auf den +// HSTS-Browser-Cache zu verlassen (der erst nach dem ersten sicheren +// Aufruf greift). +func TestMiddleware_RedirectsInsecureRequestToHTTPS(t *testing.T) { + cfg := DefaultConfig() + called := false + handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "http://example.test/pfad?x=1", nil) + // Kein X-Forwarded-Proto, kein r.TLS -> unsicherer Aufruf. + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusMovedPermanently { + t.Fatalf("status = %d, want 301 (redirect auf https)", rec.Code) + } + location := rec.Header().Get("Location") + if !strings.HasPrefix(location, "https://") { + t.Fatalf("location = %q, want https://-praefix", location) + } + if !strings.Contains(location, "/pfad?x=1") { + t.Fatalf("location = %q, erwartet erhaltenen pfad+query", location) + } + if called { + t.Fatal("eigentlicher handler haette bei unsicherem aufruf nicht erreicht werden duerfen") + } +}