A wrong password from anywhere counted against the address, so ten locked its owner out. Attempts are now counted per address and client, with a looser bound per address alone. The sign-in form carries a token matched by a cookie (login CSRF). POST /login and /signup are rationed per client address, a hash waits at most ten seconds for a turn, and server init writes signup_open: false. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
34 lines
1.0 KiB
Go
34 lines
1.0 KiB
Go
package account
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSprayingAddressesCannotGrowTheThrottleOrEscapeIt(t *testing.T) {
|
|
now := time.Unix(0, 0)
|
|
s := &Service{cfg: DefaultConfig(), attempts: map[string]*attemptWindow{}, now: func() time.Time { return now }}
|
|
|
|
// The target is being guessed at.
|
|
for i := 0; i < s.cfg.MaxAttempts; i++ {
|
|
s.allowAttempt("victim@example.com", s.cfg.MaxAttempts)
|
|
}
|
|
// Then the table is flooded with made-up addresses.
|
|
for i := 0; i < maxTrackedAddresses+100; i++ {
|
|
s.allowAttempt(fmt.Sprintf("x%d@example.com", i), s.cfg.MaxAttempts)
|
|
}
|
|
if n := len(s.attempts); n > maxTrackedAddresses {
|
|
t.Errorf("the table grew to %d", n)
|
|
}
|
|
if s.allowAttempt("victim@example.com", s.cfg.MaxAttempts) {
|
|
t.Error("flooding the table reset the target's throttle")
|
|
}
|
|
|
|
// Once the window passes, everything expires and sign-ins work again.
|
|
now = now.Add(s.cfg.AttemptWindow + time.Second)
|
|
if !s.allowAttempt("new@example.com", s.cfg.MaxAttempts) {
|
|
t.Error("a new address was refused after the flood expired")
|
|
}
|
|
}
|