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
|
// 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.
|
// global index + post-filter when the tenant index manager is not wired.
|
||||||
tenantID := tenantFromCtx(r.Context())
|
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{
|
s.audlog.Log(audit.Entry{
|
||||||
EventType: audit.EventSearch,
|
EventType: audit.EventSearch,
|
||||||
Username: sess.Username,
|
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
|
// emailsFromHeader parses a mail header value (e.g. From, To, CC) and returns
|
||||||
// the bare email addresses it contains. Parsing uses net/mail.ParseAddressList
|
// the bare email addresses it contains. Parsing uses net/mail.ParseAddressList
|
||||||
// for correctness; if parsing fails the raw value is returned as a single
|
// for correctness. If parsing fails, nil is returned (fail-closed): a
|
||||||
// lower-cased string so that the caller can still apply a substring fallback.
|
// malformed or attacker-controlled header must not grant access.
|
||||||
func emailsFromHeader(header string) []string {
|
func emailsFromHeader(header string) []string {
|
||||||
addrs, err := mail.ParseAddressList(header)
|
addrs, err := mail.ParseAddressList(header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Parsing failed (e.g. malformed header) — return raw value for fallback.
|
// SEC: fail-closed — unparseable header grants no access.
|
||||||
return []string{strings.ToLower(header)}
|
return nil
|
||||||
}
|
}
|
||||||
out := make([]string, len(addrs))
|
out := make([]string, len(addrs))
|
||||||
for i, a := range addrs {
|
for i, a := range addrs {
|
||||||
@@ -446,14 +453,10 @@ func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail string) bool {
|
|||||||
checkHeader := func(header string) bool {
|
checkHeader := func(header string) bool {
|
||||||
parsed := emailsFromHeader(header)
|
parsed := emailsFromHeader(header)
|
||||||
for _, addr := range parsed {
|
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 {
|
if addr == target {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Fallback: if parsing returned the raw value (error path), use substring.
|
|
||||||
if strings.Contains(addr, target) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,6 +12,10 @@ import (
|
|||||||
_ "github.com/go-sql-driver/mysql"
|
_ "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.
|
// manticoreIndex implements Indexer against a single Manticore RT table.
|
||||||
type manticoreIndex struct {
|
type manticoreIndex struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
@@ -304,11 +309,19 @@ func hashMailID(id string) uint64 {
|
|||||||
|
|
||||||
// manticoreTableName returns the RT table name for a given tenant.
|
// manticoreTableName returns the RT table name for a given tenant.
|
||||||
// nil / 0 → emails_global, otherwise emails_tenant_<id>.
|
// 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 {
|
func manticoreTableName(tenantID *int64) string {
|
||||||
|
var name string
|
||||||
if tenantID == nil || *tenantID == 0 {
|
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
|
// escapeManticoreMatch escapes characters that have special meaning in
|
||||||
|
|||||||
Reference in New Issue
Block a user