// QA-04: Sicherheits- und Berechtigungsprüfung, SMTP-Härtung gegen // bekannte Angriffsmuster (Pflichtprüfung: "Gezielter Testangriff auf // Header-Injection schlägt fehl"). Ergänzt ING-03/ING-06/ING-09s // bestehende Tests um einen eigenständigen, gezielten Angriffslauf // gegen den echten, laufenden Server. package smtp import ( "bufio" "context" "net" "strings" "testing" "time" "gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard" ) // TestQA04_HeaderInjectionViaEnvelopeAddressRejected ist die geforderte // Pflichtprüfung: ein gezielter Versuch, über die Envelope-Adressen // (MAIL FROM/RCPT TO) Steuerzeichen einzuschleusen, die bei // unvorsichtiger späterer Header-Konstruktion (z. B. durch einen // nachgelagerten Mailer, siehe ING-03 mail/internal/mailer) zu // Header-Injection führen könnten, schlägt bereits auf // SMTP-Envelope-Ebene fehl. func TestQA04_HeaderInjectionViaEnvelopeAddressRejected(t *testing.T) { sink := &fakeSink{} addr, stop := startTestServer(t, sink, defaultMaxMessageBytes) defer stop() attacks := []struct { name string cmd string }{ {"NUL-Byte in RCPT TO", "RCPT TO:"}, {"Steuerzeichen 0x01 in MAIL FROM", "MAIL FROM:"}, } for _, atk := range attacks { t.Run(atk.name, func(t *testing.T) { 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 } } c.send(t, "MAIL FROM:") resp := c.send(t, atk.cmd) if code(resp) != "553" && code(resp) != "501" { t.Fatalf("angriff %q wurde NICHT sauber zurückgewiesen: %q", atk.name, resp) } // Verbindung muss danach weiter funktionsfähig sein — kein // Absturz, kein hängender Zustand durch den Angriffsversuch. if resp := c.send(t, "NOOP"); code(resp) != "250" { t.Fatalf("session nach angriff %q nicht mehr funktionsfähig: %q", atk.name, resp) } }) } if sink.count() != 0 { t.Fatalf("keiner der angriffsversuche hätte eine nachricht bis zum sink durchlassen dürfen, habe %d", sink.count()) } } // TestQA04_OversizedLineWithoutCRLFDoesNotHangOrCrash ist ein // zusätzlicher, gezielter Testangriff (bekanntes Muster: Ressourcen- // erschöpfung durch eine Kommandozeile ohne Zeilenende) — bestätigt, // dass der Server bei einer weit über die dokumentierte Zeilengrenze // (maxCommandLineBytes) hinausgehenden, nie abgeschlossenen Zeile // zuverlässig reagiert (Verbindungsabbruch/Timeout) statt unbegrenzt // Speicher zu binden oder zu hängen. func TestQA04_OversizedLineWithoutCRLFDoesNotHangOrCrash(t *testing.T) { sink := &fakeSink{} // Kurzer Idle-Timeout statt protoguard.DefaultConfig()s 5 Minuten — // sonst würde dieser Test selbst bei korrektem Verhalten Minuten // brauchen, um die Reaktion des Servers zu beobachten. guardCfg := protoguard.Config{DefaultTimeout: 500 * time.Millisecond} srv := NewServerWithMaxMessageBytesTLSLoggerRateLimitAndGuardConfig(sink, defaultMaxMessageBytes, nil, nil, "mandant-qa04-guard", nil, guardCfg) 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) }() addr := listener.Addr().String() defer func() { cancel() <-done }() 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) if _, err := reader.ReadString('\n'); err != nil { t.Fatalf("begrüßung lesen: %v", err) } // Weit über maxCommandLineBytes (8192) hinaus, absichtlich OHNE // abschließendes CRLF — simuliert eine Gegenstelle, die niemals eine // Zeile abschließt. huge := strings.Repeat("A", 10*maxCommandLineBytes) writeDone := make(chan struct{}) var writeErr error go func() { defer close(writeDone) _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) _, writeErr = conn.Write([]byte(huge)) }() writeCtx, writeCancel := context.WithTimeout(context.Background(), 8*time.Second) defer writeCancel() select { case <-writeDone: case <-writeCtx.Done(): t.Fatalf("schreiben der überlangen zeile blockierte unerwartet lange (>8s) — hinweis auf hängenden server") } if writeErr != nil { // Verbindungsabbruch durch den Server während des Schreibens ist // ein AKZEPTABLES Ergebnis (frühes Erkennen), kein Testfehler. t.Logf("verbindung während des schreibens der überlangen zeile beendet (akzeptabel): %v", writeErr) return } // Server muss innerhalb einer begrenzten Zeit reagieren (Fehler oder // Verbindungsende) — NICHT unbegrenzt auf ein CRLF warten, das nie // kommt. _ = conn.SetReadDeadline(time.Now().Add(8 * time.Second)) _, err = reader.ReadString('\n') if err == nil { t.Fatalf("erwartete verbindungsende/fehler nach überlanger zeile ohne CRLF, server hat stattdessen scheinbar eine antwort geliefert") } if ne, ok := err.(net.Error); ok && ne.Timeout() { t.Fatalf("server hat innerhalb von 8s weder geantwortet noch die verbindung beendet — unbegrenztes warten auf CRLF bestätigt") } }