fix(SEC): Signup-Enumeration durch Always-Send-Email schließen

Bei doppeltem Signup wird eine "bereits registriert"-Mail gesendet,
sodass jeder Signup-Versuch eine ausgehende E-Mail erzeugt.
Side-Channel-Angriff zur Account-Enumeration nicht mehr möglich.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-31 22:00:43 +02:00
parent 4583262ea4
commit 7371a73b3e
2 changed files with 30 additions and 2 deletions
+12 -2
View File
@@ -55,10 +55,20 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
req.TenantID = tok.TenantID 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) u, err := s.users.CreateInactive(req)
if err != nil { if err != nil {
// Avoid info-leakage: same response for duplicate email/username // SEC: Send "already registered" email to prevent account enumeration via
writeJSON(w, http.StatusOK, map[string]string{"message": "Wenn die E-Mail-Adresse verfügbar ist, wurde eine Bestätigungs-E-Mail gesendet."}) // 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 return
} }
+18
View File
@@ -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) 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(`<!DOCTYPE html>
<html><body style="font-family:sans-serif;max-width:600px;margin:40px auto;color:#333">
<h2>Registrierungsversuch</h2>
<p>Es wurde versucht, einen neuen Account mit dieser E-Mail-Adresse zu erstellen.</p>
<p>Diese Adresse ist bereits bei archivmail registriert.</p>
<p>Falls du dein Passwort vergessen hast: <a href="https://%s/forgot-password">Passwort zurücksetzen</a></p>
<p style="color:#666;font-size:13px">Falls du diesen Versuch nicht ausgelöst hast, kannst du diese E-Mail ignorieren.</p>
</body></html>`, 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. // InviteHTML returns the HTML body for a tenant invitation.
func InviteHTML(fqdn, token, tenantName string) string { func InviteHTML(fqdn, token, tenantName string) string {
link := fmt.Sprintf("https://%s/signup?invite=%s", fqdn, token) link := fmt.Sprintf("https://%s/signup?invite=%s", fqdn, token)