From b1875f0b826b48d941df751061f4c92071498ed9 Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 13 Apr 2026 14:21:37 +0800 Subject: [PATCH] fix: round 3 audit fixes - SMTP header sanitization and goroutine safety - Move sanitizeEmailHeader to SendEmailWithConfig entry point, covering all email senders (verify code, password reset, ops alerts, notifications) - Add panic recovery to UpdateBalance goroutine - Fix stale comment in getAccountQuotaNotifyEmails (email="" no longer used) - Log error instead of silently discarding verifyNotifyCode cache update failure --- backend/cmd/server/VERSION | 2 +- backend/internal/service/balance_notify_service.go | 2 +- backend/internal/service/email_service.go | 8 ++++++-- backend/internal/service/user_service.go | 9 ++++++++- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index b2581b19..49e7ca57 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.110.20 +0.1.110.21 diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go index 14aa6766..a392a13e 100644 --- a/backend/internal/service/balance_notify_service.go +++ b/backend/internal/service/balance_notify_service.go @@ -225,7 +225,7 @@ func (s *BalanceNotifyService) isAccountQuotaNotifyEnabled(ctx context.Context) } // getAccountQuotaNotifyEmails reads admin notification emails from settings, -// filtering out disabled entries. Entries with email="" are resolved to the first admin's email. +// filtering out disabled and unverified entries. func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context) []string { raw, err := s.settingRepo.GetValue(ctx, SettingKeyAccountQuotaNotifyEmails) if err != nil || strings.TrimSpace(raw) == "" || raw == "[]" { diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 3eade83e..50d324f2 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -153,9 +153,13 @@ func (s *EmailService) SendEmail(ctx context.Context, to, subject, body string) // SendEmailWithConfig 使用指定配置发送邮件 func (s *EmailService) SendEmailWithConfig(config *SMTPConfig, to, subject, body string) error { - from := config.From + // Sanitize all SMTP header fields to prevent header injection (CR/LF removal). + to = sanitizeEmailHeader(to) + subject = sanitizeEmailHeader(subject) + + from := sanitizeEmailHeader(config.From) if config.FromName != "" { - from = fmt.Sprintf("%s <%s>", config.FromName, config.From) + from = fmt.Sprintf("%s <%s>", sanitizeEmailHeader(config.FromName), sanitizeEmailHeader(config.From)) } msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s", diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 3baee81d..0da73762 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -224,6 +224,11 @@ func (s *UserService) UpdateBalance(ctx context.Context, userID int64, amount fl } if s.billingCache != nil { go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("panic in balance cache invalidation", "user_id", userID, "recover", r) + } + }() cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := s.billingCache.InvalidateUserBalance(cacheCtx, userID); err != nil { @@ -359,7 +364,9 @@ func verifyNotifyCode(ctx context.Context, cache EmailCache, email, code string) } if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 { data.Attempts++ - _ = cache.SetNotifyVerifyCode(ctx, email, data, verifyCodeTTL) + if err := cache.SetNotifyVerifyCode(ctx, email, data, verifyCodeTTL); err != nil { + slog.Error("failed to update notify verify code attempts", "email", email, "error", err) + } if data.Attempts >= maxVerifyCodeAttempts { return ErrVerifyCodeMaxAttempts }