From e1f25f228782ebee99ba690919140b8b3439b9b4 Mon Sep 17 00:00:00 2001 From: sysops Date: Sat, 4 Apr 2026 02:01:50 +0200 Subject: [PATCH] fix(security): emailsFromHeader fail-closed, domain_auditor-Block, Manticore-Tabellenvalidierung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - emailsFromHeader gibt bei Parse-Fehler nil zurück (fail-closed) statt raw-Header-String; verhindert Authorization-Bypass via malformiertem From-Header - mailBelongsToUser: strings.Contains-Fallback entfernt (war dead code nach dem fix-closed-Fix) - handleSearch: domain_auditor ohne TenantID wird mit 403 abgewiesen, bevor der globale Index abgefragt wird - manticoreTableName: Regex-Validierung ^emails_(global|tenant_\d+)$ mit panic bei Abweichung Co-Authored-By: Claude Sonnet 4.6 --- internal/api/search_handlers.go | 23 +++++++++++++---------- internal/index/manticore.go | 17 +++++++++++++++-- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/internal/api/search_handlers.go b/internal/api/search_handlers.go index 613b81b..c7b23ca 100644 --- a/internal/api/search_handlers.go +++ b/internal/api/search_handlers.go @@ -84,6 +84,14 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { } } + // SEC: domain_auditor without an assigned tenant must not search at all — + // they would otherwise fall through to the global index unfiltered. + sess := sessionFromCtx(r.Context()) + if sess.Role == userstore.RoleDomainAuditor && sess.TenantID == nil { + writeError(w, http.StatusForbidden, "access denied") + return + } + // PROJ-21 Phase 4: Use per-tenant index when available; fall back to // global index + post-filter when the tenant index manager is not wired. tenantID := tenantFromCtx(r.Context()) @@ -140,7 +148,6 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { } } - sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: audit.EventSearch, Username: sess.Username, @@ -421,13 +428,13 @@ func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) { // emailsFromHeader parses a mail header value (e.g. From, To, CC) and returns // the bare email addresses it contains. Parsing uses net/mail.ParseAddressList -// for correctness; if parsing fails the raw value is returned as a single -// lower-cased string so that the caller can still apply a substring fallback. +// for correctness. If parsing fails, nil is returned (fail-closed): a +// malformed or attacker-controlled header must not grant access. func emailsFromHeader(header string) []string { addrs, err := mail.ParseAddressList(header) if err != nil { - // Parsing failed (e.g. malformed header) — return raw value for fallback. - return []string{strings.ToLower(header)} + // SEC: fail-closed — unparseable header grants no access. + return nil } out := make([]string, len(addrs)) for i, a := range addrs { @@ -446,14 +453,10 @@ func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail string) bool { checkHeader := func(header string) bool { parsed := emailsFromHeader(header) for _, addr := range parsed { - // Exact match after parsing — preferred path. + // Exact match on parsed address — only valid path after fail-closed parsing. if addr == target { return true } - // Fallback: if parsing returned the raw value (error path), use substring. - if strings.Contains(addr, target) { - return true - } } return false } diff --git a/internal/index/manticore.go b/internal/index/manticore.go index 68e27cf..2d51a0f 100644 --- a/internal/index/manticore.go +++ b/internal/index/manticore.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "hash/fnv" + "regexp" "strings" "sync" "time" @@ -11,6 +12,10 @@ import ( _ "github.com/go-sql-driver/mysql" ) +// validTableName guards against SQL injection via table name interpolation. +// Only emails_global and emails_tenant_ are valid Manticore RT tables. +var validTableName = regexp.MustCompile(`^emails_(global|tenant_\d+)$`) + // manticoreIndex implements Indexer against a single Manticore RT table. type manticoreIndex struct { db *sql.DB @@ -304,11 +309,19 @@ func hashMailID(id string) uint64 { // manticoreTableName returns the RT table name for a given tenant. // nil / 0 → emails_global, otherwise emails_tenant_. +// Panics if the resulting name does not match validTableName — this would +// indicate a programming error, not a runtime condition. func manticoreTableName(tenantID *int64) string { + var name string if tenantID == nil || *tenantID == 0 { - return "emails_global" + name = "emails_global" + } else { + name = fmt.Sprintf("emails_tenant_%d", *tenantID) } - return fmt.Sprintf("emails_tenant_%d", *tenantID) + if !validTableName.MatchString(name) { + panic(fmt.Sprintf("manticore: invalid table name: %q", name)) + } + return name } // escapeManticoreMatch escapes characters that have special meaning in