diff --git a/cmd/secheaders-devserver/main.go b/cmd/secheaders-devserver/main.go
new file mode 100644
index 0000000..bb699a6
--- /dev/null
+++ b/cmd/secheaders-devserver/main.go
@@ -0,0 +1,40 @@
+// secheaders-devserver dient der manuellen/e2e-Verifikation von API-08
+// (Pruefung 2: CSP blockiert Inline-Script) mit einem echten Browser.
+// Zwei Pfade: /protected (mit Middleware) und /unprotected (ohne) — beide
+// liefern dieselbe Seite mit einem Inline-Script, das ein DOM-Element
+// aendert. Ein echter Browser zeigt den Unterschied.
+package main
+
+import (
+ "log"
+ "net/http"
+
+ "gitea.perlbach24.de/scripte/nexarch/internal/secheaders"
+)
+
+const page = `
+
+BLOCKED
+
+`
+
+func servePage(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write([]byte(page))
+}
+
+func main() {
+ cfg := secheaders.DefaultConfig()
+ mux := http.NewServeMux()
+ // SetHeaders statt Middleware: dieser lokale Demo-Server laeuft ohne
+ // TLS-Terminierung, der HTTPS-Redirect-Teil von Middleware wuerde hier
+ // in eine Endlosschleife fuehren (siehe secheaders.go-Dokumentation).
+ mux.HandleFunc("/protected", func(w http.ResponseWriter, r *http.Request) {
+ secheaders.SetHeaders(cfg, w)
+ servePage(w, r)
+ })
+ mux.HandleFunc("/unprotected", servePage)
+
+ log.Println("secheaders-devserver listening on :8086")
+ log.Fatal(http.ListenAndServe(":8086", mux))
+}
diff --git a/internal/secheaders/secheaders.go b/internal/secheaders/secheaders.go
new file mode 100644
index 0000000..a15ebfd
--- /dev/null
+++ b/internal/secheaders/secheaders.go
@@ -0,0 +1,126 @@
+// 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
+}
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")
+ }
+}