panel-redesign: security review of 2.0 - sessions belong to a user by id

Stage 3, step 2. Reviewed by Fable (read-only), fixed by Sonnet with a
failing test before each fix, checked by Opus. No Critical or High
finding: route authorization, tenant isolation by id, the /server/
subtree guard, output encoding and the origin check were found sound.

Fixed
  - M1: a session was keyed by user name and had no link to the user. A
    deleted or renamed user's cookie stayed alive (renewed on every hit,
    before the user was looked up) and became a session of whoever was
    later created under that name, with that account's role. The
    baseline now has sessions.user_id REFERENCES users(id) ON DELETE
    CASCADE; the principal is resolved by id and a session is renewed
    only after its user was found. RenameSession is gone.
  - M2: any user's password change deleted every other user's sessions.
    It ends that user's other sessions only.
  - L1: a password set on the user form ends that user's sessions (an
    administrator editing their own account keeps the current one).
  - L2: store, saslpasswd2 and Postfix reload errors were shown verbatim
    on the application form, the inbound upstream / recipients forms and
    the limit recalculation - pages a domain administrator reaches.
    Validation errors (app.ValidationError, inbound.ValidationError,
    store.ErrNotAutoRateLimit) keep their text; anything else is logged
    and shown as "Could not save ... Check the logs and try again."
  - L3: store.ListDMARCReports treated an empty domain list as "all". It
    takes a scope now (AllDomains or a list; an empty list is no rows),
    and the DMARC hub is open to the domain role as the plan says: the
    reports of the assigned domains, without the instance-wide ingest
    box. The menu entry was already shown to them and led to a 404.
  - I1: /static/ answered with a listing of the embedded assets. A path
    that is not a known asset is a 404.

Consequences
  - The global DMARC hub lists reports of domains that were deleted
    since, too.
  - dev/migrate1x (not in the tree) no longer carries sessions over:
    everyone signs in again after the transfer.
  - docs/schema-migrations.md: the sessions row.

Recorded, not changed - for the owner:
  - L4: the report-address select names the followed user and their
    address to every administrator of the domain, by default the global
    administrator's login name. The plan asks for it.
  - L5: a delegated inbound upstream may be any host and port, loopback
    and private ranges included - within the trusted-operator model; to
    be listed among the accepted risks in security.md.
  - L6: Account's profile is saved without the password; becomes a
    requirement once password-reset mails to users.email.
  - Still echoing an error's text: backup and domain import (global
    only), the domain form's validation messages.

Not checked by the review: anything needing the container (e2e, real
Postfix / DNS), CSP behaviour in a live browser.

  go test -count=1 ./internal/web/... ./internal/store/... ./internal/app/...
    ./internal/inbound/... ./internal/dmarc/... ./cmd/panel
      ok  (all eleven packages; dev/migrate1x also ok)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-10 03:04:02 +03:00
co-authored by Claude Opus 5.5
parent 32c6ae98a1
commit b2fbe91de9
36 changed files with 1035 additions and 196 deletions
+28 -24
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
@@ -167,34 +168,37 @@ func (s *Store) IncrDMARCParseFailures() error {
return s.SetSetting(DMARCParseFailuresKey, fmt.Sprintf("%d", n+1))
}
// ListDMARCReports returns recent summaries, optionally limited to domains.
func (s *Store) ListDMARCReports(domains []string, limit int) ([]DMARCReportSummary, error) {
// DMARCReportScope is whose reports a listing may return: every domain's
// (AllDomains) or those of the named domains. A scope with neither is a viewer
// who reaches nothing and gets no rows — an empty list is never "all"; see
// SendLogFilter, which has the same shape.
type DMARCReportScope struct {
AllDomains bool
Domains []string
}
// ListDMARCReports returns recent summaries within the scope.
func (s *Store) ListDMARCReports(scope DMARCReportScope, limit int) ([]DMARCReportSummary, error) {
if limit <= 0 {
limit = 50
}
var (
rows *sql.Rows
err error
)
if len(domains) == 0 {
rows, err = s.db.Query(`
SELECT id, domain, reporter, period_begin, period_end, received_at, pass_count, fail_count
FROM dmarc_reports ORDER BY received_at DESC LIMIT ?`, limit)
} else {
placeholders := make([]any, 0, len(domains)+1)
q := `SELECT id, domain, reporter, period_begin, period_end, received_at, pass_count, fail_count
FROM dmarc_reports WHERE domain IN (`
for i, d := range domains {
if i > 0 {
q += ","
}
q += "?"
placeholders = append(placeholders, d)
q := `SELECT id, domain, reporter, period_begin, period_end, received_at, pass_count, fail_count
FROM dmarc_reports`
var args []any
if !scope.AllDomains {
if len(scope.Domains) == 0 {
return nil, nil
}
q += `) ORDER BY received_at DESC LIMIT ?`
placeholders = append(placeholders, limit)
rows, err = s.db.Query(q, placeholders...)
marks := make([]string, len(scope.Domains))
for i, d := range scope.Domains {
marks[i] = "?"
args = append(args, d)
}
q += " WHERE domain IN (" + strings.Join(marks, ",") + ")"
}
q += " ORDER BY received_at DESC LIMIT ?"
args = append(args, limit)
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("list dmarc reports: %w", err)
}
@@ -242,7 +246,7 @@ func (s *Store) GetDMARCReport(id int64) (DMARCReport, error) {
// ListDMARCReportsForDomain returns summaries for one sending domain.
func (s *Store) ListDMARCReportsForDomain(domain string, limit int) ([]DMARCReportSummary, error) {
return s.ListDMARCReports([]string{domain}, limit)
return s.ListDMARCReports(DMARCReportScope{Domains: []string{domain}}, limit)
}
// DMARCDomainRollup summarises pass/fail for a domain over the last windowDays.
+42 -2
View File
@@ -29,7 +29,7 @@ func TestDMARCReportRoundTrip(t *testing.T) {
if err != nil {
t.Fatal(err)
}
list, err := st.ListDMARCReports([]string{"example.com"}, 10)
list, err := st.ListDMARCReports(DMARCReportScope{Domains: []string{"example.com"}}, 10)
if err != nil || len(list) != 1 {
t.Fatalf("list: %v err=%v", list, err)
}
@@ -61,8 +61,48 @@ func TestPruneDMARCReports(t *testing.T) {
if err := st.PruneDMARCReports(); err != nil {
t.Fatal(err)
}
list, _ := st.ListDMARCReports(nil, 100)
list, _ := st.ListDMARCReports(DMARCReportScope{AllDomains: true}, 100)
if len(list) != 0 {
t.Fatalf("expected prune by age, got %d", len(list))
}
}
// The domains a list is limited to are the whole answer: an empty list is a
// viewer who reaches nothing, never a viewer who reaches everything.
func TestListDMARCReportsEmptyListIsNoRows(t *testing.T) {
st := openTestStore(t)
now := time.Now().UTC()
for _, domain := range []string{"a.example", "b.example"} {
if _, err := st.InsertDMARCReport(DMARCReport{
Domain: domain, Reporter: "r", ReportID: domain,
PeriodBegin: now.Add(-24 * time.Hour), PeriodEnd: now, ReceivedAt: now,
}); err != nil {
t.Fatal(err)
}
}
for name, scope := range map[string]DMARCReportScope{
"an empty list": {Domains: []string{}},
"no scope at all": {},
} {
list, err := st.ListDMARCReports(scope, 10)
if err != nil {
t.Fatalf("%s is an error: %v", name, err)
}
if len(list) != 0 {
t.Errorf("%s returned %d report(s), want none", name, len(list))
}
}
list, err := st.ListDMARCReports(DMARCReportScope{Domains: []string{"a.example"}}, 10)
if err != nil || len(list) != 1 || list[0].Domain != "a.example" {
t.Errorf("one domain = %+v, %v; want only a.example", list, err)
}
list, err = st.ListDMARCReports(DMARCReportScope{AllDomains: true}, 10)
if err != nil || len(list) != 2 {
t.Errorf("all domains = %d report(s), %v; want both", len(list), err)
}
list, err = st.ListDMARCReports(DMARCReportScope{AllDomains: true, Domains: []string{"a.example"}}, 10)
if err != nil || len(list) != 2 {
t.Errorf("all domains with a list = %d report(s), %v; want both (AllDomains wins)", len(list), err)
}
}
+4 -2
View File
@@ -51,10 +51,12 @@ CREATE TABLE users (
-- redeploy; only the SHA-256 of the session token is stored, never the token
-- itself, so a stolen database file cannot be used to sign in. expires_at
-- implements the sliding idle timeout: it is pushed forward on activity rather
-- than being fixed at creation time.
-- than being fixed at creation time. A session belongs to a user by id, not by
-- name: deleting the user ends their sessions, and renaming them leaves the
-- sessions where they are.
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
username TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
+9 -3
View File
@@ -2,6 +2,7 @@ package store
import (
"database/sql"
"errors"
"fmt"
"log"
"math"
@@ -27,15 +28,20 @@ func (s *Store) RecalcAllAutoRateLimits(retentionDays, l1Max, l1Window int) (int
return updated, nil
}
// RecalcAutoRateLimit recomputes one auto-mode limit. Returns an error when
// the row is missing or not in auto mode.
// ErrNotAutoRateLimit is returned by RecalcAutoRateLimit for a limit that is
// missing or not in auto mode: there is nothing to recalculate. It is the one
// refusal of that call that is the administrator's to read.
var ErrNotAutoRateLimit = errors.New("rate limit not in auto mode")
// RecalcAutoRateLimit recomputes one auto-mode limit. Returns
// ErrNotAutoRateLimit when the row is missing or not in auto mode.
func (s *Store) RecalcAutoRateLimit(scope string, refID int64, retentionDays, l1Max, l1Window int) error {
rl, ok, err := s.GetRateLimit(scope, refID)
if err != nil {
return err
}
if !ok || !rl.IsAuto() {
return fmt.Errorf("rate limit not in auto mode")
return ErrNotAutoRateLimit
}
rl.Scope, rl.RefID = scope, refID
return s.recalcAutoRateLimit(rl, retentionDays, l1Max, l1Window)
+1 -1
View File
@@ -41,7 +41,7 @@ func TestBaselineSchema(t *testing.T) {
want := map[string]string{
"settings": "key value",
"users": "id username password_hash role email dmarc_default_mode dmarc_default_address all_domains all_inbound_domains created_at",
"sessions": "token_hash username created_at expires_at",
"sessions": "token_hash user_id created_at expires_at",
"domains": "id name dkim_selector dmarc_rua dmarc_rua_user_id created_at",
"user_domains": "user_id domain_id",
"applications": "id domain_id login address_mode auth_ip_restrict auth_allowed_ips created_at",
+24 -28
View File
@@ -8,18 +8,19 @@ import (
)
// SessionRow is a persisted login session, keyed by the SHA-256 of its token
// (see internal/web, which owns the token itself).
// (see internal/web, which owns the token itself). It belongs to a user by id:
// deleting the user deletes their sessions, and renaming them keeps them.
type SessionRow struct {
Username string
UserID int64
ExpiresAt time.Time
}
// CreateSession inserts a new session row.
func (s *Store) CreateSession(tokenHash, username string, expiresAt time.Time) error {
// CreateSession inserts a new session row for a user.
func (s *Store) CreateSession(tokenHash string, userID int64, expiresAt time.Time) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`INSERT INTO sessions (token_hash, username, created_at, expires_at) VALUES (?, ?, ?, ?)`,
tokenHash, username, now, expiresAt.UTC().Format(time.RFC3339),
`INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
tokenHash, userID, now, expiresAt.UTC().Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("insert session: %w", err)
@@ -36,9 +37,9 @@ func (s *Store) LookupSession(tokenHash string) (SessionRow, bool, error) {
expiresAt string
)
err := s.db.QueryRow(
`SELECT username, expires_at FROM sessions WHERE token_hash = ?`,
`SELECT user_id, expires_at FROM sessions WHERE token_hash = ?`,
tokenHash,
).Scan(&row.Username, &expiresAt)
).Scan(&row.UserID, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return SessionRow{}, false, nil
}
@@ -65,20 +66,6 @@ func (s *Store) RenewSession(tokenHash string, expiresAt time.Time) error {
return nil
}
// RenameSession updates the username carried by a session, keeping its
// expiry, so a session stays usable after the administrator renames their own
// account.
func (s *Store) RenameSession(tokenHash, username string) error {
_, err := s.db.Exec(
`UPDATE sessions SET username = ? WHERE token_hash = ?`,
username, tokenHash,
)
if err != nil {
return fmt.Errorf("rename session: %w", err)
}
return nil
}
// DeleteSession removes a session row (logout, or a lookup finding it
// expired).
func (s *Store) DeleteSession(tokenHash string) error {
@@ -88,17 +75,26 @@ func (s *Store) DeleteSession(tokenHash string) error {
return nil
}
// DeleteOtherSessions removes every session except keepHash. It is called
// when the administrator changes their password: a stolen cookie issued
// under the old password must stop working, while the session performing the
// change stays signed in.
func (s *Store) DeleteOtherSessions(keepHash string) error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash != ?`, keepHash); err != nil {
// DeleteOtherSessions removes every session of the user except keepHash. It is
// called when the user changes their password: a stolen cookie issued under the
// old password must stop working, while the session performing the change
// stays signed in. Other users' sessions are not touched.
func (s *Store) DeleteOtherSessions(userID int64, keepHash string) error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE user_id = ? AND token_hash != ?`, userID, keepHash); err != nil {
return fmt.Errorf("delete other sessions: %w", err)
}
return nil
}
// DeleteUserSessions removes every session of the user, for when somebody else
// has set their password.
func (s *Store) DeleteUserSessions(userID int64) error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
return fmt.Errorf("delete user sessions: %w", err)
}
return nil
}
// DeleteExpiredSessions prunes rows whose expiry has already passed, so an
// abandoned session (cookie never presented again) does not sit in the table
// forever. It piggybacks on session creation rather than running as its own
+127
View File
@@ -0,0 +1,127 @@
package store
import (
"testing"
"time"
)
func sessionExpiry() time.Time { return time.Now().Add(time.Hour) }
// A session belongs to a person, not to a name: once the user is deleted the
// cookie must be dead for good, including when somebody else is later created
// under the same username.
func TestSessionDoesNotOutliveItsUserOrPassToANewOneWithTheSameName(t *testing.T) {
st := openTestStore(t)
if _, err := st.CreateUser("admin", "h", RoleGlobal, Reach{}); err != nil {
t.Fatal(err)
}
a, err := st.CreateUser("alice", "h", RoleGlobal, Reach{})
if err != nil {
t.Fatal(err)
}
if err := st.CreateSession("old-token", a, sessionExpiry()); err != nil {
t.Fatalf("CreateSession: %v", err)
}
if err := st.DeleteUser(a); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
if _, err := st.CreateUser("alice", "h", RoleGlobal, Reach{}); err != nil {
t.Fatal(err)
}
if _, found, err := st.LookupSession("old-token"); err != nil || found {
t.Fatalf("LookupSession after the user was deleted and the name reused = found %t, err %v; want no session", found, err)
}
}
// Renaming a user must not detach the sessions they already have.
func TestSessionsFollowTheUserThroughARename(t *testing.T) {
st := openTestStore(t)
id, err := st.CreateUser("alice", "h", RoleGlobal, Reach{})
if err != nil {
t.Fatal(err)
}
if err := st.CreateSession("one", id, sessionExpiry()); err != nil {
t.Fatal(err)
}
if err := st.CreateSession("two", id, sessionExpiry()); err != nil {
t.Fatal(err)
}
if err := st.UpdateUser(id, "alicia", "h", ""); err != nil {
t.Fatalf("UpdateUser: %v", err)
}
for _, token := range []string{"one", "two"} {
row, found, err := st.LookupSession(token)
if err != nil || !found {
t.Fatalf("session %q after the rename = found %t, err %v; want it kept", token, found, err)
}
if row.UserID != id {
t.Errorf("session %q belongs to user %d after the rename, want %d", token, row.UserID, id)
}
if u, err := st.GetUser(row.UserID); err != nil || u.Username != "alicia" {
t.Errorf("session %q resolves to %q (%v) after the rename, want alicia", token, u.Username, err)
}
}
}
// A password change signs out the other sessions of the person who changed it,
// not of anyone else.
func TestDeleteOtherSessionsTouchesOnlyThatUsersSessions(t *testing.T) {
st := openTestStore(t)
ids := map[string]int64{}
for _, name := range []string{"alice", "bob"} {
id, err := st.CreateUser(name, "h", RoleGlobal, Reach{})
if err != nil {
t.Fatal(err)
}
ids[name] = id
}
for token, name := range map[string]string{"alice-1": "alice", "bob-1": "bob", "bob-2": "bob"} {
if err := st.CreateSession(token, ids[name], sessionExpiry()); err != nil {
t.Fatal(err)
}
}
if err := st.DeleteOtherSessions(ids["bob"], "bob-1"); err != nil {
t.Fatalf("DeleteOtherSessions: %v", err)
}
for token, want := range map[string]bool{"alice-1": true, "bob-1": true, "bob-2": false} {
if _, found, _ := st.LookupSession(token); found != want {
t.Errorf("session %q present = %t, want %t", token, found, want)
}
}
}
// Setting somebody's password from the user form ends all of their sessions.
func TestDeleteUserSessionsTouchesOnlyThatUser(t *testing.T) {
st := openTestStore(t)
alice, _ := st.CreateUser("alice", "h", RoleGlobal, Reach{})
bob, _ := st.CreateUser("bob", "h", RoleGlobal, Reach{})
for token, id := range map[string]int64{"alice-1": alice, "bob-1": bob, "bob-2": bob} {
if err := st.CreateSession(token, id, sessionExpiry()); err != nil {
t.Fatal(err)
}
}
if err := st.DeleteUserSessions(bob); err != nil {
t.Fatalf("DeleteUserSessions: %v", err)
}
for token, want := range map[string]bool{"alice-1": true, "bob-1": false, "bob-2": false} {
if _, found, _ := st.LookupSession(token); found != want {
t.Errorf("session %q present = %t, want %t", token, found, want)
}
}
}
// A session cannot be written for a user that does not exist.
func TestCreateSessionRefusesAMissingUser(t *testing.T) {
st := openTestStore(t)
if err := st.CreateSession("orphan", 42, sessionExpiry()); err == nil {
t.Fatal("CreateSession for a missing user succeeded")
}
}