feat(mail): ING-09 Rate-Limiting auf Protokollebene für IMAP/POP3/SMTP

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.
This commit is contained in:
sysops
2026-09-01 12:22:21 +02:00
parent 060b73566b
commit af1709a2bb
15 changed files with 842 additions and 36 deletions
+9
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
"fmt"
"strings"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert"
@@ -77,6 +78,14 @@ func (s *Session) handleMailFrom(arg string) bool {
// SMTP-Fehlermeldung statt Absturz oder Verbindungsabbruch.
return s.reply(553, "invalid sender address") == nil
}
if s.acceptLimiter != nil {
if ok, retryAfter := s.acceptLimiter.Allow(s.tenantID, addr+"|"+s.sourceAddr()); !ok {
// Akzeptanzkriterium 1/3 (ING-09): definierte, temporäre
// Ablehnung (4xx = "try again later") MIT Wartezeit-Hinweis
// statt Verbindungsabbruch ohne Erklärung.
return s.reply(451, fmt.Sprintf("rate limit exceeded for sender, retry in %.1fs", retryAfter.Seconds())) == nil
}
}
s.from = addr
s.to = nil
s.state = MailFromSet
+134
View File
@@ -0,0 +1,134 @@
package smtp
import (
"context"
"net"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/ratelimit"
)
func startRateLimitedServer(t *testing.T, sink MessageSink, tenant string, limiter *ratelimit.Limiter) (addr string, stop func()) {
t.Helper()
srv := NewServerWithMaxMessageBytesTLSLoggerAndRateLimit(sink, defaultMaxMessageBytes, nil, nil, tenant, limiter)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listener: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
_ = srv.Serve(ctx, listener)
close(done)
}()
return listener.Addr().String(), func() {
cancel()
<-done
}
}
// attemptMailFrom führt EHLO+MAIL FROM über eine NEUE Verbindung aus
// und liefert die MAIL FROM-Antwortzeile.
func attemptMailFrom(t *testing.T, addr, from string) string {
t.Helper()
c := dial(t, addr)
defer c.close()
c.send(t, "EHLO client.example.com")
for {
line := c.readLine(t)
if strings.HasPrefix(line, "250 ") {
break
}
}
return c.send(t, "MAIL FROM:<"+from+">")
}
// TestRateLimit_LoadExceedingLimitGetsRejectedWithRetryHint ist die
// geforderte Pflichtprüfung 1 (ING-09).
func TestRateLimit_LoadExceedingLimitGetsRejectedWithRetryHint(t *testing.T) {
limiter := ratelimit.NewLimiter(ratelimit.StaticConfig{
Default: ratelimit.Config{Burst: 5, RefillEvery: time.Hour},
})
sink := &fakeSink{}
addr, stop := startRateLimitedServer(t, sink, "mandant-a", limiter)
defer stop()
const attempts = 20
var accepted, rejected int
for i := 0; i < attempts; i++ {
resp := attemptMailFrom(t, addr, "immer-gleicher-absender@example.com")
switch {
case code(resp) == "250":
accepted++
case code(resp) == "451" && strings.Contains(resp, "rate limit"):
rejected++
default:
t.Fatalf("unerwartete antwort: %q", resp)
}
}
if accepted != 5 {
t.Fatalf("erwartete genau 5 akzeptierte versuche (burst), habe %d", accepted)
}
if rejected != attempts-5 {
t.Fatalf("erwartete %d abgelehnte versuche, habe %d", attempts-5, rejected)
}
}
// TestRateLimit_LegitUsageBelowThresholdUnaffected ist die geforderte
// Pflichtprüfung 2 (ING-09).
func TestRateLimit_LegitUsageBelowThresholdUnaffected(t *testing.T) {
limiter := ratelimit.NewLimiter(ratelimit.StaticConfig{
Default: ratelimit.Config{Burst: 10, RefillEvery: time.Second},
})
sink := &fakeSink{}
addr, stop := startRateLimitedServer(t, sink, "mandant-a", limiter)
defer stop()
for i := 0; i < 3; i++ {
resp := attemptMailFrom(t, addr, "legitim@example.com")
if code(resp) != "250" {
t.Fatalf("versuch %d unterhalb der schwelle wurde abgelehnt: %q", i+1, resp)
}
}
}
// TestRateLimit_PerTenantIndependentAndEffective ist die geforderte
// Pflichtprüfung 3 (ING-09).
func TestRateLimit_PerTenantIndependentAndEffective(t *testing.T) {
limiter := ratelimit.NewLimiter(ratelimit.StaticConfig{
Default: ratelimit.Config{Burst: 2, RefillEvery: time.Hour},
PerTenant: map[string]ratelimit.Config{
"mandant-grosszuegig": {Burst: 8, RefillEvery: time.Hour},
},
})
sinkKnapp := &fakeSink{}
addrKnapp, stopKnapp := startRateLimitedServer(t, sinkKnapp, "mandant-knapp", limiter)
defer stopKnapp()
sinkGross := &fakeSink{}
addrGross, stopGross := startRateLimitedServer(t, sinkGross, "mandant-grosszuegig", limiter)
defer stopGross()
var acceptedKnapp int
for i := 0; i < 10; i++ {
if code(attemptMailFrom(t, addrKnapp, "absender@example.com")) == "250" {
acceptedKnapp++
}
}
var acceptedGross int
for i := 0; i < 10; i++ {
if code(attemptMailFrom(t, addrGross, "absender@example.com")) == "250" {
acceptedGross++
}
}
if acceptedKnapp != 2 {
t.Fatalf("mandant-knapp: erwartete 2 akzeptierte versuche, habe %d", acceptedKnapp)
}
if acceptedGross != 8 {
t.Fatalf("mandant-grosszuegig: erwartete 8 akzeptierte versuche, habe %d", acceptedGross)
}
}
+15 -1
View File
@@ -7,6 +7,8 @@ import (
"fmt"
"log/slog"
"net"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/ratelimit"
)
// defaultMaxMessageBytes ist die Standard-Höchstgröße einer
@@ -23,6 +25,9 @@ type Server struct {
maxMessageBytes int64
tlsConfig *tls.Config
logger *slog.Logger
tenantID string
acceptLimiter *ratelimit.Limiter
}
func NewServer(sink MessageSink) *Server {
@@ -49,6 +54,15 @@ func NewServerWithMaxMessageBytesTLSAndLogger(sink MessageSink, maxMessageBytes
return &Server{sink: sink, maxMessageBytes: maxMessageBytes, tlsConfig: tlsConfig, logger: logger}
}
// NewServerWithMaxMessageBytesTLSLoggerAndRateLimit erlaubt zusätzlich
// Rate-Limiting der SMTP-Annahmerate pro Absender (ING-09, MAIL FROM).
// acceptLimiter darf nil sein (Rate-Limiting dann deaktiviert).
// tenantID identifiziert diesen Server gegenüber dem Limiter
// (Akzeptanzkriterium 3).
func NewServerWithMaxMessageBytesTLSLoggerAndRateLimit(sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config, logger *slog.Logger, tenantID string, acceptLimiter *ratelimit.Limiter) *Server {
return &Server{sink: sink, maxMessageBytes: maxMessageBytes, tlsConfig: tlsConfig, logger: logger, tenantID: tenantID, acceptLimiter: acceptLimiter}
}
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird.
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
go func() {
@@ -68,7 +82,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
}
return fmt.Errorf("smtp: verbindung annehmen: %w", err)
}
session := newSession(conn, srv.sink, srv.maxMessageBytes, srv.tlsConfig, srv.logger)
session := newSession(conn, srv.sink, srv.maxMessageBytes, srv.tlsConfig, srv.logger, srv.tenantID, srv.acceptLimiter)
go session.Serve(ctx)
}
}
+17 -1
View File
@@ -11,6 +11,7 @@ import (
"strings"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protolog"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/ratelimit"
)
// maxCommandLineBytes begrenzt eine einzelne Kommando-/DATA-Zeile
@@ -34,12 +35,15 @@ type Session struct {
log *protolog.SessionLogger // ING-08, nie nil (log.Event() ist nil-sicher)
tenantID string
acceptLimiter *ratelimit.Limiter // ING-09, nil = kein Rate-Limiting
state State
from string
to []string
}
func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config, logger *slog.Logger) *Session {
func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config, logger *slog.Logger, tenantID string, acceptLimiter *ratelimit.Limiter) *Session {
_, alreadyTLS := conn.(*tls.Conn)
return &Session{
conn: conn,
@@ -50,10 +54,22 @@ func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64, tlsConfi
tlsConfig: tlsConfig,
tlsActive: alreadyTLS,
log: protolog.NewSessionLogger(logger, "smtp"),
tenantID: tenantID,
acceptLimiter: acceptLimiter,
state: Greeting,
}
}
// sourceAddr liefert die Quell-IP dieser Verbindung ohne Portanteil
// (ING-09).
func (s *Session) sourceAddr() string {
host, _, err := net.SplitHostPort(s.conn.RemoteAddr().String())
if err != nil {
return s.conn.RemoteAddr().String()
}
return host
}
// State liefert den aktuellen Sitzungszustand (für Tests).
func (s *Session) State() State { return s.state }