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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user