feat(web): Sessions, Rate-Limiting, Security-Header, Templates und eingebettete Assets

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
Carsten Abele 2026-08-14 09:20:38 +02:00
parent e49882b8a8
commit bb2bc223b9
16 changed files with 1292 additions and 0 deletions

46
internal/web/security.go Normal file
View file

@ -0,0 +1,46 @@
package web
import (
"net"
"net/http"
"strings"
)
// contentSecurityPolicy ist so restriktiv möglich, weil sämtliche Assets
// eingebettet und gleich-origin ausgeliefert werden. Es gibt keine externen
// Ressourcen und kein Inline-Script.
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; " +
"connect-src 'none'; object-src 'none'; base-uri 'none'; " +
"form-action 'self'; frame-ancestors 'none'"
// SecurityHeaders setzt die Sicherheits-Header auf jede Antwort.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", contentSecurityPolicy)
next.ServeHTTP(w, r)
})
}
// NoStore verhindert jegliches Zwischenspeichern.
func NoStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
// ClientIP liefert die Quell-IP der Verbindung. X-Forwarded-For wird bewusst
// ignoriert: das Portal terminiert TLS selbst, ein gesetzter Header wäre
// fälschbar und würde das Audit-Log entwerten.
func ClientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return strings.TrimSpace(r.RemoteAddr)
}
return host
}