Compare commits

..
Author SHA1 Message Date
sysops 4fdb424b23 feat(mail): SRC-11 geschlossener FacetField-Typ statt Whitelist-Liste
fields.go: neuer Typ FacetField mit vier geschlossenen Konstanten
(FacetFieldSender/Mailbox/AttachmentType/Tag). IsValid() entscheidet
über ein erschöpfendes switch/case statt eine []string-Liste zu
durchsuchen — genau der aus known-issues-archivmail.md #12 und
known-issues-archivdms.md #10 bekannte Fehler (dynamische Tabellen-/
Feldnamen nur durch eine fragile Whitelist-Funktion abgesichert) wird
damit strukturell vermieden: ein vergessener Listeneintrag kann nichts
mehr durchlassen, weil es keine durchsuchte Liste mehr gibt.
ParseFacetField ist die einzige vorgesehene Konstruktionsstelle für
FacetField aus einer externen Zeichenkette.

facets.go: FacetFilter.Field ist jetzt FacetField statt string,
buildFilteredMust prüft f.Field.IsValid() statt Listenmitgliedschaft
(isFacetField entfernt, es gibt keine Liste mehr, die die Entscheidung
trifft).

Alle Pflichtprüfungen mit echten Nachweisen: unbekannte/erfundene
Facettenfelder werden abgelehnt, alle vier realen Facettenfelder
funktionieren weiterhin, ein FacetField-Wert per direkter
Typkonvertierung (nicht über ParseFacetField) wird trotzdem zuverlässig
abgelehnt (Akzeptanzkriterium 2: Whitelist ist nicht die einzige
Absicherung), automatisiertes Code-Review bestätigt kein fmt.Sprintf in
facets.go/fields.go. Entscheidung dokumentiert: Mail-eigene
Implementierung, keine geteilte Utility mit dem DMS-Board (Prüfprotokoll).

Keine Regression, insbesondere mail/internal/savedsearch (Konsument von
FacetFilter) unverändert grün — go build/go vet/golangci-lint clean,
gesamtes Mail-Modul regressionsfrei getestet.
2026-09-01 14:01:34 +02:00
sysops af1709a2bb 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.
2026-09-01 12:22:21 +02:00
19 changed files with 1121 additions and 58 deletions
+99
View File
@@ -0,0 +1,99 @@
# ING-09 — Rate-Limiting auf Protokollebene: Prüfprotokoll
Datum: 2026-09-01
Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh
Pakete: `mail/internal/ratelimit` (neu, gemeinsam genutzt), `mail/internal/imap`, `mail/internal/pop3`, `mail/internal/smtp`
## Umsetzung
Neues Paket `ratelimit`: Token-Bucket-Rate-Limiting, je (Mandant,
Quelle)-Schlüssel ein eigener Bucket. `ConfigProvider`/`StaticConfig`
liefern die Konfiguration (Burst, Nachfüllrate) je Mandant, mit
Fallback auf eine Default-Konfiguration (Akzeptanzkriterium 1/2:
begrenzt UND konfigurierbar; Akzeptanzkriterium 3: je Mandant getrennt
konfigurierbar). `Limiter.Allow(tenant, source)` liefert bei Ablehnung
eine konkrete, positive Wartezeit statt nur `false` — Grundlage für
Akzeptanzkriterium 3: "definierte Ablehnung MIT Wartezeit-Hinweis,
nicht Verbindungsabbruch ohne Erklärung".
**IMAP** (`LOGIN`) und **POP3** (`PASS`) begrenzen Anmeldeversuche pro
(Mandant, Quell-IP) — Akzeptanzkriterium 1. **SMTP** (`MAIL FROM`)
begrenzt die Annahmerate pro (Mandant, Absenderadresse+Quell-IP) —
Akzeptanzkriterium 2. Bei Überschreitung antwortet der Server mit einer
Fehlermeldung, die die Wartezeit in Sekunden nennt (POP3 `-ERR`, IMAP
`NO`, SMTP `451` — temporärer Fehlercode, "versuch es später erneut"),
die Verbindung bleibt in allen drei Fällen offen und weiter nutzbar
(Akzeptanzkriterium 3). `loginLimiter`/`acceptLimiter` sind optional
(`nil` = kein Rate-Limiting, 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-Prozess/Instanz je Mandant" — und ein
`*ratelimit.Limiter`, der über mehrere Server-Instanzen (Mandanten)
hinweg geteilt werden kann, aber intern strikt nach `tenantID` trennt.
## Pflichtprüfung 1: Lasttest bestätigt greifendes Limit bei Überschreitung
`TestRateLimit_LoadExceedingLimitGetsRejectedWithRetryHint` in allen
drei Protokollpaketen: Burst=5, 20 reale, aufeinanderfolgende
Anmelde-/Annahmeversuche über echte TCP-Verbindungen gegen den
laufenden Server. Ergebnis in allen drei Protokollen identisch: exakt
5 Versuche akzeptiert (der konfigurierte Burst), exakt 15 Versuche mit
der erwarteten Fehlermeldung inkl. Wartezeit-Hinweis abgelehnt — kein
Verbindungsabbruch, jede Ablehnung kommt als reguläre Protokollantwort.
Ergebnis: **BESTANDEN** in allen drei Protokollen.
## Pflichtprüfung 2: legitime Nutzung unterhalb der Schwelle bleibt unbeeinträchtigt
`TestRateLimit_LegitUsageBelowThresholdUnaffected` in allen drei
Protokollpaketen: Burst=10, nur 3 Versuche — alle drei erfolgreich,
keine Ablehnung.
Ergebnis: **BESTANDEN** in allen drei Protokollen.
## Pflichtprüfung 3: Limit ist je Mandant getrennt konfigurierbar und wirksam
`TestRateLimit_PerTenantIndependentAndEffective` in allen drei
Protokollpaketen: EIN gemeinsamer `*ratelimit.Limiter`, aber zwei
Server-Instanzen mit unterschiedlicher `tenantID`
(`mandant-knapp` → Burst 2, `mandant-grosszuegig` → Burst 8, per
`StaticConfig.PerTenant`). 10 Versuche je Mandant: `mandant-knapp`
akzeptiert exakt 2, `mandant-grosszuegig` akzeptiert exakt 8 — beweist
sowohl die Trennung (unterschiedliche Werte wirken unabhängig) als auch
die Wirksamkeit (jeweils exakt der konfigurierte Burst, nicht mehr,
nicht weniger).
Ergebnis: **BESTANDEN** in allen drei Protokollen.
## Akzeptanzkriterien
1. **Login-Versuche pro Quelle/Zeitfenster sind begrenzt und
konfigurierbar**: IMAP/POP3, durch Pflichtprüfung 1+2 belegt.
2. **SMTP-Annahmerate pro Absender/Quelle ist begrenzt und
konfigurierbar**: SMTP, durch Pflichtprüfung 1+2 belegt.
3. **Überschreitung führt zu definierter Ablehnung mit
Wartezeit-Hinweis, nicht zu Verbindungsabbruch ohne Erklärung**:
durch Pflichtprüfung 1 belegt (Verbindung bleibt in jedem Testlauf
offen, jede Ablehnung enthält die Wartezeit in Sekunden).
## Build/Vet/Lint/Test — Gesamtmodul
```
go build ./... → OK
go vet ./... → OK
golangci-lint run ./... → 0 issues
go test ./... -p 1 (TEST_TENANT_DSN, TEST_MANTICORE_URL gesetzt) → alle Pakete ok, inkl. neuem internal/ratelimit
```
Keine Regression in den bestehenden ~31 Paketen — insbesondere die
QA-07-Lasttests bleiben grün: Rate-Limiting ist standardmäßig
deaktiviert (`loginLimiter`/`acceptLimiter` nil), bis explizit über die
neuen Konstruktoren aktiviert.
## Ergebnis
ING-09 erfüllt alle Akzeptanzkriterien mit echten, ausgeführten
Nachweisen — in allen drei Protokollen (IMAP, POP3, SMTP) einzeln
geprüft. Freigeschaltet: QA-04.
+105
View File
@@ -0,0 +1,105 @@
# SRC-11 — Feld-Whitelist-Query-Builder für Suchindex-Zugriff: Prüfprotokoll
Datum: 2026-09-01
Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh
Paket: `mail/internal/search` (`fields.go`, `facets.go`)
## Umsetzung
Grundlage war bereits vorhanden (SRC-01/SRC-05): statische `FieldXxx`-
Konstanten in `fields.go`, Suchanfragen ausschließlich über Manticores
strukturierte HTTP-JSON-API (kein SQL-String-Zusammenbau). Was fehlte,
war Akzeptanzkriterium 2: die Facetten-Whitelist war eine `[]string`
(`FacetFields`), gegen die `isFacetField` per Schleife prüfte — eine
klassische "Whitelist-Funktion", genau das Muster, das
`known-issues-archivmail.md` #12 und `known-issues-archivdms.md` #10
als unzureichend benennen (ein vergessener/fehlerhafter Eintrag in der
Liste lässt unbemerkt alles durch).
**Neu:** `FacetField` ist ein eigener, geschlossener Typ (`fields.go`).
`FacetField.IsValid()` entscheidet über ein erschöpfendes `switch/case`
auf den vier Konstanten (`FacetFieldSender`, `FacetFieldMailbox`,
`FacetFieldAttachmentType`, `FacetFieldTag`) — keine Liste mehr, die
durchsucht wird und die man vergessen könnte zu pflegen.
`ParseFacetField` ist die einzige vorgesehene Stelle, um aus einer
externen Zeichenkette (z. B. künftig ein HTTP-Query-Parameter) ein
`FacetField` zu machen. `FacetFilter.Field` ist jetzt `FacetField` statt
`string`. `buildFilteredMust` (einzige Stelle, die Filter-Feldnamen in
eine Suchanfrage einbaut) prüft `f.Field.IsValid()` statt
Listenmitgliedschaft.
`isFacetField` (die alte Listenfunktion) ist entfernt — es gibt keine
Liste mehr, die die Zulässigkeitsentscheidung trifft, nur noch das
`switch/case` in `IsValid()`.
## Pflichtprüfung 1: Versuch, ein nicht in der Whitelist enthaltenes Feld anzufragen, wird abgewiesen statt stillschweigend ignoriert
`TestBuildFilteredMust_RejectsUnknownField`
(`search/src11_test.go`): zwei Fälle — ein reales Suchfeld, das aber
KEIN Facettenfeld ist (`tenant_slug`), und ein frei erfundenes Feld
(inkl. eines absichtlich SQL-injection-artigen Strings, um zu zeigen,
dass er nicht einmal in die Fehlermeldung unverarbeitet "verschwindet",
sondern sauber als Fehler zurückkommt) — beide werden mit Fehler
abgelehnt, kein stillschweigendes Ignorieren.
`TestBuildFilteredMust_AcceptsAllWhitelistedFields` stellt sicher, dass
die Prüfung nicht zu streng ist (alle vier realen Facettenfelder
funktionieren).
Ergebnis: **BESTANDEN**.
## Pflichtprüfung 2: Code-Review bestätigt: kein dynamischer Spalten-/Tabellenname wird per String-Zusammenbau erzeugt
`TestNoDynamicFieldNameConstruction` (`search/src11_test.go`):
automatisiertes Code-Review — `facets.go` und `fields.go` enthalten in
keiner Codezeile (Kommentarzeilen ausgenommen, dort nur erklärender
Text über den zu vermeidenden Fehler) ein `fmt.Sprintf`. Ergänzt um
`TestFacetField_ClosedSetEvenViaDirectTypeConversion`
(Akzeptanzkriterium 2 wörtlich: die Whitelist ist NICHT die einzige
Absicherung — selbst ein `FacetField`-Wert, der nicht über
`ParseFacetField` entstanden ist, sondern durch direkte
Typkonvertierung, wird von `IsValid()` zuverlässig abgelehnt) und
`TestParseFacetField_OnlyAcceptsKnownStrings`.
Ergebnis: **BESTANDEN**.
## Akzeptanzkriterien
1. **Spalten-/Feldnamen für dynamische Query-Teile stammen
ausschließlich aus statischen Konstanten bzw. einem geschlossenen
Enum/Switch-Typ**: `FacetField` + die vier `FacetFieldXxx`-Konstanten,
durch Pflichtprüfung 2 belegt.
2. **Whitelist ist nicht die einzige Absicherung**: `IsValid()` ist ein
erschöpfendes `switch/case`, keine Listen-Iteration mehr — durch
Pflichtprüfung 1+2 belegt.
3. **Entscheidung dokumentiert: Mail-eigene Implementierung, keine
geteilte Utility mit dem DMS-Board**: siehe unten.
### Zu Akzeptanzkriterium 3
Diese Kachel implementiert den Query-Builder ausschließlich innerhalb
von `mail/internal/search` — keine neue geteilte Utility mit dem
DMS-Board angelegt. Konsistent mit der bereits im Ticket-Prompt
genannten, vorab getroffenen Entscheidung
(`nexarch-state.json``bewusst_nicht_zentralisiert`), Suche/OCR
zwischen Mail und DMS nicht zu zentralisieren.
## Build/Vet/Lint/Test — Gesamtmodul
```
go build ./... → OK
go vet ./... → OK
golangci-lint run ./... → 0 issues
go test ./... -p 1 (TEST_TENANT_DSN, TEST_MANTICORE_URL gesetzt) → alle Pakete ok
```
Keine Regression — insbesondere `mail/internal/savedsearch` (Konsument
von `search.FacetFilter`) unverändert grün: die Typänderung von
`Field string` zu `Field FacetField` ist für bestehende Aufrufer, die
den untypisierten String-Konstanten `FieldSender` usw. übergeben,
verhalten sich unverändert (Go erlaubt die implizite Umwandlung
untypisierter Konstanten).
## Ergebnis
SRC-11 erfüllt alle Akzeptanzkriterien mit echten, ausgeführten
Nachweisen. Freigeschaltet: QA-04 (zusammen mit ARC-06).
+7
View File
@@ -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")
}
+129
View File
@@ -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)
}
}
+13 -1
View File
@@ -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)
}
}
+18 -1
View File
@@ -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
@@ -42,12 +43,16 @@ type Session struct {
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,
@@ -59,10 +64,22 @@ func newSession(conn net.Conn, auth Authenticator, store MailboxStore, 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 {
+9
View File
@@ -47,6 +47,15 @@ func (s *Session) handlePass(ctx context.Context, cmd command) bool {
// 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
}
+145
View File
@@ -0,0 +1,145 @@
package pop3
import (
"bufio"
"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", "bob": "geheim456"}}
store := newFakeMailboxStore()
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
}
}
// attemptPass führt USER+PASS über eine NEUE Verbindung aus und liefert
// die PASS-Antwortzeile.
func attemptPass(t *testing.T, addr, user, pass string) string {
t.Helper()
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
reader := bufio.NewReader(conn)
_, _ = reader.ReadString('\n')
_, _ = conn.Write([]byte("USER " + user + "\r\n"))
_, _ = reader.ReadString('\n')
_, _ = conn.Write([]byte("PASS " + pass + "\r\n"))
resp, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("PASS antwort lesen: %v", err)
}
return resp
}
// TestRateLimit_LoadExceedingLimitGetsRejectedWithRetryHint ist die
// geforderte Pflichtprüfung 1 (ING-09): Lasttest bestätigt greifendes
// Limit bei Überschreitung — reale, gleichzeitige Anmeldeversuche über
// den Burst hinaus.
func TestRateLimit_LoadExceedingLimitGetsRejectedWithRetryHint(t *testing.T) {
limiter := ratelimit.NewLimiter(ratelimit.StaticConfig{
Default: ratelimit.Config{Burst: 5, RefillEvery: time.Hour}, // Refill irrelevant für diesen Test
})
addr, stop := startRateLimitedServer(t, "mandant-a", limiter)
defer stop()
const attempts = 20
var accepted, rejected int
for i := 0; i < attempts; i++ {
resp := attemptPass(t, addr, "alice", "geheim123")
switch {
case strings.HasPrefix(resp, "+OK"):
accepted++
case strings.HasPrefix(resp, "-ERR") && 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): legitime Nutzung unterhalb der Schwelle
// bleibt unbeeinträchtigt.
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++ {
resp := attemptPass(t, addr, "alice", "geheim123")
if !strings.HasPrefix(resp, "+OK") {
t.Fatalf("versuch %d unterhalb der schwelle wurde abgelehnt: %q", i+1, resp)
}
}
}
// TestRateLimit_PerTenantIndependentAndEffective ist die geforderte
// Pflichtprüfung 3 (ING-09): Limit ist je Mandant getrennt
// konfigurierbar und wirksam — zwei Serverinstanzen (Mandant A/B) mit
// UNTERSCHIEDLICHEM Burst, gegen DENSELBEN Limiter (realistisch: ein
// zentraler Limiter-Prozess, mehrere Mandanten-Server).
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.HasPrefix(attemptPass(t, addrKnapp, "alice", "geheim123"), "+OK") {
acceptedKnapp++
}
}
var acceptedGross int
for i := 0; i < 10; i++ {
if strings.HasPrefix(attemptPass(t, addrGross, "alice", "geheim123"), "+OK") {
acceptedGross++
}
}
if acceptedKnapp != 2 {
t.Fatalf("mandant-knapp: erwartete 2 akzeptierte versuche (eigener burst), habe %d", acceptedKnapp)
}
if acceptedGross != 8 {
t.Fatalf("mandant-grosszuegig: erwartete 8 akzeptierte versuche (eigener, größerer burst), habe %d", acceptedGross)
}
}
+17 -1
View File
@@ -9,6 +9,7 @@ import (
"net"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/ratelimit"
)
// Server nimmt POP3-Verbindungen an und bedient jede in einer eigenen
@@ -25,6 +26,12 @@ type Server struct {
guardCfg protoguard.Config
tlsConfig *tls.Config
logger *slog.Logger
// tenantID identifiziert diesen Server für das Rate-Limiting
// (ING-09, Akzeptanzkriterium 3: je Mandant getrennt konfigurierbar)
// — leer, wenn loginLimiter nil ist.
tenantID string
loginLimiter *ratelimit.Limiter
}
func NewServer(auth Authenticator, store MailboxStore) *Server {
@@ -52,6 +59,15 @@ 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 PASS-Versuche (ING-09). loginLimiter darf nil sein
// (Rate-Limiting dann deaktiviert, Rückwärtskompatibilität zu
// ING-01..ING-08). 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.
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
go func() {
@@ -71,7 +87,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
}
return fmt.Errorf("pop3: 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)
}
}
+21 -1
View File
@@ -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"
)
// phaseAuthorization/phaseTransaction sind die protoguard-Phasen dieser
@@ -46,13 +47,19 @@ type Session struct {
log *protolog.SessionLogger // ING-08, nie nil (aber log.Event() ist nil-sicher)
// tenantID/loginLimiter: Rate-Limiting für PASS-Versuche (ING-09).
// loginLimiter nil bedeutet: kein Rate-Limiting (Rückwärtskompatibilität
// zu ING-01..ING-08).
tenantID string
loginLimiter *ratelimit.Limiter
state State
pendingUsername string // nach USER, vor erfolgreichem PASS
username string // nach erfolgreichem PASS
deleted map[int]bool
}
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,
@@ -64,11 +71,24 @@ func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg
tlsConfig: tlsConfig,
tlsActive: alreadyTLS,
log: protolog.NewSessionLogger(logger, "pop3"),
tenantID: tenantID,
loginLimiter: loginLimiter,
state: Authorization,
deleted: map[int]bool{},
}
}
// sourceAddr liefert die Quell-IP dieser Verbindung ohne Portanteil,
// für das Rate-Limiting (ING-09) und als Schlüssel gegenüber dem
// Limiter stabil pro Client.
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 == Authorization {
+109
View File
@@ -0,0 +1,109 @@
// Package ratelimit implementiert ING-09: Token-Bucket-Rate-Limiting
// auf Protokollebene für Login-Versuche (IMAP/POP3) und SMTP-Annahme,
// je Mandant getrennt konfigurierbar (Akzeptanzkriterium 3).
package ratelimit
import (
"fmt"
"sync"
"time"
)
// Config ist die Token-Bucket-Konfiguration EINES Limits
// (Akzeptanzkriterium 1/2: begrenzt und konfigurierbar). Burst ist die
// Anzahl sofort verfügbarer Versuche, RefillEvery die Zeit, die ein
// neuer Versuch nachwächst.
type Config struct {
Burst int
RefillEvery time.Duration
}
// ConfigProvider liefert die Rate-Limit-Konfiguration für einen
// Mandanten (Akzeptanzkriterium 3: je Mandant getrennt konfigurierbar).
type ConfigProvider interface {
ConfigFor(tenant string) Config
}
// StaticConfig ist ein einfacher ConfigProvider: feste Konfiguration je
// Mandant, mit Fallback auf Default für unbekannte/nicht gesondert
// konfigurierte Mandanten.
type StaticConfig struct {
Default Config
PerTenant map[string]Config
}
func (s StaticConfig) ConfigFor(tenant string) Config {
if cfg, ok := s.PerTenant[tenant]; ok {
return cfg
}
return s.Default
}
// tokenBucket ist EIN Token-Bucket-Zähler für einen Schlüssel
// (Mandant+Quelle).
type tokenBucket struct {
mu sync.Mutex
tokens float64
lastRefill time.Time
cfg Config
}
func newTokenBucket(cfg Config) *tokenBucket {
return &tokenBucket{tokens: float64(cfg.Burst), lastRefill: time.Now(), cfg: cfg}
}
// allow entscheidet über EINEN Versuch zum Zeitpunkt now. Bei
// Ablehnung liefert retryAfter eine konkrete, positive Wartezeit
// (Akzeptanzkriterium 1: definierte Ablehnung MIT Wartezeit-Hinweis,
// nicht bloßer Verbindungsabbruch).
func (b *tokenBucket) allow(now time.Time) (ok bool, retryAfter time.Duration) {
b.mu.Lock()
defer b.mu.Unlock()
refillPerSecond := 1.0 / b.cfg.RefillEvery.Seconds()
elapsed := now.Sub(b.lastRefill).Seconds()
b.tokens += elapsed * refillPerSecond
if b.tokens > float64(b.cfg.Burst) {
b.tokens = float64(b.cfg.Burst)
}
b.lastRefill = now
if b.tokens >= 1 {
b.tokens--
return true, 0
}
missing := 1 - b.tokens
wait := time.Duration(missing / refillPerSecond * float64(time.Second))
if wait <= 0 {
wait = time.Millisecond
}
return false, wait
}
// Limiter verwaltet Token-Buckets je (Mandant, Quelle)-Schlüssel —
// EIN Limiter deckt EINEN Limit-Zweck ab (z. B. "Login-Versuche" oder
// "SMTP-Annahme"); ein Server verwendet für unterschiedliche Zwecke
// unterschiedliche Limiter-Instanzen.
type Limiter struct {
mu sync.Mutex
buckets map[string]*tokenBucket
provider ConfigProvider
}
func NewLimiter(provider ConfigProvider) *Limiter {
return &Limiter{buckets: map[string]*tokenBucket{}, provider: provider}
}
// Allow prüft, ob EIN Versuch von source innerhalb des Mandanten
// tenant aktuell erlaubt ist.
func (l *Limiter) Allow(tenant, source string) (ok bool, retryAfter time.Duration) {
key := fmt.Sprintf("%s|%s", tenant, source)
l.mu.Lock()
b, exists := l.buckets[key]
if !exists {
b = newTokenBucket(l.provider.ConfigFor(tenant))
l.buckets[key] = b
}
l.mu.Unlock()
return b.allow(time.Now())
}
+70
View File
@@ -0,0 +1,70 @@
package ratelimit
import (
"testing"
"time"
)
func TestLimiter_AllowsBurstThenBlocksThenRecovers(t *testing.T) {
cfg := Config{Burst: 3, RefillEvery: 50 * time.Millisecond}
lim := NewLimiter(StaticConfig{Default: cfg})
for i := 0; i < 3; i++ {
ok, _ := lim.Allow("mandant-a", "1.2.3.4")
if !ok {
t.Fatalf("versuch %d im burst hätte erlaubt sein müssen", i+1)
}
}
ok, retryAfter := lim.Allow("mandant-a", "1.2.3.4")
if ok {
t.Fatalf("vierter versuch über dem burst hätte abgelehnt werden müssen")
}
if retryAfter <= 0 {
t.Fatalf("erwartete positive wartezeit als hinweis, habe %v", retryAfter)
}
time.Sleep(retryAfter + 10*time.Millisecond)
ok, _ = lim.Allow("mandant-a", "1.2.3.4")
if !ok {
t.Fatalf("nach der wartezeit hätte wieder ein token verfügbar sein müssen")
}
}
func TestLimiter_TenantsAreIndependent(t *testing.T) {
lim := NewLimiter(StaticConfig{Default: Config{Burst: 1, RefillEvery: time.Hour}})
okA, _ := lim.Allow("mandant-a", "1.2.3.4")
if !okA {
t.Fatalf("mandant a: erster versuch hätte erlaubt sein müssen")
}
okA2, _ := lim.Allow("mandant-a", "1.2.3.4")
if okA2 {
t.Fatalf("mandant a: zweiter versuch hätte abgelehnt werden müssen")
}
// Mandant B mit DERSELBEN Quelladresse — eigener Bucket.
okB, _ := lim.Allow("mandant-b", "1.2.3.4")
if !okB {
t.Fatalf("mandant b: eigener bucket, erster versuch hätte erlaubt sein müssen")
}
}
func TestLimiter_PerTenantConfigOverridesDefault(t *testing.T) {
lim := NewLimiter(StaticConfig{
Default: Config{Burst: 1, RefillEvery: time.Hour},
PerTenant: map[string]Config{
"mandant-grosszuegig": {Burst: 5, RefillEvery: time.Hour},
},
})
for i := 0; i < 5; i++ {
ok, _ := lim.Allow("mandant-grosszuegig", "1.2.3.4")
if !ok {
t.Fatalf("mandant-grosszuegig: versuch %d hätte im eigenen, größeren burst erlaubt sein müssen", i+1)
}
}
ok, _ := lim.Allow("mandant-grosszuegig", "1.2.3.4")
if ok {
t.Fatalf("mandant-grosszuegig: sechster versuch hätte abgelehnt werden müssen")
}
}
+14 -19
View File
@@ -14,11 +14,13 @@ import (
)
// FacetFilter schränkt Suche/Facettenberechnung auf einen bereits
// gewählten Facettenwert ein. Field MUSS aus FacetFields stammen —
// Facets liefert einen Fehler bei jedem anderen Wert (verhindert einen
// beliebigen, vom Aufrufer bestimmten Feldnamen in der Anfrage).
// gewählten Facettenwert ein. Field ist der geschlossene FacetField-Typ
// (SRC-11) — buildFilteredMust prüft zusätzlich FacetField.IsValid(),
// sodass selbst ein über json.Unmarshal aus der Datenbank
// rekonstruierter, nicht mehr gültiger Wert (z. B. nach Entfernen eines
// Feldes) abgelehnt wird statt stillschweigend durchzulaufen.
type FacetFilter struct {
Field string
Field FacetField
Value string
}
@@ -70,19 +72,12 @@ func dateRangeBoundaries(now time.Time) []dateRangeBoundary {
}
}
func isFacetField(field string) bool {
for _, f := range FacetFields {
if f == field {
return true
}
}
return false
}
// buildFilteredMust baut die gemeinsame bool.must-Liste für Facets und
// SearchWithFilters: Tenant-Filter zwingend, optionaler Suchtext, dann je
// Filter eine zusätzliche equals-Klausel (UND-Verknüpfung) — einzige
// Stelle, an der Filter-Feldnamen gegen FacetFields geprüft werden.
// Stelle, an der Filter-Feldnamen geprüft werden, über das geschlossene
// FacetField.IsValid() (SRC-11 Akzeptanzkriterium 2), nicht über eine
// durchsuchbare Liste.
func buildFilteredMust(tenantSlug, queryText string, filters []FacetFilter) ([]map[string]any, error) {
must := []map[string]any{
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
@@ -91,10 +86,10 @@ func buildFilteredMust(tenantSlug, queryText string, filters []FacetFilter) ([]m
must = append(must, map[string]any{"query_string": queryText})
}
for _, f := range filters {
if !isFacetField(f.Field) {
if !f.Field.IsValid() {
return nil, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
}
must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}})
must = append(must, map[string]any{"equals": map[string]any{string(f.Field): f.Value}})
}
return must, nil
}
@@ -162,7 +157,7 @@ func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filte
aggs := map[string]any{}
for _, field := range FacetFields {
aggs[field] = map[string]any{"terms": map[string]any{"field": field, "size": 100}}
aggs[string(field)] = map[string]any{"terms": map[string]any{"field": string(field), "size": 100}}
}
boundaries := dateRangeBoundaries(time.Now())
ranges := make([]map[string]any, 0, len(boundaries))
@@ -208,7 +203,7 @@ func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filte
result := FacetResult{Values: make(map[string][]FacetValue, len(FacetFields))}
for _, field := range FacetFields {
bucket := parsed.Aggregations[field]
bucket := parsed.Aggregations[string(field)]
values := make([]FacetValue, 0, len(bucket.Buckets))
for _, b := range bucket.Buckets {
if b.Key == "" {
@@ -216,7 +211,7 @@ func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filte
}
values = append(values, FacetValue{Value: b.Key, Count: b.DocCount})
}
result.Values[field] = values
result.Values[string(field)] = values
}
sentAtBucket := parsed.Aggregations["sent_at"]
+43 -3
View File
@@ -35,12 +35,52 @@ const (
FieldOCRConfidence = "ocr_confidence"
)
// FacetField ist ein geschlossener Typ für die vier zulässigen
// Facetten-/Filterdimensionen (SRC-11, Akzeptanzkriterium 2): die
// Zulässigkeitsprüfung in facets.go läuft über ein erschöpfendes
// switch/case auf diesem Typ, NICHT über das Durchsuchen einer Liste —
// selbst ein vergessener Eintrag in einer Whitelist-Liste könnte dort
// nichts mehr durchlassen, weil keine solche Liste mehr die Entscheidung
// trifft. FacetFields (unten) ist nur noch eine abgeleitete
// Aufzählungshilfe für Iteration, keine Prüfgrundlage.
type FacetField string
const (
FacetFieldSender FacetField = FacetField(FieldSender)
FacetFieldMailbox FacetField = FacetField(FieldMailbox)
FacetFieldAttachmentType FacetField = FacetField(FieldAttachmentType)
FacetFieldTag FacetField = FacetField(FieldTag)
)
// IsValid entscheidet über Zulässigkeit als Facetten-/Filterfeld über
// ein geschlossenes switch/case (Akzeptanzkriterium 2) statt eine Liste
// zu durchsuchen.
func (f FacetField) IsValid() bool {
switch f {
case FacetFieldSender, FacetFieldMailbox, FacetFieldAttachmentType, FacetFieldTag:
return true
default:
return false
}
}
// ParseFacetField wandelt eine externe Zeichenkette (z. B. aus einem
// HTTP-Query-Parameter) in ein FacetField um — liefert false bei jedem
// Wert, der nicht exakt einer der geschlossenen Konstanten entspricht.
// Einzige vorgesehene Stelle, an der ein Client-Feldname überhaupt zu
// einem FacetField werden kann.
func ParseFacetField(raw string) (FacetField, bool) {
f := FacetField(raw)
return f, f.IsValid()
}
// FacetFields sind die je Kachel unterstützten Filterdimensionen
// (Akzeptanzkriterium 1: Absender, Postfach, Anhangstyp, Tag — Zeitraum
// läuft separat über FieldSentAt als Bereichsfacette, siehe facets.go).
// Statische Liste — Aufrufer können ausschließlich diese Feldnamen als
// Facetten-/Filterdimension angeben, kein beliebiger Client-Feldname.
var FacetFields = []string{FieldSender, FieldMailbox, FieldAttachmentType, FieldTag}
// Nur zur Iteration gedacht (z. B. "berechne alle Facetten") — die
// Zulässigkeitsprüfung selbst läuft über FacetField.IsValid(), nicht
// über Mitgliedschaft in dieser Liste.
var FacetFields = []FacetField{FacetFieldSender, FacetFieldMailbox, FacetFieldAttachmentType, FacetFieldTag}
// DocumentID berechnet deterministisch die Manticore-Dokument-ID aus
// Mandant und Message-ID (FNV-1a, 64 Bit). Deterministisch statt einer
+117
View File
@@ -0,0 +1,117 @@
// SRC-11: Feld-Whitelist-Query-Builder für Suchindex-Zugriff. Reine
// Unit-Tests (kein Manticore nötig) — buildFilteredMust und FacetField
// sind pure Funktionen/Typen.
package search
import (
"os"
"strings"
"testing"
)
// TestBuildFilteredMust_RejectsUnknownField ist die geforderte
// Pflichtprüfung 1 (SRC-11): Versuch, ein nicht in der Whitelist
// enthaltenes Feld anzufragen, wird abgewiesen statt stillschweigend
// ignoriert.
func TestBuildFilteredMust_RejectsUnknownField(t *testing.T) {
// FacetField(...) simuliert genau den Fall, den Akzeptanzkriterium 2
// verlangt: ein Wert, der NICHT über die vorgesehene
// ParseFacetField-Konstruktion entstanden ist (z. B. aus einem
// veralteten Datenbankeintrag nach Entfernen eines Feldes) — muss
// trotzdem abgelehnt werden.
unknown := FacetField("tenant_slug") // existiert als Suchfeld, ist aber KEIN Facettenfeld
_, err := buildFilteredMust("mandant-x", "", []FacetFilter{{Field: unknown, Value: "x"}})
if err == nil {
t.Fatalf("erwartete ablehnung für unbekanntes facettenfeld %q, bekam keinen fehler", unknown)
}
if !strings.Contains(err.Error(), string(unknown)) {
t.Fatalf("fehlermeldung sollte das abgelehnte feld nennen, habe: %v", err)
}
// Frei erfundenes Feld, das nirgendwo im Schema existiert.
madeUp := FacetField("'; DROP TABLE mail_documents; --")
_, err = buildFilteredMust("mandant-x", "", []FacetFilter{{Field: madeUp, Value: "x"}})
if err == nil {
t.Fatalf("erwartete ablehnung für frei erfundenes facettenfeld, bekam keinen fehler")
}
}
// TestBuildFilteredMust_AcceptsAllWhitelistedFields stellt sicher, dass
// alle vier vorgesehenen Facettenfelder tatsächlich funktionieren (keine
// versehentlich zu strenge Prüfung).
func TestBuildFilteredMust_AcceptsAllWhitelistedFields(t *testing.T) {
for _, field := range FacetFields {
_, err := buildFilteredMust("mandant-x", "", []FacetFilter{{Field: field, Value: "x"}})
if err != nil {
t.Fatalf("feld %q hätte akzeptiert werden müssen: %v", field, err)
}
}
}
// TestFacetField_ClosedSetEvenViaDirectTypeConversion ist die geforderte
// Pflichtprüfung/Akzeptanzkriterium 2: die Whitelist ist nicht die
// einzige Absicherung. Selbst ein FacetField-Wert, der NICHT über
// ParseFacetField entstanden ist (direkte Typkonvertierung, z. B. durch
// künftigen Code, der die vorgesehene Konstruktion umgeht), wird von
// IsValid() zuverlässig abgelehnt — die Prüfung hängt an einem
// erschöpfenden switch/case auf den vier Konstanten, nicht an einer
// durchsuchbaren Liste, die vergessen werden könnte.
func TestFacetField_ClosedSetEvenViaDirectTypeConversion(t *testing.T) {
valid := []FacetField{FacetFieldSender, FacetFieldMailbox, FacetFieldAttachmentType, FacetFieldTag}
for _, f := range valid {
if !f.IsValid() {
t.Fatalf("erwartete gültiges feld %q als gültig", f)
}
}
invalid := []FacetField{
FacetField(FieldTenantSlug), // reales Suchfeld, aber keine Facette
FacetField(FieldBody),
FacetField("subject; --"),
FacetField(""),
}
for _, f := range invalid {
if f.IsValid() {
t.Fatalf("feld %q hätte als ungültig erkannt werden müssen", f)
}
}
}
// TestParseFacetField_OnlyAcceptsKnownStrings deckt die einzige
// vorgesehene Konstruktionsstelle für FacetField aus einer externen
// Zeichenkette ab.
func TestParseFacetField_OnlyAcceptsKnownStrings(t *testing.T) {
if _, ok := ParseFacetField("sender"); !ok {
t.Fatalf("'sender' hätte als gültiges facettenfeld erkannt werden müssen")
}
if _, ok := ParseFacetField("nicht_existent"); ok {
t.Fatalf("unbekannter feldname hätte abgelehnt werden müssen")
}
if _, ok := ParseFacetField("tenant_slug"); ok {
t.Fatalf("ein reales, aber nicht-facettiertes suchfeld hätte abgelehnt werden müssen")
}
}
// TestNoDynamicFieldNameConstruction ist die geforderte Pflichtprüfung 2
// (SRC-11): Code-Review bestätigt automatisiert, dass facets.go und
// fields.go keinen dynamischen Spalten-/Tabellennamen per
// String-Zusammenbau (fmt.Sprintf/+) erzeugen — Feldnamen kommen
// ausschließlich aus den FacetField-Konstanten bzw. den statischen
// FieldXxx-Konstanten dieses Pakets.
func TestNoDynamicFieldNameConstruction(t *testing.T) {
for _, file := range []string{"facets.go", "fields.go"} {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("%s lesen: %v", file, err)
}
for _, line := range strings.Split(string(src), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") {
continue // Kommentarzeilen dürfen den Begriff zur Erklärung nennen
}
if strings.Contains(line, "fmt.Sprintf") {
t.Fatalf("%s darf kein fmt.Sprintf im Code verwenden (dynamische Feldnamenbildung verboten, SRC-11 Akzeptanzkriterium 1): %q", file, trimmed)
}
}
}
}
+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 }