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
}