findings of the security review of stage 0

The review of 2c27275..2c8e981 (read-only, a separate agent) found no high
or medium; three small ones, fixed here:

- reauth took a limiter attempt before the compare, right password
  included: with A2 asking on every user save, ten saves in fifteen
  minutes locked the administrator out of Account's password change, the
  backup and the export. A right password now gives its attempt back
  (rateLimiter.Refund, Module.PasswordCheckPassed; a bucket already at its
  limit stays blocked). TestRightAccountPasswordsDoNotUseUpTheLimit.
- LoggedPath hid the tokens of /login/reset/ and /confirm/ but not of the
  first administrator's /setup/<token>; now it does (test case added).
- The entrypoint's own `[ -e ] || : >` lines (sender_login_maps, the four
  inbound maps, mail.log) get the `[ -L ]` guard postfix-config.sh got for
  I2. Safe before too - drop_links runs first on those paths and the panel
  is not running yet - this is the belt and braces.

The review also confirmed, with a probe of the regex against Postfix's
formats, that a port-25 client can place only its own envelope in an
Inbound log row, and that every path to a password, e-mail or role asks
for the signed-in password.

go test ./internal/web/handlers ./internal/web/auth; sh -n entrypoint.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-17 21:58:38 +03:00
co-authored by Claude Opus 5.5
parent fd29468e00
commit e9d2cbbe28
9 changed files with 46 additions and 6 deletions
+8
View File
@@ -100,6 +100,14 @@ func (m *Module) AllowPasswordChange(userID int64) bool {
return m.passwordLimiter.Allow("user:" + strconv.FormatInt(userID, 10))
}
// PasswordCheckPassed gives back the attempt AllowPasswordChange took for a
// check that found the right password: only wrong guesses count, so an
// administrator who adds ten users in a quarter of an hour is not locked out
// of the next backup (review of audit 2026-10-17 stage 0).
func (m *Module) PasswordCheckPassed(userID int64) {
m.passwordLimiter.Refund("user:" + strconv.FormatInt(userID, 10))
}
// SessionToken returns the session token the request carries, if exactly one
// cookie of that name is present.
func (m *Module) SessionToken(r *http.Request) (string, bool) {
+1 -1
View File
@@ -407,7 +407,7 @@ func loginInfo(r *http.Request) string {
// Every log line that names a request's path goes through it: the origin
// check's refusal and the duplicate-cookie line (audit 2026-10-17, A4).
func LoggedPath(p string) string {
for _, prefix := range []string{"/login/reset/", "/confirm/"} {
for _, prefix := range []string{"/login/reset/", "/confirm/", "/setup/"} {
if strings.HasPrefix(p, prefix) && len(p) > len(prefix) {
return prefix + "…"
}
+1
View File
@@ -8,6 +8,7 @@ func TestLoggedPathHidesTokens(t *testing.T) {
for in, want := range map[string]string{
"/login/reset/abcDEF123": "/login/reset/…",
"/confirm/abcDEF123": "/confirm/…",
"/setup/abcDEF123": "/setup/…",
"/login/reset": "/login/reset",
"/confirm/": "/confirm/",
"/account/profile": "/account/profile",
+11
View File
@@ -74,6 +74,17 @@ func (r *rateLimiter) Allow(key string) bool {
return true
}
// Refund gives back the attempt Allow took for key, for an attempt that
// turned out not to be a guess (a right password). A bucket already at its
// limit stays there: a refund never unblocks.
func (r *rateLimiter) Refund(key string) {
r.mu.Lock()
defer r.mu.Unlock()
if b := r.buckets[key]; b != nil && b.count > 0 && b.count < r.max {
b.count--
}
}
func (r *rateLimiter) makeRoom(now time.Time) {
if r.maxBuckets <= 0 || len(r.buckets) < r.maxBuckets {
return
+1
View File
@@ -353,6 +353,7 @@ func (h *Handlers) reauth(r *http.Request, what string) (int, string) {
logf("panel: %s: user %d gave a wrong account password", what, p.ID)
return http.StatusUnauthorized, "Your account password is incorrect."
}
h.auth.PasswordCheckPassed(p.ID)
return 0, ""
}
@@ -134,3 +134,18 @@ func TestUserFormAsksForTheAccountPassword(t *testing.T) {
t.Fatalf("delete with the right password = %d:\n%s", rec.Code, rec.Body.String())
}
}
// Only wrong guesses count against the account-password limit: a dozen
// right ones in a row - an administrator adding users - leave the next
// backup open (review of audit 2026-10-17 stage 0).
func TestRightAccountPasswordsDoNotUseUpTheLimit(t *testing.T) {
h, st := inboundHandlers(t)
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
withAccountPassword(t, h, st, root)
for i := 0; i < 12; i++ {
form := url.Values{"username": {"new-user-" + idStr(int64(i))}, "password": {"a-long-enough-password-1"}, "role": {"global"}, "account_password": {testAccountPassword}}
if rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, form); rec.Code != http.StatusSeeOther {
t.Fatalf("user %d = %d:\n%.600s", i, rec.Code, rec.Body.String())
}
}
}