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:
@@ -37,6 +37,13 @@ func (s *Session) handleLogin(ctx context.Context, cmd command) bool {
|
||||
// akzeptiert, sobald der Server TLS überhaupt anbietet.
|
||||
return s.writeErr(cmd.Tag, "NO", "LOGIN disabled without TLS, use STARTTLS")
|
||||
}
|
||||
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.
|
||||
return s.writeErr(cmd.Tag, "NO", fmt.Sprintf("rate limit exceeded, retry in %.1fs", retryAfter.Seconds()))
|
||||
}
|
||||
}
|
||||
if s.auth == nil {
|
||||
return s.writeErr(cmd.Tag, "NO", "LOGIN not available")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/ratelimit"
|
||||
)
|
||||
|
||||
func startRateLimitedServer(t *testing.T, tenant string, limiter *ratelimit.Limiter) (addr string, stop func()) {
|
||||
t.Helper()
|
||||
auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}}
|
||||
store := fakeMailboxStore{mailboxes: map[string][]Message{
|
||||
"INBOX": {{SequenceNumber: 1, UID: 1, Flags: []string{}}},
|
||||
}}
|
||||
srv := NewServerWithGuardTLSLoggerAndRateLimit(auth, store, protoguard.DefaultConfig(), 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
|
||||
}
|
||||
}
|
||||
|
||||
// attemptLogin führt LOGIN über eine NEUE Verbindung aus und liefert
|
||||
// die Abschlusszeile.
|
||||
func attemptLogin(t *testing.T, addr string) string {
|
||||
t.Helper()
|
||||
c := dial(t, addr)
|
||||
defer c.close()
|
||||
_, lines := c.sendTagged(t, "LOGIN alice geheim123")
|
||||
return lines[len(lines)-1]
|
||||
}
|
||||
|
||||
// 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},
|
||||
})
|
||||
addr, stop := startRateLimitedServer(t, "mandant-a", limiter)
|
||||
defer stop()
|
||||
|
||||
const attempts = 20
|
||||
var accepted, rejected int
|
||||
for i := 0; i < attempts; i++ {
|
||||
last := attemptLogin(t, addr)
|
||||
switch {
|
||||
case strings.Contains(last, "OK"):
|
||||
accepted++
|
||||
case strings.Contains(last, "NO") && strings.Contains(last, "rate limit"):
|
||||
rejected++
|
||||
default:
|
||||
t.Fatalf("unerwartete abschlussantwort: %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
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},
|
||||
})
|
||||
addr, stop := startRateLimitedServer(t, "mandant-a", limiter)
|
||||
defer stop()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
last := attemptLogin(t, addr)
|
||||
if !strings.Contains(last, "OK") {
|
||||
t.Fatalf("versuch %d unterhalb der schwelle wurde abgelehnt: %q", i+1, last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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},
|
||||
},
|
||||
})
|
||||
addrKnapp, stopKnapp := startRateLimitedServer(t, "mandant-knapp", limiter)
|
||||
defer stopKnapp()
|
||||
addrGross, stopGross := startRateLimitedServer(t, "mandant-grosszuegig", limiter)
|
||||
defer stopGross()
|
||||
|
||||
var acceptedKnapp int
|
||||
for i := 0; i < 10; i++ {
|
||||
if strings.Contains(attemptLogin(t, addrKnapp), "OK") {
|
||||
acceptedKnapp++
|
||||
}
|
||||
}
|
||||
var acceptedGross int
|
||||
for i := 0; i < 10; i++ {
|
||||
if strings.Contains(attemptLogin(t, addrGross), "OK") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/ratelimit"
|
||||
)
|
||||
|
||||
// Server nimmt IMAP-Verbindungen an und bedient jede in einer eigenen
|
||||
@@ -23,6 +24,9 @@ type Server struct {
|
||||
guardCfg protoguard.Config
|
||||
tlsConfig *tls.Config
|
||||
logger *slog.Logger
|
||||
|
||||
tenantID string
|
||||
loginLimiter *ratelimit.Limiter
|
||||
}
|
||||
|
||||
func NewServer(auth Authenticator, store MailboxStore) *Server {
|
||||
@@ -50,6 +54,14 @@ func NewServerWithGuardTLSAndLogger(auth Authenticator, store MailboxStore, guar
|
||||
return &Server{auth: auth, store: store, guardCfg: guardCfg, tlsConfig: tlsConfig, logger: logger}
|
||||
}
|
||||
|
||||
// NewServerWithGuardTLSLoggerAndRateLimit erlaubt zusätzlich
|
||||
// Rate-Limiting für LOGIN-Versuche (ING-09). loginLimiter darf nil sein
|
||||
// (Rate-Limiting dann deaktiviert). tenantID identifiziert diesen
|
||||
// Server gegenüber dem Limiter (Akzeptanzkriterium 3).
|
||||
func NewServerWithGuardTLSLoggerAndRateLimit(auth Authenticator, store MailboxStore, guardCfg protoguard.Config, tlsConfig *tls.Config, logger *slog.Logger, tenantID string, loginLimiter *ratelimit.Limiter) *Server {
|
||||
return &Server{auth: auth, store: store, guardCfg: guardCfg, tlsConfig: tlsConfig, logger: logger, tenantID: tenantID, loginLimiter: loginLimiter}
|
||||
}
|
||||
|
||||
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird oder
|
||||
// Accept endgültig fehlschlägt. Blockiert den Aufrufer.
|
||||
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
@@ -70,7 +82,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
}
|
||||
return fmt.Errorf("imap: verbindung annehmen: %w", err)
|
||||
}
|
||||
session := newSession(conn, srv.auth, srv.store, srv.guardCfg, srv.tlsConfig, srv.logger)
|
||||
session := newSession(conn, srv.auth, srv.store, srv.guardCfg, srv.tlsConfig, srv.logger, srv.tenantID, srv.loginLimiter)
|
||||
go session.Serve(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protolog"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/ratelimit"
|
||||
)
|
||||
|
||||
// phaseNotAuthenticated/phaseSelected sind die protoguard-Phasen dieser
|
||||
@@ -33,36 +34,52 @@ const maxCommandLineBytes = 8192
|
||||
// Session ist eine einzelne IMAP-Verbindung mit eigener
|
||||
// Zustandsmaschine (Akzeptanzkriterium 1).
|
||||
type Session struct {
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
writer *bufio.Writer
|
||||
auth Authenticator
|
||||
store MailboxStore
|
||||
guard *protoguard.Guard
|
||||
tlsConfig *tls.Config // nil = kein TLS/STARTTLS angeboten (ING-06)
|
||||
tlsActive bool
|
||||
log *protolog.SessionLogger // ING-08, nie nil (log.Event() ist nil-sicher)
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
writer *bufio.Writer
|
||||
auth Authenticator
|
||||
store MailboxStore
|
||||
guard *protoguard.Guard
|
||||
tlsConfig *tls.Config // nil = kein TLS/STARTTLS angeboten (ING-06)
|
||||
tlsActive bool
|
||||
log *protolog.SessionLogger // ING-08, nie nil (log.Event() ist nil-sicher)
|
||||
|
||||
tenantID string
|
||||
loginLimiter *ratelimit.Limiter // ING-09, nil = kein Rate-Limiting
|
||||
|
||||
state State
|
||||
mailbox string // gewähltes Postfach im Zustand Selected
|
||||
mailboxSize uint32 // Nachrichtenzahl aus dem letzten erfolgreichen SELECT
|
||||
}
|
||||
|
||||
func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg protoguard.Config, tlsConfig *tls.Config, logger *slog.Logger) *Session {
|
||||
func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg protoguard.Config, tlsConfig *tls.Config, logger *slog.Logger, tenantID string, loginLimiter *ratelimit.Limiter) *Session {
|
||||
_, alreadyTLS := conn.(*tls.Conn)
|
||||
return &Session{
|
||||
conn: conn,
|
||||
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
||||
writer: bufio.NewWriter(conn),
|
||||
auth: auth,
|
||||
store: store,
|
||||
guard: protoguard.New(guardCfg),
|
||||
tlsConfig: tlsConfig,
|
||||
tlsActive: alreadyTLS,
|
||||
log: protolog.NewSessionLogger(logger, "imap"),
|
||||
state: NotAuthenticated,
|
||||
conn: conn,
|
||||
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
||||
writer: bufio.NewWriter(conn),
|
||||
auth: auth,
|
||||
store: store,
|
||||
guard: protoguard.New(guardCfg),
|
||||
tlsConfig: tlsConfig,
|
||||
tlsActive: alreadyTLS,
|
||||
log: protolog.NewSessionLogger(logger, "imap"),
|
||||
tenantID: tenantID,
|
||||
loginLimiter: loginLimiter,
|
||||
state: NotAuthenticated,
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// currentPhase liefert die protoguard-Phase des aktuellen Sitzungszustands.
|
||||
func (s *Session) currentPhase() protoguard.Phase {
|
||||
if s.state == NotAuthenticated {
|
||||
|
||||
Reference in New Issue
Block a user