fix(09): WR-12 time a missing admin login at the configured bcrypt cost

This commit is contained in:
Jakub Zych
2026-10-01 21:17:37 +02:00
parent 3f476164f9
commit eb8c727790
2 changed files with 45 additions and 8 deletions

View File

@@ -9,6 +9,7 @@ import (
"net" "net"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/backpack"
@@ -142,7 +143,7 @@ func (s *service) login(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError) WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return return
} }
hash := dummyPasswordHash hash := s.missingUserHash()
if found && user.Password != "" { if found && user.Password != "" {
hash = user.Password hash = user.Password
} }
@@ -496,11 +497,23 @@ func adminIssuer(app *backpack.App, prefix string) string {
return base + prefix + adminAPIVersion + "/auth/login" return base + prefix + adminAPIVersion + "/auth/login"
} }
// dummyPasswordHash keeps a missing-user login on the bcrypt path. // missingHashes caches the unknown-login hash per bcrypt cost.
var dummyPasswordHash = func() string { var missingHashes sync.Map
hash, err := bouncer.HashPassword(10, "cabana-invalid-credentials")
if err != nil { // missingUserHash is the hash a login for an unknown (or ambiguous) identifier
return "" // is checked against, so it costs the same bcrypt work as a real admin's. It is
// built once per cost at the service's configured cost, the cost stored hashes
// are rehashed to on login.
func (s *service) missingUserHash() string {
cost := s.bcryptCost
if cached, ok := missingHashes.Load(cost); ok {
return cached.(string)
}
hash, err := bouncer.HashPassword(cost, "cabana-invalid-credentials")
if err != nil {
// An unusable cost: fall back to the default, still on the bcrypt path.
hash, _ = bouncer.HashPassword(10, "cabana-invalid-credentials")
}
actual, _ := missingHashes.LoadOrStore(cost, hash)
return actual.(string)
} }
return hash
}()

View File

@@ -0,0 +1,24 @@
package cabana
import (
"testing"
"golang.org/x/crypto/bcrypt"
)
// TestMissingUserHashUsesConfiguredCost pins WR-12: a login for an unknown
// identifier is checked against a hash at the configured bcrypt cost, so it
// costs the same as a real admin's (whose hash is rehashed to that cost).
func TestMissingUserHashUsesConfiguredCost(t *testing.T) {
for _, cost := range []int{4, 6} {
s := &service{bcryptCost: cost}
hash := s.missingUserHash()
got, err := bcrypt.Cost([]byte(hash))
if err != nil || got != cost {
t.Fatalf("missing-user hash cost = %d, %v; want %d", got, err, cost)
}
if again := s.missingUserHash(); again != hash {
t.Fatal("missing-user hash was rebuilt instead of reused")
}
}
}