// 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 } SetHeaders(cfg, w) next(w, r) } } // SetHeaders setzt die Baseline-Header direkt, OHNE die HTTPS-Redirect- // Pruefung von Middleware — nuetzlich fuer lokale Entwicklungs-/ // Demo-Umgebungen ohne TLS-Terminierung (siehe cmd/secheaders-devserver) // und fuer Aufrufer, die den Redirect selbst schon anderweitig sicherstellen. func SetHeaders(cfg Config, w http.ResponseWriter) { 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") } } // 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 }