Security(Go): Login rate limit + WS origin allowlist + REST bearer auth

This commit is contained in:
yuanyuanxiang
2026-05-18 23:37:58 +02:00
parent 5947d41617
commit c6244462a9
8 changed files with 566 additions and 41 deletions

View File

@@ -95,13 +95,26 @@ func (a *Authenticator) AddUser(u User) {
a.mu.Unlock()
}
// AddAdminFromPlainPassword is a convenience for the bootstrap admin: salt is
// empty (matching the C++ admin record), hash is SHA256(password).
// AddAdminFromPlainPassword is a convenience for the bootstrap admin.
// Unlike legacy convention, the admin record is given a real per-instance
// salt — exposing an empty salt for admin while everyone else has a real
// 16-hex one would let an unauthenticated probe distinguish admin from
// other accounts via /get_salt alone. The cost is a tiny break in
// users.json schema compat: admin is never persisted to users.json
// anyway (snapshotPersistableLocked excludes it), so this is in-memory
// only.
func (a *Authenticator) AddAdminFromPlainPassword(username, plainPassword string) {
salt, err := NewSalt()
if err != nil {
// Fall back to deterministic salt derived from the password hash
// rather than empty — preserves the uniform-shape property even
// if crypto/rand briefly errors at startup.
salt = ComputeSHA256(plainPassword)[:saltBytes*2]
}
a.AddUser(User{
Username: username,
PasswordHash: ComputeSHA256(plainPassword),
Salt: "",
PasswordHash: HashPassword(plainPassword, salt),
Salt: salt,
Role: "admin",
})
}
@@ -200,17 +213,43 @@ func (a *Authenticator) ListUsers() []User {
return out
}
// GetSalt returns the per-user salt. If the user does not exist, returns ("", false).
// Note: the C++ admin uses an empty salt — that is still considered "found"
// and the empty string is returned with ok=true.
// GetSalt returns the per-user salt for an existing user, or a
// deterministic 16-hex pseudo-salt for an unknown user. The ok flag
// reports which case occurred, so callers can decide whether to update
// rate-limit / audit state — but the returned salt itself is shaped
// identically (16 hex chars) in both cases, defeating the user-existence
// probe an attacker would otherwise mount via /get_salt.
//
// The pseudo-salt is derived from a server-instance secret (the admin
// password hash, taken at first call) mixed with the username, so the
// same unknown user always sees the same fake salt across requests.
// Without this, an attacker could fingerprint the "fake-salt branch"
// by submitting the same username twice and watching for differences.
func (a *Authenticator) GetSalt(username string) (string, bool) {
a.mu.RLock()
u, ok := a.users[username]
a.mu.RUnlock()
if !ok {
return "", false
if ok {
return u.Salt, true
}
return u.Salt, true
return a.fakeSalt(username), false
}
// fakeSalt derives a deterministic 16-hex value for unknown usernames.
// The secret pepper is the bootstrap admin's password hash — present as
// long as the server has any admin, deterministic per deployment, never
// transmitted. Reveals nothing useful to an attacker even if reverse-
// engineered: the only thing they can do with it is reproduce the fake
// salt, which they already see in the response.
func (a *Authenticator) fakeSalt(username string) string {
a.mu.RLock()
pepper := ""
if admin, ok := a.users["admin"]; ok {
pepper = admin.PasswordHash
}
a.mu.RUnlock()
digest := ComputeSHA256("yama-fake-salt|" + pepper + "|" + username)
return digest[:saltBytes*2]
}
// VerifyLogin checks a challenge-response login. The browser sends