package pop3 import ( "bufio" "context" "fmt" "strconv" "strings" "gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert" ) // genericAuthFailure ist bewusst IMMER derselbe Text, unabhängig davon, // ob der Benutzername unbekannt oder nur das Passwort falsch war // (Akzeptanzkriterium 3: fehlerhafte Anmeldeversuche ohne // Informationspreisgabe). const genericAuthFailure = "authentication failed" func (s *Session) handleUser(cmd command) bool { if s.state != Authorization { return writeErr(s.writer, "command not valid in this state") == nil } if len(cmd.Args) != 1 { return writeErr(s.writer, "USER requires a username") == nil } // RFC 1939: USER antwortet immer mit +OK, unabhängig davon, ob der // Name existiert — die eigentliche Prüfung passiert erst bei PASS // (Akzeptanzkriterium 3: keine Informationspreisgabe schon an dieser // Stelle). s.pendingUsername = cmd.Args[0] return writeOK(s.writer, "send PASS") == nil } func (s *Session) handlePass(ctx context.Context, cmd command) bool { if s.state != Authorization { return writeErr(s.writer, "command not valid in this state") == nil } if s.pendingUsername == "" { return writeErr(s.writer, genericAuthFailure) == nil } if len(cmd.Args) != 1 { return writeErr(s.writer, "PASS requires a password") == nil } if s.tlsConfig != nil && !s.tlsActive { // Akzeptanzkriterium 1 (ING-06): Anmeldedaten werden // ausschließlich über TLS oder nach erfolgreichem STLS // akzeptiert, sobald der Server TLS überhaupt anbietet. return writeErr(s.writer, "TLS required before authentication, use STLS") == nil } if s.loginLimiter != nil { if ok, retryAfter := s.loginLimiter.Allow(s.tenantID, s.sourceAddr()); !ok { // Akzeptanzkriterium 1/3 (ING-09): definierte Ablehnung MIT // Wartezeit-Hinweis statt Verbindungsabbruch ohne Erklärung — // die Verbindung bleibt offen (true), nur DIESER Versuch wird // abgelehnt. return writeErr(s.writer, fmt.Sprintf("rate limit exceeded, retry in %.1fs", retryAfter.Seconds())) == nil } } if s.auth == nil { return writeErr(s.writer, genericAuthFailure) == nil } ok, err := s.auth.Authenticate(ctx, s.pendingUsername, cmd.Args[0]) if err != nil || !ok { // Backoff statt Dauerschleife bei wiederholten Fehlversuchen // (Akzeptanzkriterium 3, ING-07). Immer derselbe generische Text, // egal ob unbekannter Nutzer, falsches Passwort oder interner // Fehler. backoff, disconnect := s.guard.RecordAuthFailure() s.guard.Wait(ctx, backoff) if err := writeErr(s.writer, genericAuthFailure); err != nil { return false } return !disconnect } s.guard.ResetAuthFailures() s.username = s.pendingUsername s.state = Transaction return writeOK(s.writer, "maildrop locked and ready") == nil } func (s *Session) handleStat(ctx context.Context) bool { if s.state != Transaction { return writeErr(s.writer, "command not valid in this state") == nil } messages, err := s.activeMessages(ctx) if err != nil { return writeErr(s.writer, "unable to read maildrop") == nil } var totalSize int64 for _, m := range messages { totalSize += m.Size } return writeOK(s.writer, fmt.Sprintf("%d %d", len(messages), totalSize)) == nil } func (s *Session) handleList(ctx context.Context, cmd command) bool { if s.state != Transaction { return writeErr(s.writer, "command not valid in this state") == nil } messages, err := s.activeMessages(ctx) if err != nil { return writeErr(s.writer, "unable to read maildrop") == nil } if len(cmd.Args) == 1 { n, convErr := strconv.Atoi(cmd.Args[0]) if convErr != nil { return writeErr(s.writer, "invalid message number") == nil } for _, m := range messages { if m.Number == n { return writeOK(s.writer, fmt.Sprintf("%d %d", m.Number, m.Size)) == nil } } return writeErr(s.writer, "no such message") == nil } var totalSize int64 lines := make([]string, 0, len(messages)) for _, m := range messages { totalSize += m.Size lines = append(lines, fmt.Sprintf("%d %d", m.Number, m.Size)) } return writeMultiline(s.writer, fmt.Sprintf("%d messages (%d octets)", len(messages), totalSize), strings.Join(lines, "\n")) == nil } func (s *Session) handleRetr(ctx context.Context, cmd command) bool { if s.state != Transaction { return writeErr(s.writer, "command not valid in this state") == nil } n, err := s.parseActiveMessageNumber(ctx, cmd) if err != nil { return writeErr(s.writer, err.Error()) == nil } content, err := s.store.Retrieve(ctx, s.username, n) if err != nil { return writeErr(s.writer, "unable to retrieve message") == nil } // Akzeptanzkriterium 2: RETR liefert die VOLLSTÄNDIGE Nachricht. return writeMultiline(s.writer, fmt.Sprintf("%d octets", len(content)), string(content)) == nil } func (s *Session) handleDele(cmd command) bool { if s.state != Transaction { return writeErr(s.writer, "command not valid in this state") == nil } if len(cmd.Args) != 1 { return writeErr(s.writer, "DELE requires a message number") == nil } n, err := strconv.Atoi(cmd.Args[0]) if err != nil { return writeErr(s.writer, "invalid message number") == nil } if s.deleted[n] { return writeErr(s.writer, "message already deleted") == nil } // NUR innerhalb der Sitzung markiert — endgültig gelöscht wird // ausschließlich in handleQuit (Akzeptanzkriterium 2/Pflichtprüfung 3). s.deleted[n] = true return writeOK(s.writer, fmt.Sprintf("message %d deleted", n)) == nil } func (s *Session) handleQuit(ctx context.Context) bool { if s.state != Transaction { // Aus Authorization: keine Update-Phase, keine Löschungen möglich // (es wurde noch nichts markiert). _ = writeOK(s.writer, "goodbye") return false } s.state = Update if len(s.deleted) > 0 { numbers := make([]int, 0, len(s.deleted)) for n := range s.deleted { numbers = append(numbers, n) } if err := s.store.Delete(ctx, s.username, numbers); err != nil { _ = writeErr(s.writer, "unable to update maildrop, changes not committed") return false } } _ = writeOK(s.writer, "goodbye") return false } // activeMessages liefert alle Nachrichten, die in DIESER Sitzung noch // nicht per DELE markiert wurden (RFC 1939: gelöschte Nachrichten sind // für STAT/LIST/RETR ab dem Zeitpunkt der Markierung nicht mehr sichtbar, // auch wenn die Löschung selbst erst bei QUIT endgültig wird). func (s *Session) activeMessages(ctx context.Context) ([]Message, error) { all, err := s.store.List(ctx, s.username) if err != nil { return nil, err } active := make([]Message, 0, len(all)) for _, m := range all { if !s.deleted[m.Number] { active = append(active, m) } } return active, nil } func (s *Session) parseActiveMessageNumber(ctx context.Context, cmd command) (int, error) { if len(cmd.Args) != 1 { return 0, fmt.Errorf("requires a message number") } n, err := strconv.Atoi(cmd.Args[0]) if err != nil { return 0, fmt.Errorf("invalid message number") } if s.deleted[n] { return 0, fmt.Errorf("message deleted") } messages, err := s.activeMessages(ctx) if err != nil { return 0, fmt.Errorf("unable to read maildrop") } for _, m := range messages { if m.Number == n { return n, nil } } return 0, fmt.Errorf("no such message") } // handleStls implementiert STLS (RFC 2595 §4): TLS-Aufwertung der // bestehenden Klartextverbindung, nur vor der Anmeldung erlaubt. func (s *Session) handleStls(ctx context.Context) bool { if s.tlsConfig == nil { return writeErr(s.writer, "TLS not available") == nil } if s.tlsActive { return writeErr(s.writer, "already using TLS") == nil } if s.state != Authorization { return writeErr(s.writer, "command not valid in this state") == nil } if err := writeOK(s.writer, "begin TLS negotiation"); err != nil { return false } tlsConn, err := tlscert.UpgradeServer(ctx, s.conn, s.tlsConfig) if err != nil { // Handshake fehlgeschlagen -> Verbindung beenden, kein Rückfall // auf Klartext. return false } s.conn = tlsConn // Reader/Writer NEU aufsetzen (nicht wiederverwenden): verhindert, // dass vor dem Handshake gepufferte Klartextdaten nach dem Wechsel // noch als Kommandos verarbeitet werden (RFC 2595 §4, // Command-Injection-Schutz). s.reader = bufio.NewReaderSize(tlsConn, maxCommandLineBytes) s.writer = bufio.NewWriter(tlsConn) s.tlsActive = true s.pendingUsername = "" return true }