security: fix rate-limit bypass, payment amount check, stats leak, owner-dir collision, video panic guard
- clientIP: stop trusting client-supplied X-Forwarded-For; use gin ClientIP with trusted proxies limited to private docker networks - payment: verify notify money matches order amount; remove hardcoded gateway default, fail loudly when api_base is unset - stats: public endpoint now serves aggregates only; recent files (owner names) moved to admin-only /stats/recent - auth: reject signups whose username/email-local collides with another user's media directory namespace - v1: recover panics in background video jobs, refund + mark failed
This commit is contained in:
@@ -221,7 +221,6 @@ func (s *AdminReadService) Stats(ctx context.Context) (map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recentFiles, _ := s.RecentImages(ctx, 24)
|
||||
files, _, _ := s.scanGeneratedFiles(ctx)
|
||||
// Exclude homepage showcase media so 已生成作品 matches the 作品管理 total.
|
||||
pinned := map[string]struct{}{}
|
||||
@@ -239,15 +238,26 @@ func (s *AdminReadService) Stats(ctx context.Context) (map[string]any, error) {
|
||||
count++
|
||||
size += f.Size
|
||||
}
|
||||
// Aggregate-only: `recent` (owner dirs + filenames) is served via
|
||||
// StatsRecent behind an admin session — it must not leak publicly.
|
||||
return map[string]any{
|
||||
"generated_count": count,
|
||||
"generated_size_bytes": size,
|
||||
"recent": recentFiles,
|
||||
"avg_elapsed_ms": stats.AvgElapsedMS,
|
||||
"avg_elapsed_ms_24h": stats.AvgElapsedMS24,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StatsRecent backs the admin image picker; the route lives in the
|
||||
// RequireAdminSession group because entries carry per-user owner names.
|
||||
func (s *AdminReadService) StatsRecent(ctx context.Context) (map[string]any, error) {
|
||||
recentFiles, err := s.RecentImages(ctx, 24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"recent": recentFiles}, nil
|
||||
}
|
||||
|
||||
// Dashboard assembles the admin overview's analytics entirely server-side
|
||||
// (event windows, hourly trend, top models/failures/spenders) plus CDK / invite
|
||||
// / checkin summaries. This replaces the old client-side math over the last 200
|
||||
|
||||
@@ -100,6 +100,34 @@ func ownsImageDir(user *model.User, owner string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// checkOwnerDirCollision rejects a signup whose sanitized username or email
|
||||
// local-part collides with any existing user's directory candidates. Media
|
||||
// authorization (ownsImageDir) matches on those candidates, so without this
|
||||
// check registering as <victim-name>@any-domain would grant access to the
|
||||
// victim's /images/<name>/ directory.
|
||||
func (s *AuthService) checkOwnerDirCollision(ctx context.Context, username, email string) error {
|
||||
newName := sanitizeOwnerName(username)
|
||||
newEmail := sanitizeOwnerName(strings.Split(email, "@")[0])
|
||||
if newName == "" && newEmail == "" {
|
||||
return nil
|
||||
}
|
||||
users, err := s.users.ListOwnerCandidates(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, u := range users {
|
||||
oldName := sanitizeOwnerName(u.Name)
|
||||
oldEmail := sanitizeOwnerName(strings.Split(u.Email, "@")[0])
|
||||
if newName != "" && (oldName == newName || oldEmail == newName) {
|
||||
return errors.New("用户名与已有用户冲突,请更换")
|
||||
}
|
||||
if newEmail != "" && newName != newEmail && (oldName == newEmail || oldEmail == newEmail) {
|
||||
return errors.New("邮箱前缀与已有用户冲突,请更换")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) CurrentUserFromBearer(ctx context.Context, authHeader string) (*model.User, *SessionPayload, error) {
|
||||
token := ParseBearer(authHeader)
|
||||
return s.currentUserFromToken(ctx, token)
|
||||
@@ -272,6 +300,9 @@ func (s *AuthService) Register(ctx context.Context, email, username, password, i
|
||||
if exists {
|
||||
return nil, "", nil, errors.New("用户名已存在")
|
||||
}
|
||||
if err := s.checkOwnerDirCollision(ctx, normalizedUsername, normalizedEmail); err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
|
||||
passwordHash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
|
||||
@@ -55,9 +55,6 @@ func (s *PaymentService) get(ctx context.Context, key string) string {
|
||||
|
||||
func (s *PaymentService) Settings(ctx context.Context) PaySettings {
|
||||
api := strings.TrimRight(strings.TrimSpace(s.get(ctx, "pay.api_base")), "/")
|
||||
if api == "" {
|
||||
api = "https://www.ezfpy.cn"
|
||||
}
|
||||
ratio, _ := strconv.Atoi(s.get(ctx, "pay.points_ratio"))
|
||||
if ratio <= 0 {
|
||||
ratio = 100
|
||||
@@ -147,6 +144,11 @@ func (s *PaymentService) CreateOrder(ctx context.Context, user *model.User, amou
|
||||
if cfg.PID == "" || cfg.Key == "" {
|
||||
return nil, ErrPayNotConfig
|
||||
}
|
||||
// Never guess a gateway: an empty api_base must fail loudly instead of
|
||||
// silently routing signed payment traffic to some hardcoded third party.
|
||||
if cfg.APIBase == "" {
|
||||
return nil, ErrPayNotConfig
|
||||
}
|
||||
if !cfg.allows(method) {
|
||||
return nil, ErrPayMethod
|
||||
}
|
||||
@@ -227,6 +229,11 @@ func (s *PaymentService) HandleNotify(ctx context.Context, params map[string]str
|
||||
if err != nil || order == nil {
|
||||
return false, ErrOrderNotFound
|
||||
}
|
||||
// The gateway signs whatever the cashier actually collected — verify the
|
||||
// paid amount matches the order, or an underpaid notify credits full points.
|
||||
if money, perr := strconv.ParseFloat(strings.TrimSpace(params["money"]), 64); perr != nil || math.Abs(money-order.Amount) > 0.01 {
|
||||
return false, errors.New("notify amount mismatch")
|
||||
}
|
||||
credited, err := s.orders.MarkPaid(ctx, order.ID, params["trade_no"], time.Now())
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -969,6 +970,16 @@ func (s *V1Service) runVideoJob(ctx context.Context, principal *APIPrincipal, in
|
||||
defer s.inflight.Done(eventID)
|
||||
startedAt := time.Now()
|
||||
|
||||
// A panic in provider fan-out must fail the event (and refund), not
|
||||
// take down the whole API process with it.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[v1] panic in runVideoJob event=%s model=%s: %v\n%s", eventID, modelItem.Name, r, debug.Stack())
|
||||
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", fmt.Sprintf("internal error: %v", r), 0)
|
||||
}
|
||||
}()
|
||||
|
||||
// No-store: capture only the UPSTREAM video URL. /content streams it on demand
|
||||
// (grok URLs are auth-gated → fetched with the generating account's token).
|
||||
var videoURL string
|
||||
|
||||
Reference in New Issue
Block a user