120 lines
4.1 KiB
Go
120 lines
4.1 KiB
Go
// 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
|
|
}
|