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:
parent
e49882b8a8
commit
bb2bc223b9
16 changed files with 1292 additions and 0 deletions
139
internal/web/ratelimit_test.go
Normal file
139
internal/web/ratelimit_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue