fix(security): emailsFromHeader fail-closed, domain_auditor-Block, Manticore-Tabellenvalidierung

- 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 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-04-04 02:01:50 +02:00
parent 896f8dceb9
commit e1f25f2287
2 changed files with 28 additions and 12 deletions
+13 -10
View File
@@ -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
}
+15 -2
View File
@@ -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_<digits> 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_<id>.
// 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