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

View file

@ -0,0 +1,90 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSecurityHeaders(t *testing.T) {
h := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
want := map[string]string{
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"X-Frame-Options": "DENY",
}
for k, v := range want {
if got := rec.Header().Get(k); got != v {
t.Errorf("%s = %q, want %q", k, got, v)
}
}
if hsts := rec.Header().Get("Strict-Transport-Security"); !strings.Contains(hsts, "max-age=") {
t.Errorf("Strict-Transport-Security = %q", hsts)
}
csp := rec.Header().Get("Content-Security-Policy")
for _, directive := range []string{"default-src 'self'", "frame-ancestors 'none'", "form-action 'self'"} {
if !strings.Contains(csp, directive) {
t.Errorf("CSP %q fehlt %q", csp, directive)
}
}
if strings.Contains(csp, "unsafe-inline") || strings.Contains(csp, "unsafe-eval") {
t.Errorf("CSP darf kein unsafe-* enthalten: %q", csp)
}
}
func TestNoStore(t *testing.T) {
rec := httptest.NewRecorder()
NoStore(rec)
if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Errorf("Cache-Control = %q", got)
}
}
func TestClientIPIgnoresForwardedHeader(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "10.1.20.34:51234"
r.Header.Set("X-Forwarded-For", "1.2.3.4")
// Das Portal terminiert TLS selbst; ein gefälschter Header darf das
// Audit-Log nicht verfälschen.
if got := ClientIP(r); got != "10.1.20.34" {
t.Errorf("ClientIP = %q, want 10.1.20.34", got)
}
}
func TestClientIPHandlesIPv6AndMalformed(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "[2001:db8::1]:443"
if got := ClientIP(r); got != "2001:db8::1" {
t.Errorf("ClientIP = %q", got)
}
r.RemoteAddr = "kaputt"
if got := ClientIP(r); got != "kaputt" {
t.Errorf("ClientIP = %q, malformed muss unverändert durchgereicht werden", got)
}
}
func TestStringsAreComplete(t *testing.T) {
required := []string{
"login_title", "login_username", "login_password", "login_submit",
"login_username_hint", "error_generic_login", "error_password_expired",
"error_rate_limited", "error_session_expired", "overview_title",
"overview_no_certs", "overview_expiry_warning", "download_ovpn",
"download_viscosity", "logout", "guides_title", "error_service_unavailable",
"error_forbidden", "logout_reminder", "valid_until", "vpn_instance",
}
for _, k := range required {
v, ok := S[k]
if !ok || strings.TrimSpace(v) == "" {
t.Errorf("String %q fehlt oder ist leer", k)
}
}
if T("gibt_es_nicht") != "gibt_es_nicht" {
t.Error("T muss bei unbekanntem Schlüssel den Schlüssel liefern, nie einen leeren String")
}
}