Files
webhooker/internal/database/password_test.go
sneak fad97445ca
All checks were successful
check / check (push) Successful in 2m54s
Verify login credentials before spending rate-limit budget (closes #150)
With TRUSTED_PROXIES empty behind the reverse proxy production is
required to run behind, every login POST keyed on the proxy's address
and shared one 5/minute bucket. A stranger sending five POSTs a
minute -- 0.08 requests per second, from anywhere -- kept that bucket
permanently full, and the operator's own correct password was answered
429 indefinitely with no second administrative path.

The login POST no longer has a pre-emptive limiter. The handler
verifies credentials first and spends budget only on a FAILED attempt,
so a correct password is never throttled whatever the counters hold.
Three things follow, and are implemented together because the first is
unsafe without the other two:

- Failures are counted per (client bucket, submitted username), five
  per minute, after which further failures get 429 with a Retry-After.
  A successful login clears the counter, so mistyping and then
  succeeding does not leave the operator throttled.
- Both key sets are capped at 1024 entries. The submitted username is
  attacker-controlled, so past the first cap failures fall back to a
  counter keyed on the client alone, and past both caps a failure is
  answered as throttled without being recorded. Tracked state stays
  under half a megabyte and does not grow with invented usernames.
- Concurrent Argon2id verifications are capped at two, a 128 MB
  ceiling at 64 MB per hash. Every password-hashing endpoint takes a
  slot, including the password-change endpoint, which holds one across
  both its hashes. A request that waits five seconds without a slot is
  answered 503 and no hash runs for it.

An unknown username is verified against a dummy hash instead of
returning early, so a nonexistent account costs the same time as a
real one and the response cannot be used to enumerate usernames.

The password-change limiter is unchanged: RequireAuth runs ahead of
it, so only a request already carrying a valid session reaches its
bucket.

Also adds the missing test for the third bucketKey call site, where
the peer is a trusted proxy but the forwarded chain names no client.
Every existing test of that fallback uses an IPv4 proxy, where
bucketKey is the identity function, so dropping the /64 masking there
left the suite green.

README and the TRUSTED_PROXIES startup warning updated: a shared
bucket now costs precision, not the availability of the admin path.
2026-08-17 22:17:49 +00:00

232 lines
4.7 KiB
Go

package database_test
import (
"strings"
"testing"
"sneak.berlin/go/webhooker/internal/database"
)
func TestGenerateRandomPassword(t *testing.T) {
t.Parallel()
tests := []struct {
name string
length int
}{
{"Short password", 8},
{"Medium password", 16},
{"Long password", 32},
{"Very short password", 3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
password, err := database.GenerateRandomPassword(
tt.length,
)
if err != nil {
t.Fatalf(
"GenerateRandomPassword() error = %v",
err,
)
}
if len(password) != tt.length {
t.Errorf(
"Password length = %v, want %v",
len(password), tt.length,
)
}
checkPasswordComplexity(
t, password, tt.length,
)
})
}
}
func checkPasswordComplexity(
t *testing.T,
password string,
length int,
) {
t.Helper()
// For passwords >= 4 chars, check complexity
if length < 4 {
return
}
flags := classifyChars(password)
if !flags[0] || !flags[1] || !flags[2] || !flags[3] {
t.Errorf(
"Password lacks required complexity: "+
"upper=%v, lower=%v, digit=%v, special=%v",
flags[0], flags[1], flags[2], flags[3],
)
}
}
func classifyChars(s string) [4]bool {
var flags [4]bool // upper, lower, digit, special
for _, char := range s {
switch {
case char >= 'A' && char <= 'Z':
flags[0] = true
case char >= 'a' && char <= 'z':
flags[1] = true
case char >= '0' && char <= '9':
flags[2] = true
case strings.ContainsRune(
"!@#$%^&*()_+-=[]{}|;:,.<>?",
char,
):
flags[3] = true
}
}
return flags
}
func TestGenerateRandomPasswordUniqueness(t *testing.T) {
t.Parallel()
// Generate multiple passwords and ensure they're different
passwords := make(map[string]bool)
const numPasswords = 100
for range numPasswords {
password, err := database.GenerateRandomPassword(16)
if err != nil {
t.Fatalf(
"GenerateRandomPassword() error = %v",
err,
)
}
if passwords[password] {
t.Errorf(
"Duplicate password generated: %s",
password,
)
}
passwords[password] = true
}
}
func TestHashPassword(t *testing.T) {
t.Parallel()
password := "testPassword123!"
hash, err := database.HashPassword(password)
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
// Check that hash has correct format
if !strings.HasPrefix(hash, "$argon2id$") {
t.Errorf(
"Hash doesn't have correct prefix: %s",
hash,
)
}
// Verify password
valid, err := database.VerifyPassword(password, hash)
if err != nil {
t.Fatalf("VerifyPassword() error = %v", err)
}
if !valid {
t.Error(
"VerifyPassword() returned false " +
"for correct password",
)
}
// Verify wrong password fails
valid, err = database.VerifyPassword(
"wrongPassword", hash,
)
if err != nil {
t.Fatalf("VerifyPassword() error = %v", err)
}
if valid {
t.Error(
"VerifyPassword() returned true " +
"for wrong password",
)
}
}
func TestHashPasswordUniqueness(t *testing.T) {
t.Parallel()
password := "testPassword123!"
// Same password should produce different hashes
hash1, err := database.HashPassword(password)
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
hash2, err := database.HashPassword(password)
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
if hash1 == hash2 {
t.Error(
"Same password produced identical hashes " +
"(salt not working)",
)
}
}
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
// path. Login charges an unknown username a verification against a
// dummy hash so that a nonexistent account is not answered in
// microseconds where a real one takes tens of milliseconds. That only
// works if the dummy hash is a real, decodable Argon2id hash: a
// malformed one would make VerifyPassword fail on the decode and
// return before hashing anything.
func TestVerifyDummyPassword_DoesRealWork(t *testing.T) {
t.Parallel()
// Runs the OnceValue that builds the dummy hash, so a panic in
// it surfaces here rather than on a live login.
database.VerifyDummyPassword("whatever was submitted")
dummy := database.DummyPasswordHashForTest()
// A hash the verifier cannot decode would make VerifyPassword
// return on the decode error, before hashing anything — the
// timing oracle this path exists to close.
valid, err := database.VerifyPassword("whatever", dummy)
if err != nil {
t.Fatalf(
"the dummy hash must decode like a real one: %v", err,
)
}
if valid {
t.Error("nothing may authenticate against the dummy hash")
}
if !strings.HasPrefix(dummy, "$argon2id$") {
t.Errorf(
"the dummy hash must use the same algorithm as real "+
"hashes, got %q", dummy,
)
}
}