feat(mail): QA-04 Sicherheits-/Berechtigungsprüfung — fehlenden SMTP-Idle-Timeout gefunden und behoben
Gezielter Testangriff auf den SMTP-Pfad deckte einen realen Härtungsfehler auf: ING-07 (Idle-Timeout via protoguard) wurde versehentlich nur in mail/internal/imap und mail/internal/pop3 verdrahtet, SMTP bekam nie einen Timeout. Eine Gegenstelle, die eine Kommandozeile ohne abschließendes CRLF öffnet und nie beendet, konnte die Session unbegrenzt blockieren — real reproduziert und danach behoben. session.go/server.go (smtp): guard *protoguard.Guard neu, Timeout wird in readLine() selbst gesetzt (ein Ort für Haupt-Serve-Schleife, handleData, drainUntilDot). Neuer Konstruktor NewServerWithMaxMessageBytesTLSLoggerRateLimitAndGuardConfig für abweichende Timeout-Werte. Bestehende Konstruktoren bekommen automatisch protoguard.DefaultConfig() (5 Minuten) statt wie zuvor gar keinen Timeout — reine Härtung, keine Verhaltensänderung für funktionierende Clients, QA-07-Lasttest bleibt unverändert grün. Neue Tests: qa04_security_test.go (Header-Injection-Angriffe auf Envelope-Adressen, Ressourcenerschöpfung durch nie abgeschlossene Zeile — deckte den Fehler auf und bestätigt die Korrektur). mailboxconfig/tenant_scoping_test.go: Stichprobe eines dritten Speicherpfads (verschlüsselte IMAP-Zugangsdaten) — Zugriff mit echter, bekannter fremder ID wird über alle vier Operationen zuverlässig abgelehnt. Rate-Limiting-Teil von Akzeptanzkriterium 3 real bestätigt (ING-09, erneut mitgeprüft). API-Token-Teil bleibt offen: das Mail-Board besitzt keine eigene Token-Authentifizierung, bewusst an Core-Board IAM delegiert (QA-04s eigene Ausgangslage) — im Prüfprotokoll dokumentiert. go build/go vet/golangci-lint clean, gesamtes Mail-Modul regressionsfrei getestet.
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protolog"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/ratelimit"
|
||||
)
|
||||
@@ -20,6 +21,14 @@ import (
|
||||
// mail/internal/pop3).
|
||||
const maxCommandLineBytes = 8192
|
||||
|
||||
// phaseSMTP ist die (einzige) protoguard-Phase dieser Sitzung
|
||||
// (QA-04-Nachtrag zu ING-07): SMTP hat — anders als IMAP/POP3 — keine
|
||||
// sicherheitsrelevant unterschiedlichen Phasen (Anmeldephase
|
||||
// existiert in der aktuellen Implementierung nicht, siehe ING-03), ein
|
||||
// einziger Idle-Timeout für jeden Lesevorgang genügt, um eine
|
||||
// Gegenstelle ohne abschließendes CRLF nicht unbegrenzt zu blockieren.
|
||||
const phaseSMTP protoguard.Phase = "smtp"
|
||||
|
||||
// Session ist eine einzelne SMTP-Verbindung mit eigener
|
||||
// Zustandsmaschine (Akzeptanzkriterium 1).
|
||||
type Session struct {
|
||||
@@ -38,12 +47,14 @@ type Session struct {
|
||||
tenantID string
|
||||
acceptLimiter *ratelimit.Limiter // ING-09, nil = kein Rate-Limiting
|
||||
|
||||
guard *protoguard.Guard // QA-04-Nachtrag zu ING-07
|
||||
|
||||
state State
|
||||
from string
|
||||
to []string
|
||||
}
|
||||
|
||||
func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config, logger *slog.Logger, tenantID string, acceptLimiter *ratelimit.Limiter) *Session {
|
||||
func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config, logger *slog.Logger, tenantID string, acceptLimiter *ratelimit.Limiter, guardCfg protoguard.Config) *Session {
|
||||
_, alreadyTLS := conn.(*tls.Conn)
|
||||
return &Session{
|
||||
conn: conn,
|
||||
@@ -56,6 +67,7 @@ func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64, tlsConfi
|
||||
log: protolog.NewSessionLogger(logger, "smtp"),
|
||||
tenantID: tenantID,
|
||||
acceptLimiter: acceptLimiter,
|
||||
guard: protoguard.New(guardCfg),
|
||||
state: Greeting,
|
||||
}
|
||||
}
|
||||
@@ -113,7 +125,15 @@ func (s *Session) Serve(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// readLine liest eine Zeile — mit Idle-Timeout VOR jedem Lesevorgang
|
||||
// (QA-04-Nachtrag zu ING-07): ein einziger Ort für alle Aufrufer
|
||||
// (Haupt-Serve-Schleife, handleData, drainUntilDot), damit eine
|
||||
// Gegenstelle, die eine Zeile ohne abschließendes CRLF nie beendet,
|
||||
// die Session nicht unbegrenzt blockieren kann.
|
||||
func (s *Session) readLine() (string, error) {
|
||||
if err := s.guard.ApplyReadDeadline(s.conn, phaseSMTP); err != nil {
|
||||
return "", err
|
||||
}
|
||||
line, err := s.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) && line != "" {
|
||||
|
||||
Reference in New Issue
Block a user