fix: address audit findings for notify, websearch and security

- Fix GetByKeyForAuth missing user.FieldEmail and user.FieldUsername (notifications sent to empty address)
- Guard against empty email in collectBalanceNotifyRecipients
- Remove non-atomic TotalRecharged read-modify-write in admin balance adjustment
- HTML-escape userName/siteName/accountName in notification email templates
- Fix timer leak in ProfileBalanceNotifyCard (add onUnmounted cleanup)
- Add warning log on websearch proxy URL resolution failure
This commit is contained in:
erio
2026-04-12 18:11:47 +08:00
parent eba289a7ff
commit 4e96a6faec
5 changed files with 15 additions and 10 deletions

View File

@@ -139,6 +139,8 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
WithUser(func(q *dbent.UserQuery) { WithUser(func(q *dbent.UserQuery) {
q.Select( q.Select(
user.FieldID, user.FieldID,
user.FieldEmail,
user.FieldUsername,
user.FieldStatus, user.FieldStatus,
user.FieldRole, user.FieldRole,
user.FieldBalance, user.FieldBalance,

View File

@@ -709,12 +709,6 @@ func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64,
return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", oldBalance, user.Balance) return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", oldBalance, user.Balance)
} }
// Track cumulative recharge for percentage-based balance notifications
balanceDelta := user.Balance - oldBalance
if balanceDelta > 0 {
user.TotalRecharged += balanceDelta
}
if err := s.userRepo.Update(ctx, user); err != nil { if err := s.userRepo.Update(ctx, user); err != nil {
return nil, err return nil, err
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html"
"log/slog" "log/slog"
"strconv" "strconv"
"strings" "strings"
@@ -195,7 +196,10 @@ func (s *BalanceNotifyService) getSiteName(ctx context.Context) string {
// collectBalanceNotifyRecipients collects all email recipients for balance notifications. // collectBalanceNotifyRecipients collects all email recipients for balance notifications.
func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string { func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string {
recipients := []string{user.Email} var recipients []string
if user.Email != "" {
recipients = append(recipients, user.Email)
}
for _, extra := range user.BalanceNotifyExtraEmails { for _, extra := range user.BalanceNotifyExtraEmails {
email := strings.TrimSpace(extra) email := strings.TrimSpace(extra)
if email != "" && !strings.EqualFold(email, user.Email) { if email != "" && !strings.EqualFold(email, user.Email) {
@@ -224,7 +228,7 @@ func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userNam
displayName = userEmail displayName = userEmail
} }
subject := fmt.Sprintf("[%s] 余额不足提醒 / Balance Low Alert", siteName) subject := fmt.Sprintf("[%s] 余额不足提醒 / Balance Low Alert", siteName)
body := s.buildBalanceLowEmailBody(displayName, balance, threshold, siteName) body := s.buildBalanceLowEmailBody(html.EscapeString(displayName), balance, threshold, html.EscapeString(siteName))
s.sendEmails(recipients, subject, body, "user_email", userEmail, "balance", balance) s.sendEmails(recipients, subject, body, "user_email", userEmail, "balance", balance)
} }
@@ -236,7 +240,7 @@ func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accoun
} }
subject := fmt.Sprintf("[%s] 账号限额告警 / Account Quota Alert - %s", siteName, accountName) subject := fmt.Sprintf("[%s] 账号限额告警 / Account Quota Alert - %s", siteName, accountName)
body := s.buildQuotaAlertEmailBody(accountName, dimLabel, used, limit, threshold, siteName) body := s.buildQuotaAlertEmailBody(html.EscapeString(accountName), html.EscapeString(dimLabel), used, limit, threshold, html.EscapeString(siteName))
s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dimension) s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dimension)
} }

View File

@@ -235,6 +235,7 @@ func (s *SettingService) resolveProviderProxyURLs(ctx context.Context, cfg *WebS
} }
proxies, err := s.proxyRepo.ListByIDs(ctx, ids) proxies, err := s.proxyRepo.ListByIDs(ctx, ids)
if err != nil { if err != nil {
slog.Warn("websearch: failed to resolve proxy URLs", "error", err)
return nil return nil
} }
result := make(map[int64]string, len(proxies)) result := make(map[int64]string, len(proxies))

View File

@@ -93,7 +93,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
@@ -122,6 +122,10 @@ const codeCountdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null let countdownTimer: ReturnType<typeof setInterval> | null = null
onUnmounted(() => {
if (countdownTimer) clearInterval(countdownTimer)
})
watch(() => props.enabled, (val) => { notifyEnabled.value = val }) watch(() => props.enabled, (val) => { notifyEnabled.value = val })
watch(() => props.threshold, (val) => { customThreshold.value = val }) watch(() => props.threshold, (val) => { customThreshold.value = val })
watch(() => props.extraEmails, (val) => { extraEmails.value = [...val] }) watch(() => props.extraEmails, (val) => { extraEmails.value = [...val] })