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,139 @@
package web
import (
"fmt"
"testing"
"time"
)
func TestLimiterAllowsFirstAttempts(t *testing.T) {
l := NewLimiter(nil)
for i := 0; i < 3; i++ {
if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok {
t.Fatalf("Versuch %d muss erlaubt sein", i+1)
}
l.RecordFailure("mmueller", "10.0.0.1")
}
if _, ok := l.Allow("mmueller", "10.0.0.1"); ok {
t.Fatal("nach 3 Fehlversuchen muss gebremst werden")
}
}
func TestBackoffGrowsAndExpires(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
l := NewLimiter(func() time.Time { return now })
for i := 0; i < 4; i++ {
l.RecordFailure("mmueller", "10.0.0.1")
}
wait1, ok := l.Allow("mmueller", "10.0.0.1")
if ok {
t.Fatal("nach 4 Fehlversuchen muss gebremst werden")
}
if wait1 <= 0 {
t.Fatalf("retryAfter = %v, muss positiv sein", wait1)
}
// Wartezeit absitzen -> wieder erlaubt.
now = now.Add(wait1 + time.Second)
if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok {
t.Fatal("nach Ablauf der Wartezeit muss wieder erlaubt sein")
}
// Weiterer Fehlversuch -> längere Wartezeit als vorher.
l.RecordFailure("mmueller", "10.0.0.1")
wait2, ok := l.Allow("mmueller", "10.0.0.1")
if ok {
t.Fatal("erneuter Fehlversuch muss wieder bremsen")
}
if wait2 <= wait1 {
t.Errorf("Backoff muss wachsen: %v -> %v", wait1, wait2)
}
}
func TestBackoffIsCapped(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
l := NewLimiter(func() time.Time { return now })
for i := 0; i < 40; i++ {
l.RecordFailure("mmueller", "10.0.0.1")
}
wait, ok := l.Allow("mmueller", "10.0.0.1")
if ok {
t.Fatal("muss gebremst sein")
}
if wait > maxBackoff {
t.Errorf("retryAfter = %v, Deckel ist %v", wait, maxBackoff)
}
}
func TestIPLimitProtectsAgainstUserSpray(t *testing.T) {
l := NewLimiter(nil)
// Angreifer probiert viele verschiedene Benutzernamen von einer IP.
for i := 0; i < 12; i++ {
l.RecordFailure(fmt.Sprintf("user%d", i), "10.0.0.9")
}
if _, ok := l.Allow("nochNieProbiert", "10.0.0.9"); ok {
t.Fatal("die IP muss trotz wechselnder Benutzernamen gebremst werden")
}
}
func TestUserLimitProtectsAcrossIPs(t *testing.T) {
l := NewLimiter(nil)
// Verteilter Angriff auf einen Benutzer von vielen IPs.
for i := 0; i < 12; i++ {
l.RecordFailure("mmueller", fmt.Sprintf("10.0.1.%d", i))
}
if _, ok := l.Allow("mmueller", "192.168.99.99"); ok {
t.Fatal("der Benutzer muss auch von einer neuen IP gebremst werden")
}
}
func TestUsernameIsCaseInsensitive(t *testing.T) {
l := NewLimiter(nil)
for i := 0; i < 6; i++ {
l.RecordFailure("MMueller", "10.0.0.1")
}
// Andere Schreibweise darf den Zähler nicht umgehen.
if _, ok := l.Allow("mmueller", "192.168.5.5"); ok {
t.Fatal("Schreibweise des Benutzernamens darf das Limit nicht umgehen")
}
}
func TestSuccessResetsBothCounters(t *testing.T) {
l := NewLimiter(nil)
for i := 0; i < 5; i++ {
l.RecordFailure("mmueller", "10.0.0.1")
}
if _, ok := l.Allow("mmueller", "10.0.0.1"); ok {
t.Fatal("Vorbedingung: muss gebremst sein")
}
l.RecordSuccess("mmueller", "10.0.0.1")
if _, ok := l.Allow("mmueller", "10.0.0.1"); !ok {
t.Fatal("erfolgreiche Anmeldung muss die Zähler zurücksetzen")
}
}
func TestNoHardLockout(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
l := NewLimiter(func() time.Time { return now })
for i := 0; i < 100; i++ {
l.RecordFailure("opfer", "10.0.0.1")
}
// Nach dem gedeckelten Backoff darf der Benutzer wieder anklopfen —
// sonst könnte ein Dritter Kollegen dauerhaft aussperren.
now = now.Add(maxBackoff + time.Second)
if _, ok := l.Allow("opfer", "10.0.0.1"); !ok {
t.Fatal("es darf keinen dauerhaften Lockout geben")
}
}
func TestGCDropsIdleEntries(t *testing.T) {
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
l := NewLimiter(func() time.Time { return now })
l.RecordFailure("mmueller", "10.0.0.1")
now = now.Add(2 * entryTTL)
l.GC()
if n := l.size(); n != 0 {
t.Fatalf("GC muss veraltete Einträge entfernen, size = %d", n)
}
}