Neues Paket mail/internal/ratelimit: Token-Bucket je (Mandant, Quelle)-Schlüssel. ConfigProvider/StaticConfig liefern Burst/Nachfüllrate je Mandant mit Fallback auf eine Default-Konfiguration (Akzeptanzkriterium 2/3: begrenzt, konfigurierbar, je Mandant getrennt). Limiter.Allow liefert bei Ablehnung eine konkrete positive Wartezeit statt nur false — Grundlage für die geforderte definierte Ablehnung MIT Wartezeit-Hinweis statt Verbindungsabbruch (Akzeptanzkriterium 3). IMAP LOGIN und POP3 PASS begrenzen Anmeldeversuche pro (Mandant, Quell-IP). SMTP MAIL FROM begrenzt die Annahmerate pro (Mandant, Absenderadresse+Quell-IP). Bei Überschreitung: POP3 -ERR, IMAP NO, SMTP 451 (temporärer Fehlercode) mit Wartezeit in Sekunden, Verbindung bleibt in allen drei Fällen offen und weiter nutzbar. loginLimiter/ acceptLimiter sind optional (nil = deaktiviert, Rückwärtskompatibilität zu ING-01..ING-08) — neue Konstruktoren NewServerWithGuardTLSLoggerAndRateLimit (IMAP/POP3) und NewServerWithMaxMessageBytesTLSLoggerAndRateLimit (SMTP). Jeder Server bekommt eine tenantID, konsistent mit dem in ING-10 etablierten Muster "ein Server je Mandant". Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt, jeweils in IMAP, POP3 und SMTP einzeln: 20 reale Versuche bei Burst=5 -> exakt 5 akzeptiert, 15 mit Wartezeit-Hinweis abgelehnt, keine Verbindung getrennt; legitime Nutzung unterhalb der Schwelle unbeeinträchtigt; zwei Mandanten mit unterschiedlichem Burst über denselben Limiter unabhängig und exakt wirksam (2 vs. 8 akzeptierte Versuche). go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~31 Pakete) regressionsfrei getestet — insbesondere QA-07-Lasttests bleiben grün, da Rate-Limiting standardmäßig deaktiviert ist.
263 lines
8.2 KiB
Go
263 lines
8.2 KiB
Go
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
|
|
}
|