diff --git a/internal/api/onboarding_handlers.go b/internal/api/onboarding_handlers.go index ead2c8d..93af58c 100644 --- a/internal/api/onboarding_handlers.go +++ b/internal/api/onboarding_handlers.go @@ -55,10 +55,20 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { req.TenantID = tok.TenantID } + const signupMsg = "Wenn die E-Mail-Adresse verfügbar ist, wurde eine Bestätigungs-E-Mail gesendet." + u, err := s.users.CreateInactive(req) if err != nil { - // Avoid info-leakage: same response for duplicate email/username - writeJSON(w, http.StatusOK, map[string]string{"message": "Wenn die E-Mail-Adresse verfügbar ist, wurde eine Bestätigungs-E-Mail gesendet."}) + // SEC: Send "already registered" email to prevent account enumeration via + // email delivery side-channel. Every signup attempt produces an outgoing email. + if s.mailer.IsConfigured() { + go func() { + html := mailer.AlreadyRegisteredHTML(s.fqdn) + txt := mailer.AlreadyRegisteredText(s.fqdn) + _ = s.mailer.Send(body.Email, "archivmail – Registrierungsversuch", html, txt) + }() + } + writeJSON(w, http.StatusOK, map[string]string{"message": signupMsg}) return } diff --git a/internal/mailer/templates.go b/internal/mailer/templates.go index d44409d..d506196 100644 --- a/internal/mailer/templates.go +++ b/internal/mailer/templates.go @@ -45,6 +45,24 @@ func ResetPasswordText(fqdn, token, username string) string { return fmt.Sprintf("Hallo %s,\n\nPasswort zurücksetzen:\n\n%s\n\nDer Link ist 1 Stunde gültig.\n\narcivmail", username, link) } +// AlreadyRegisteredHTML returns the HTML body sent when a duplicate signup is attempted. +// Prevents email enumeration by always sending an email on any signup attempt. +func AlreadyRegisteredHTML(fqdn string) string { + return fmt.Sprintf(` + +

Registrierungsversuch

+

Es wurde versucht, einen neuen Account mit dieser E-Mail-Adresse zu erstellen.

+

Diese Adresse ist bereits bei archivmail registriert.

+

Falls du dein Passwort vergessen hast: Passwort zurücksetzen

+

Falls du diesen Versuch nicht ausgelöst hast, kannst du diese E-Mail ignorieren.

+`, fqdn) +} + +// AlreadyRegisteredText returns the plain-text body for duplicate signup. +func AlreadyRegisteredText(fqdn string) string { + return fmt.Sprintf("Diese E-Mail-Adresse ist bereits bei archivmail registriert.\n\nPasswort vergessen? https://%s/forgot-password\n\nFalls du diesen Versuch nicht ausgelöst hast, ignoriere diese E-Mail.", fqdn) +} + // InviteHTML returns the HTML body for a tenant invitation. func InviteHTML(fqdn, token, tenantName string) string { link := fmt.Sprintf("https://%s/signup?invite=%s", fqdn, token)