From 9db5eaa1e871a606182d1b5aafb2db4f259279bc Mon Sep 17 00:00:00 2001 From: sysops Date: Wed, 8 Jul 2026 11:43:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(PROJ-71):=20TLS-Pflicht=20(optional)=20f?= =?UTF-8?q?=C3=BCr=20eingehenden=20SMTP-BCC-Kanal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: Audit-Log-Detailspalte per Tooltip statt hartem Truncate lesbar machen Co-Authored-By: Claude Sonnet 5 --- config/config.go | 1 + features/INDEX.md | 3 +- features/PROJ-71-smtp-require-tls.md | 49 ++++++++++++++++++++++++++ internal/smtpd/smtpd.go | 16 ++++++++- src/components/admin/tabs/AuditTab.tsx | 21 +++++++++-- 5 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 features/PROJ-71-smtp-require-tls.md diff --git a/config/config.go b/config/config.go index 7320de3..33012b8 100644 --- a/config/config.go +++ b/config/config.go @@ -184,6 +184,7 @@ type SMTPConfig struct { Domain string `yaml:"domain"` TLSCert string `yaml:"tls_cert"` TLSKey string `yaml:"tls_key"` + RequireTLS bool `yaml:"require_tls"` // PROJ-71: reject DATA over plaintext when true (needs tls_cert/tls_key set) MaxSizeMB int `yaml:"max_size_mb"` AllowedIPs []string `yaml:"allowed_ips"` TenantRouting string `yaml:"tenant_routing"` // "domain" or "default" diff --git a/features/INDEX.md b/features/INDEX.md index 1acb1ad..206ee50 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -86,7 +86,8 @@ | PROJ-68 | sudo-Provisionierung für Admin-Dienststeuerung fehlte komplett | Deployed | [PROJ-68](PROJ-68-sudo-provisionierung-dienststeuerung.md) | 2026-07-05 | | PROJ-69 | Admin-Dashboard Tab-Gruppierung (2-Ebenen-Navigation) | Deployed | [PROJ-69](PROJ-69-admin-tabs-gruppierung.md) | 2026-07-06 | | PROJ-70 | User-Self-Service IMAP-Rückholung (Archiv-Mail zurück ins Postfach) | Deployed | [PROJ-70](PROJ-70-imap-rueckholung-self-service.md) | 2026-07-07 | +| PROJ-71 | TLS-Pflicht (optional) für eingehenden SMTP-BCC-Journaling-Kanal | Deployed | [PROJ-71](PROJ-71-smtp-require-tls.md) | 2026-07-08 | -## Next Available ID: PROJ-71 +## Next Available ID: PROJ-72 diff --git a/features/PROJ-71-smtp-require-tls.md b/features/PROJ-71-smtp-require-tls.md new file mode 100644 index 0000000..6a8403a --- /dev/null +++ b/features/PROJ-71-smtp-require-tls.md @@ -0,0 +1,49 @@ +--- +id: PROJ-71 +title: TLS-Pflicht (optional) für eingehenden SMTP-BCC-Journaling-Kanal +status: Deployed +created: 2026-07-08 +--- + +## Problem + +Eingehender SMTP-Daemon (`internal/smtpd/`) für BCC-Journaling erlaubt STARTTLS +optional, erzwingt es aber nicht. Mails können unverschlüsselt per Klartext +eintreffen, obwohl TLS-Zertifikat konfiguriert ist. Sicherheitslücke für den +Journaling-Kanal, der Original-Mails 1:1 (Rohbyte-Erhalt für Revisionssicherheit, +siehe GoBD-Anforderung) entgegennimmt. + +## Lösung + +Neues optionales Config-Flag `smtp.require_tls` (`config/config.go`, Feld +`RequireTLS`). Wenn `true`: + +- Start-Validierung: Daemon startet nicht, wenn `require_tls: true` gesetzt ist, + aber `tls_cert`/`tls_key` fehlen (`internal/smtpd/smtpd.go` `Start()`). +- Enforcement bei `MAIL FROM`: Session ohne aktive TLS-Verbindung wird mit + `530 5.7.0 Must issue STARTTLS first` abgelehnt, bevor Daten übertragen werden + (`session.Mail()`). +- TLS-Status pro Session wird beim Verbindungsaufbau via + `c.TLSConnectionState()` ermittelt (`backend.NewSession()`). + +Standardmäßig deaktiviert (`require_tls` fehlt/false) — keine Breaking Change +für bestehende Installationen ohne TLS-Zertifikat. + +## Implementation Notes + +- `config/config.go`: `SMTPConfig.RequireTLS bool` `yaml:"require_tls"`. +- `internal/smtpd/smtpd.go`: + - `session.isTLS bool` Feld, gesetzt in `NewSession()`. + - `Start()`: Fehler beim Boot, falls `RequireTLS` ohne Cert/Key. + - `session.Mail()`: Reject vor Datenübertragung, spart Bandbreite ggü. + Ablehnung erst nach `DATA`. +- Rohbyte-Erhalt, Message-ID-Erhalt, Envelope/Header-Trennung (BCC-Journaling) + bereits vorhanden und unverändert (siehe Code-Review vom 2026-07-08) — dieses + Feature schließt nur die TLS-Lücke im Transportkanal. + +## Acceptance Criteria + +- [x] `require_tls: false`/fehlend → Verhalten unverändert (STARTTLS optional). +- [x] `require_tls: true` ohne `tls_cert`/`tls_key` → Daemon-Start schlägt fehl. +- [x] `require_tls: true` + Cert/Key gesetzt → Klartext-`MAIL FROM` wird mit + 530 abgelehnt, STARTTLS-Verbindung wird angenommen. diff --git a/internal/smtpd/smtpd.go b/internal/smtpd/smtpd.go index 8b8073f..9c438d8 100644 --- a/internal/smtpd/smtpd.go +++ b/internal/smtpd/smtpd.go @@ -225,6 +225,8 @@ func (d *Daemon) Start() error { return fmt.Errorf("smtpd: load TLS cert: %w", err) } srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} + } else if d.cfg.RequireTLS { + return fmt.Errorf("smtpd: require_tls is set but tls_cert/tls_key are missing") } d.mu.Lock() @@ -322,16 +324,20 @@ func (b *backend) NewSession(c *smtp.Conn) (smtp.Session, error) { } } - b.daemon.logger.Debug("SMTP: new session", "ip", remoteIP) + _, isTLS := c.TLSConnectionState() + + b.daemon.logger.Debug("SMTP: new session", "ip", remoteIP, "tls", isTLS) return &session{ daemon: b.daemon, remoteIP: remoteIP, + isTLS: isTLS, }, nil } type session struct { daemon *Daemon remoteIP string + isTLS bool from string rcpts []string } @@ -342,6 +348,14 @@ func (s *session) AuthPlain(_, _ string) error { } func (s *session) Mail(from string, _ *smtp.MailOptions) error { + if s.daemon.cfg.RequireTLS && !s.isTLS { + s.daemon.stats.Rejected.Add(1) + return &smtp.SMTPError{ + Code: 530, + EnhancedCode: smtp.EnhancedCode{5, 7, 0}, + Message: "Must issue STARTTLS first", + } + } s.from = from return nil } diff --git a/src/components/admin/tabs/AuditTab.tsx b/src/components/admin/tabs/AuditTab.tsx index 6bc8739..a18d358 100644 --- a/src/components/admin/tabs/AuditTab.tsx +++ b/src/components/admin/tabs/AuditTab.tsx @@ -13,6 +13,12 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; const AUDIT_PAGE_SIZE = 25; @@ -75,8 +81,19 @@ export function AuditTab({ {entry.username} - - {entry.detail} + + + + + + {entry.detail} + + + + {entry.detail} + + + ))}