From 501ee8f7eae43f419a8c1fc1511d5b408600d487 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 12 Jun 2026 23:31:56 +0200 Subject: [PATCH] =?UTF-8?q?fix(sec):=20Cross-Tenant-IDOR=20bei=20POP3-Kont?= =?UTF-8?q?en=20schlie=C3=9Fen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gleiches Muster wie bei IMAP (730099d): domain_admin konnte POP3-Konten fremder Tenants auflisten, löschen und Importe/Progress fremder Tenants ansehen, da pop3_accounts keine tenant_id hatte und Store.List() für Admins ungefiltert alle Konten lieferte. - pop3_accounts: neue Spalte tenant_id (ALTER TABLE ADD COLUMN IF NOT EXISTS) - Store.List() filtert nach tenant_id, außer für superadmin - Store.Create() setzt tenant_id beim Anlegen - delete/start-import/progress prüfen zusätzlich tenantAccessAllowed() --- internal/api/import_handlers.go | 7 ++++++- internal/pop3/store.go | 25 ++++++++++++++++--------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/internal/api/import_handlers.go b/internal/api/import_handlers.go index e6f2604..e9dfcf2 100644 --- a/internal/api/import_handlers.go +++ b/internal/api/import_handlers.go @@ -400,7 +400,7 @@ func (s *Server) handleListPop3(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) // SEC-03: Use HasRole to correctly check admin privileges (domain_admin, admin, superadmin). isAdmin := auth.HasRole(sess.Role, userstore.RoleDomainAdmin) - accounts, err := s.pop3Store.List(r.Context(), sess.Username, isAdmin) + accounts, err := s.pop3Store.List(r.Context(), sess.Username, isAdmin, sess.TenantID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list POP3 accounts") return @@ -449,6 +449,7 @@ func (s *Server) handleCreatePop3(w http.ResponseWriter, r *http.Request) { TLS: req.TLS, TLSSkipVerify: req.TLSSkipVerify, Username: req.Username, + TenantID: sess.TenantID, } created, err := s.pop3Store.Create(r.Context(), acc, req.Password) @@ -611,6 +612,10 @@ func (s *Server) handlePop3Progress(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, "access denied") return } + if !tenantAccessAllowed(sess, acc.TenantID) { + writeError(w, http.StatusForbidden, "access denied") + return + } writeJSON(w, http.StatusOK, acc) } diff --git a/internal/pop3/store.go b/internal/pop3/store.go index 2e843d1..02f4494 100644 --- a/internal/pop3/store.go +++ b/internal/pop3/store.go @@ -34,6 +34,7 @@ type Account struct { ProgressCurrent int `json:"progress_current"` ProgressTotal int `json:"progress_total"` CreatedAt time.Time `json:"created_at"` + TenantID *int64 `json:"tenant_id,omitempty"` } // Store manages POP3 account persistence in PostgreSQL. @@ -62,6 +63,7 @@ CREATE TABLE IF NOT EXISTS pop3_accounts ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_pop3_accounts_owner ON pop3_accounts (owner); +ALTER TABLE pop3_accounts ADD COLUMN IF NOT EXISTS tenant_id INTEGER REFERENCES tenants(id); ` // New creates a new Store, connects to PostgreSQL, and runs the schema migration. @@ -93,10 +95,10 @@ func (s *Store) Create(ctx context.Context, acc Account, password string) (*Acco } row := s.pool.QueryRow(ctx, ` - INSERT INTO pop3_accounts (owner, name, host, port, tls, tls_skip_verify, username, password_enc) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO pop3_accounts (owner, name, host, port, tls, tls_skip_verify, username, password_enc, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, created_at`, - acc.Owner, acc.Name, acc.Host, acc.Port, acc.TLS, acc.TLSSkipVerify, acc.Username, enc, + acc.Owner, acc.Name, acc.Host, acc.Port, acc.TLS, acc.TLSSkipVerify, acc.Username, enc, acc.TenantID, ) if err := row.Scan(&acc.ID, &acc.CreatedAt); err != nil { @@ -111,7 +113,7 @@ func (s *Store) Create(ctx context.Context, acc Account, password string) (*Acco // selectColumns is the canonical column list used in all SELECT statements. const selectColumns = ` id, owner, name, host, port, tls, tls_skip_verify, username, status, error_msg, last_import_at, last_import_count, - progress_current, progress_total, created_at ` + progress_current, progress_total, created_at, tenant_id ` // scanner abstracts pgx.Row and pgx.Rows — both expose Scan(...any) error. type scanner interface { @@ -123,21 +125,26 @@ func scanRow(row scanner) (Account, error) { err := row.Scan( &a.ID, &a.Owner, &a.Name, &a.Host, &a.Port, &a.TLS, &a.TLSSkipVerify, &a.Username, &a.Status, &a.ErrorMsg, &a.LastImportAt, - &a.LastImportCount, &a.ProgressCurrent, &a.ProgressTotal, &a.CreatedAt, + &a.LastImportCount, &a.ProgressCurrent, &a.ProgressTotal, &a.CreatedAt, &a.TenantID, ) return a, err } -// List returns POP3 accounts. Admins see all accounts; regular users see only their own. -func (s *Store) List(ctx context.Context, owner string, isAdmin bool) ([]Account, error) { +// List returns POP3 accounts. Superadmins (tenantID == nil) see all accounts; +// other admins (tenantID != nil) see all accounts within their own tenant; +// regular users see only their own accounts. +func (s *Store) List(ctx context.Context, owner string, isAdmin bool, tenantID *int64) ([]Account, error) { var rows pgx.Rows var err error q := `SELECT` + selectColumns + `FROM pop3_accounts` - if isAdmin { + switch { + case isAdmin && tenantID == nil: rows, err = s.pool.Query(ctx, q+` ORDER BY id`) - } else { + case isAdmin: + rows, err = s.pool.Query(ctx, q+` WHERE tenant_id = $1 ORDER BY id`, *tenantID) + default: rows, err = s.pool.Query(ctx, q+` WHERE owner = $1 ORDER BY id`, owner) } if err != nil {