feat(mail): QA-04 Sicherheits-/Berechtigungsprüfung — fehlenden SMTP-Idle-Timeout gefunden und behoben
Gezielter Testangriff auf den SMTP-Pfad deckte einen realen Härtungsfehler auf: ING-07 (Idle-Timeout via protoguard) wurde versehentlich nur in mail/internal/imap und mail/internal/pop3 verdrahtet, SMTP bekam nie einen Timeout. Eine Gegenstelle, die eine Kommandozeile ohne abschließendes CRLF öffnet und nie beendet, konnte die Session unbegrenzt blockieren — real reproduziert und danach behoben. session.go/server.go (smtp): guard *protoguard.Guard neu, Timeout wird in readLine() selbst gesetzt (ein Ort für Haupt-Serve-Schleife, handleData, drainUntilDot). Neuer Konstruktor NewServerWithMaxMessageBytesTLSLoggerRateLimitAndGuardConfig für abweichende Timeout-Werte. Bestehende Konstruktoren bekommen automatisch protoguard.DefaultConfig() (5 Minuten) statt wie zuvor gar keinen Timeout — reine Härtung, keine Verhaltensänderung für funktionierende Clients, QA-07-Lasttest bleibt unverändert grün. Neue Tests: qa04_security_test.go (Header-Injection-Angriffe auf Envelope-Adressen, Ressourcenerschöpfung durch nie abgeschlossene Zeile — deckte den Fehler auf und bestätigt die Korrektur). mailboxconfig/tenant_scoping_test.go: Stichprobe eines dritten Speicherpfads (verschlüsselte IMAP-Zugangsdaten) — Zugriff mit echter, bekannter fremder ID wird über alle vier Operationen zuverlässig abgelehnt. Rate-Limiting-Teil von Akzeptanzkriterium 3 real bestätigt (ING-09, erneut mitgeprüft). API-Token-Teil bleibt offen: das Mail-Board besitzt keine eigene Token-Authentifizierung, bewusst an Core-Board IAM delegiert (QA-04s eigene Ausgangslage) — im Prüfprotokoll dokumentiert. go build/go vet/golangci-lint clean, gesamtes Mail-Modul regressionsfrei getestet.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package mailboxconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTenantScoping_ForeignKnownIDNeverAccessible ist Teil der
|
||||
// geforderten Pflichtprüfung "Stichprobenprüfung mehrerer Speicherpfade
|
||||
// auf Mandantentrennung" (QA-04): dieser Speicherpfad ist besonders
|
||||
// sensibel (verschlüsselte Postfach-Zugangsdaten). Anders als die
|
||||
// Tenant-Scoping-Tests aus ING-10 (unterschiedliche Server-/Store-
|
||||
// Instanzen je Mandant) prüft dieser Test den REALISTISCHSTEN
|
||||
// Angriffsfall für eine geteilte Datenbank mit tenant_slug-Spalte:
|
||||
// Mandant B kennt/errät eine gültige, echte ID aus Mandant As Zeile
|
||||
// (z. B. durch fortlaufende IDs) und versucht direkt darauf
|
||||
// zuzugreifen — muss an der tenant_slug-Prüfung in der WHERE-Klausel
|
||||
// scheitern, nicht an einem geratenen falschen Wert.
|
||||
func TestTenantScoping_ForeignKnownIDNeverAccessible(t *testing.T) {
|
||||
store := setupStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tenantA := "mandant-qa04-a"
|
||||
tenantB := "mandant-qa04-b"
|
||||
|
||||
idA := createTestMailbox(t, store, tenantA, "Postfach A")
|
||||
|
||||
// Mandant B versucht, MIT DER ECHTEN, BEKANNTEN ID von Mandant A
|
||||
// zuzugreifen — List gibt es aus Mandant Bs Sicht nicht.
|
||||
listB, err := store.List(ctx, tenantB)
|
||||
if err != nil {
|
||||
t.Fatalf("List mandant b: %v", err)
|
||||
}
|
||||
for _, mb := range listB {
|
||||
if mb.ID == idA {
|
||||
t.Fatalf("mandant b sieht mandant As postfach in seiner eigenen liste")
|
||||
}
|
||||
}
|
||||
|
||||
// Direkter Zugriff mit fremder, aber echter ID: GetDecryptedPassword.
|
||||
_, err = store.GetDecryptedPassword(ctx, tenantB, idA)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("GetDecryptedPassword mit fremder mandant-b-anfrage auf mandant-a-id hätte ErrNotFound liefern müssen, habe: %v", err)
|
||||
}
|
||||
|
||||
// Direkter Zugriff mit fremder, aber echter ID: Update.
|
||||
err = store.Update(ctx, tenantB, idA, UpdateInput{
|
||||
IMAPHost: "boese.example.com",
|
||||
IMAPPort: 993,
|
||||
FolderSelection: []string{"INBOX"},
|
||||
IntervalSeconds: 60,
|
||||
})
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Update mit fremder mandant-b-anfrage auf mandant-a-id hätte ErrNotFound liefern müssen, habe: %v", err)
|
||||
}
|
||||
|
||||
// Direkter Zugriff mit fremder, aber echter ID: Delete.
|
||||
err = store.Delete(ctx, tenantB, idA)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Delete mit fremder mandant-b-anfrage auf mandant-a-id hätte ErrNotFound liefern müssen, habe: %v", err)
|
||||
}
|
||||
|
||||
// Kontrolle: Mandant As eigenes Postfach ist von alledem UNBERÜHRT
|
||||
// (kein erfolgreicher Update/Delete-Seiteneffekt trotz Ablehnung).
|
||||
stillA, err := store.List(ctx, tenantA)
|
||||
if err != nil {
|
||||
t.Fatalf("List mandant a nach angriffsversuchen: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, ma := range stillA {
|
||||
if ma.ID == idA {
|
||||
found = true
|
||||
if ma.IMAPHost == "boese.example.com" {
|
||||
t.Fatalf("mandant Bs Update-Versuch hat mandant As postfach tatsächlich verändert")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("mandant As postfach ist nach den angriffsversuchen verschwunden")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user