From b2b55ea52d8574705a3554c9738aa869da703221 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Thu, 30 Apr 2026 12:26:25 +0800 Subject: [PATCH] feat: enhance security with IP banning, ownership checks, and SSRF protection Add comprehensive security improvements across the application: - **IP-based login protection**: Implement IP ban system tracking login failures, auto-banning after threshold exceeded - **Ownership verification**: Add userID parameter to Delete/Update operations for posts and comments to prevent unauthorized modifications - **SSRF protection**: Add URL and resolved host validation for image URLs in chat and OpenAI client - **SQL injection prevention**: Add EscapeLikeWildcard utility to escape special characters in LIKE queries - **HTTP security**: Configure server timeouts and add security headers middleware - **Rate limiting**: Refactor to support configurable duration and per-endpoint rate limits for auth routes - **Error handling**: Standardize error responses using HandleError and proper error types --- cmd/server/app.go | 9 +- internal/handler/comment_handler.go | 8 +- internal/handler/notification_handler.go | 4 +- internal/handler/post_handler.go | 4 +- internal/handler/user_handler.go | 4 + internal/middleware/ratelimit.go | 202 +++++++++++++++++++++- internal/middleware/security_headers.go | 20 +++ internal/pkg/netutil/safeclient.go | 29 ++++ internal/pkg/netutil/ssrf.go | 134 ++++++++++++++ internal/pkg/openai/client.go | 11 +- internal/pkg/utils/like.go | 11 ++ internal/repository/material_repo.go | 5 +- internal/repository/post_repo.go | 13 +- internal/repository/verification_repo.go | 4 +- internal/router/router.go | 16 +- internal/service/comment_service.go | 11 +- internal/service/message_segment_image.go | 7 + internal/service/notification_service.go | 31 ++-- internal/service/post_service.go | 16 +- internal/service/schedule_sync_service.go | 5 +- internal/service/sticker_service.go | 3 +- internal/service/trade_service.go | 13 +- internal/service/user_service.go | 23 +-- internal/service/vote_service.go | 14 +- 24 files changed, 517 insertions(+), 80 deletions(-) create mode 100644 internal/middleware/security_headers.go create mode 100644 internal/pkg/netutil/safeclient.go create mode 100644 internal/pkg/netutil/ssrf.go create mode 100644 internal/pkg/utils/like.go diff --git a/cmd/server/app.go b/cmd/server/app.go index 8206f4c..4b24ec9 100644 --- a/cmd/server/app.go +++ b/cmd/server/app.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "time" "with_you/internal/config" "with_you/internal/grpc/runner" @@ -72,8 +73,12 @@ func (a *App) Start(ctx context.Context) error { ) a.Server = &http.Server{ - Addr: addr, - Handler: a.Router.Engine(), + Addr: addr, + Handler: a.Router.Engine(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, } // 4. 启动 HTTP 服务器 diff --git a/internal/handler/comment_handler.go b/internal/handler/comment_handler.go index 7718e30..e2c4087 100644 --- a/internal/handler/comment_handler.go +++ b/internal/handler/comment_handler.go @@ -178,9 +178,9 @@ func (h *CommentHandler) Update(c *gin.Context) { comment.Content = req.Content - err = h.commentService.Update(c.Request.Context(), comment) + err = h.commentService.Update(c.Request.Context(), userID, comment) if err != nil { - response.InternalServerError(c, "failed to update comment") + response.HandleError(c, err, "failed to update comment") return } @@ -208,9 +208,9 @@ func (h *CommentHandler) Delete(c *gin.Context) { return } - err = h.commentService.Delete(c.Request.Context(), id) + err = h.commentService.Delete(c.Request.Context(), userID, id) if err != nil { - response.InternalServerError(c, "failed to delete comment") + response.HandleError(c, err, "failed to delete comment") return } diff --git a/internal/handler/notification_handler.go b/internal/handler/notification_handler.go index e513436..a7839dd 100644 --- a/internal/handler/notification_handler.go +++ b/internal/handler/notification_handler.go @@ -56,7 +56,7 @@ func (h *NotificationHandler) MarkAsRead(c *gin.Context) { err := h.notificationService.MarkAsReadWithUserID(c.Request.Context(), id, userID) if err != nil { - response.InternalServerError(c, "failed to mark as read") + response.HandleError(c, err, "failed to mark as read") return } @@ -109,7 +109,7 @@ func (h *NotificationHandler) DeleteNotification(c *gin.Context) { err := h.notificationService.DeleteNotification(c.Request.Context(), id, userID) if err != nil { - response.InternalServerError(c, "failed to delete notification") + response.HandleError(c, err, "failed to delete notification") return } diff --git a/internal/handler/post_handler.go b/internal/handler/post_handler.go index 12a6128..740fc26 100644 --- a/internal/handler/post_handler.go +++ b/internal/handler/post_handler.go @@ -496,9 +496,9 @@ func (h *PostHandler) Delete(c *gin.Context) { return } - err = h.postService.Delete(c.Request.Context(), id) + err = h.postService.Delete(c.Request.Context(), userID, id) if err != nil { - response.InternalServerError(c, "failed to delete post") + response.HandleError(c, err, "failed to delete post") return } diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index 5a5aac4..40efd14 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "with_you/internal/dto" + "with_you/internal/middleware" "with_you/internal/model" "with_you/internal/pkg/response" "with_you/internal/service" @@ -153,10 +154,13 @@ func (h *UserHandler) Login(c *gin.Context) { user, err := h.userService.Login(c.Request.Context(), account, req.Password) if err != nil { + middleware.RecordLoginFailure(c.ClientIP()) response.HandleError(c, err, "failed to login") return } + middleware.ResetLoginFailures(c.ClientIP()) + // 生成Token accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username) refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username) diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go index abccef9..bb1c152 100644 --- a/internal/middleware/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -75,8 +75,29 @@ func (rl *RateLimiter) isAllowed(key string) bool { return true } +func (rl *RateLimiter) count(key string) int { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + times := rl.requests[key] + + var valid []time.Time + for _, t := range times { + if now.Sub(t) < rl.window { + valid = append(valid, t) + } + } + rl.requests[key] = valid + return len(valid) +} + func RateLimit(requestsPerMinute int) gin.HandlerFunc { - limiter := NewRateLimiter(requestsPerMinute, time.Minute) + return RateLimitWithDuration(requestsPerMinute, time.Minute) +} + +func RateLimitWithDuration(limit int, window time.Duration) gin.HandlerFunc { + limiter := NewRateLimiter(limit, window) return func(c *gin.Context) { ip := c.ClientIP() @@ -95,7 +116,11 @@ func RateLimit(requestsPerMinute int) gin.HandlerFunc { } func RateLimitWithKey(requestsPerMinute int, keyFunc func(c *gin.Context) string) gin.HandlerFunc { - limiter := NewRateLimiter(requestsPerMinute, time.Minute) + return RateLimitWithDurationAndKey(requestsPerMinute, time.Minute, keyFunc) +} + +func RateLimitWithDurationAndKey(limit int, window time.Duration, keyFunc func(c *gin.Context) string) gin.HandlerFunc { + limiter := NewRateLimiter(limit, window) return func(c *gin.Context) { key := keyFunc(c) @@ -115,3 +140,176 @@ func RateLimitWithKey(requestsPerMinute int, keyFunc func(c *gin.Context) string c.Next() } } + +// ============================================================ +// IP 封禁与登录失败追踪 +// ============================================================ + +const ( + DefaultMaxLoginFailures = 10 + DefaultBanDuration = 15 * time.Minute + DefaultFailureWindow = 15 * time.Minute +) + +type ipBanEntry struct { + unbanAt time.Time +} + +type loginFailureEntry struct { + count int + firstAt time.Time + lastAt time.Time +} + +type IPBanStore struct { + mu sync.RWMutex + bans map[string]*ipBanEntry + failures map[string]*loginFailureEntry + + maxFailures int + banDuration time.Duration + failWindow time.Duration +} + +var defaultIPBanStore = NewIPBanStore(DefaultMaxLoginFailures, DefaultBanDuration, DefaultFailureWindow) + +func NewIPBanStore(maxFailures int, banDuration, failWindow time.Duration) *IPBanStore { + store := &IPBanStore{ + bans: make(map[string]*ipBanEntry), + failures: make(map[string]*loginFailureEntry), + maxFailures: maxFailures, + banDuration: banDuration, + failWindow: failWindow, + } + + go store.cleanupLoop() + + return store +} + +func (s *IPBanStore) cleanupLoop() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + + for range ticker.C { + s.cleanup() + } +} + +func (s *IPBanStore) cleanup() { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + + for ip, entry := range s.bans { + if now.After(entry.unbanAt) { + delete(s.bans, ip) + } + } + + for ip, entry := range s.failures { + if now.Sub(entry.firstAt) > s.failWindow && now.Sub(entry.lastAt) > s.failWindow { + delete(s.failures, ip) + } + } +} + +func (s *IPBanStore) IsBanned(ip string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + + entry, exists := s.bans[ip] + if !exists { + return false + } + + return time.Now().Before(entry.unbanAt) +} + +func (s *IPBanStore) RecordFailure(ip string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + + entry, exists := s.failures[ip] + if !exists || now.Sub(entry.firstAt) > s.failWindow { + entry = &loginFailureEntry{ + count: 1, + firstAt: now, + lastAt: now, + } + s.failures[ip] = entry + return false + } + + entry.count++ + entry.lastAt = now + + if entry.count >= s.maxFailures { + s.bans[ip] = &ipBanEntry{ + unbanAt: now.Add(s.banDuration), + } + delete(s.failures, ip) + return true + } + + return false +} + +func (s *IPBanStore) ResetFailures(ip string) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.failures, ip) +} + +func RecordLoginFailure(ip string) bool { + return defaultIPBanStore.RecordFailure(ip) +} + +func ResetLoginFailures(ip string) { + defaultIPBanStore.ResetFailures(ip) +} + +func IPBanMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + + if defaultIPBanStore.IsBanned(ip) { + c.JSON(http.StatusTooManyRequests, gin.H{ + "code": 429, + "message": "your IP has been temporarily banned due to too many failed attempts, please try again later", + }) + c.Abort() + return + } + + c.Next() + } +} + +// ============================================================ +// 预配置的速率限制中间件 +// ============================================================ + +func LoginRateLimit() gin.HandlerFunc { + return RateLimitWithDuration(10, time.Minute) +} + +func RegisterRateLimit() gin.HandlerFunc { + return RateLimitWithDuration(5, time.Hour) +} + +func PasswordResetRateLimit() gin.HandlerFunc { + return RateLimitWithDuration(3, time.Hour) +} + +func SendCodeRateLimit() gin.HandlerFunc { + return RateLimitWithDuration(5, time.Hour) +} + +func GlobalAuthRateLimit() gin.HandlerFunc { + return RateLimitWithDuration(30, time.Minute) +} diff --git a/internal/middleware/security_headers.go b/internal/middleware/security_headers.go new file mode 100644 index 0000000..17b4a35 --- /dev/null +++ b/internal/middleware/security_headers.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +func SecurityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'") + c.Header("Referrer-Policy", "strict-origin-when-cross-origin") + c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "DENY") + c.Header("X-XSS-Protection", "0") + c.Header("Cross-Origin-Opener-Policy", "same-origin") + c.Header("Cross-Origin-Resource-Policy", "same-origin") + + c.Next() + } +} diff --git a/internal/pkg/netutil/safeclient.go b/internal/pkg/netutil/safeclient.go new file mode 100644 index 0000000..9d6756b --- /dev/null +++ b/internal/pkg/netutil/safeclient.go @@ -0,0 +1,29 @@ +package netutil + +import ( + "net" + "net/http" + "time" +) + +func NewSafeHTTPClient(timeout time.Duration) *http.Client { + safeDialer := &SafeDialer{ + Dialer: net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }, + } + + transport := &http.Transport{ + DialContext: safeDialer.DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + + return &http.Client{ + Timeout: timeout, + Transport: transport, + } +} \ No newline at end of file diff --git a/internal/pkg/netutil/ssrf.go b/internal/pkg/netutil/ssrf.go new file mode 100644 index 0000000..542c388 --- /dev/null +++ b/internal/pkg/netutil/ssrf.go @@ -0,0 +1,134 @@ +package netutil + +import ( + "context" + "fmt" + "net" + "net/url" + "strings" +) + +func isPrivateIP(ip net.IP) bool { + privateRanges := []struct { + network *net.IPNet + }{ + {mustParseCIDR("10.0.0.0/8")}, + {mustParseCIDR("172.16.0.0/12")}, + {mustParseCIDR("192.168.0.0/16")}, + {mustParseCIDR("127.0.0.0/8")}, + {mustParseCIDR("169.254.0.0/16")}, + {mustParseCIDR("0.0.0.0/8")}, + {mustParseCIDR("100.64.0.0/10")}, + {mustParseCIDR("198.18.0.0/15")}, + {mustParseCIDR("::1/128")}, + {mustParseCIDR("fc00::/7")}, + {mustParseCIDR("fe80::/10")}, + {mustParseCIDR("::ffff:127.0.0.0/104")}, + } + + for _, r := range privateRanges { + if r.network.Contains(ip) { + return true + } + } + return false +} + +func mustParseCIDR(s string) *net.IPNet { + _, n, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + return n +} + +type SafeDialer struct { + Dialer net.Dialer +} + +func (d *SafeDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("netutil: invalid address %q: %w", addr, err) + } + + resolver := net.Resolver{} + ips, err := resolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, fmt.Errorf("netutil: DNS lookup failed for %q: %w", host, err) + } + + for _, ipAddr := range ips { + if isPrivateIP(ipAddr.IP) { + return nil, fmt.Errorf("netutil: resolved IP %s of %q is a private/reserved address", ipAddr.IP, host) + } + } + + var targetIPs []string + for _, ipAddr := range ips { + targetIPs = append(targetIPs, ipAddr.IP.String()) + } + + var lastErr error + for _, ipStr := range targetIPs { + conn, err := d.Dialer.DialContext(ctx, network, net.JoinHostPort(ipStr, port)) + if err != nil { + lastErr = err + continue + } + + remoteAddr, ok := conn.RemoteAddr().(*net.TCPAddr) + if ok && isPrivateIP(remoteAddr.IP) { + conn.Close() + return nil, fmt.Errorf("netutil: connected IP %s is a private/reserved address (DNS rebinding detected)", remoteAddr.IP) + } + return conn, nil + } + + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("netutil: no reachable IP for %q", host) +} + +func ValidateURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("netutil: invalid URL: %w", err) + } + + scheme := strings.ToLower(u.Scheme) + if scheme != "http" && scheme != "https" { + return fmt.Errorf("netutil: scheme %q is not allowed, only http and https are permitted", u.Scheme) + } + + if u.Host == "" { + return fmt.Errorf("netutil: URL missing host") + } + + hostname := u.Hostname() + if hostname == "" { + return fmt.Errorf("netutil: URL missing hostname") + } + + if ip := net.ParseIP(hostname); ip != nil { + if isPrivateIP(ip) { + return fmt.Errorf("netutil: host IP %s is a private/reserved address", ip) + } + } + + return nil +} + +func CheckResolvedHost(hostname string) error { + ips, err := net.LookupIP(hostname) + if err != nil { + return fmt.Errorf("netutil: DNS lookup failed for %q: %w", hostname, err) + } + for _, ip := range ips { + if isPrivateIP(ip) { + return fmt.Errorf("netutil: resolved IP %s of %q is a private/reserved address", ip, hostname) + } + } + return nil +} diff --git a/internal/pkg/openai/client.go b/internal/pkg/openai/client.go index 836bc4e..066ccac 100644 --- a/internal/pkg/openai/client.go +++ b/internal/pkg/openai/client.go @@ -16,6 +16,8 @@ import ( "strings" "time" + "with_you/internal/pkg/netutil" + xdraw "golang.org/x/image/draw" ) @@ -108,10 +110,8 @@ func NewClient(cfg Config) Client { timeout = 30 } return &clientImpl{ - cfg: cfg, - httpClient: &http.Client{ - Timeout: time.Duration(timeout) * time.Second, - }, + cfg: cfg, + httpClient: netutil.NewSafeHTTPClient(time.Duration(timeout) * time.Second), } } @@ -619,6 +619,9 @@ func (c *clientImpl) tryCompressImageForModeration(ctx context.Context, imageURL if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { return imageURL } + if err := netutil.ValidateURL(imageURL); err != nil { + return imageURL + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) if err != nil { diff --git a/internal/pkg/utils/like.go b/internal/pkg/utils/like.go new file mode 100644 index 0000000..f345360 --- /dev/null +++ b/internal/pkg/utils/like.go @@ -0,0 +1,11 @@ +package utils + +import "strings" + +func EscapeLikeWildcard(s string) string { + return strings.NewReplacer( + `\`, `\\`, + "%", `\%`, + "_", `\_`, + ).Replace(s) +} \ No newline at end of file diff --git a/internal/repository/material_repo.go b/internal/repository/material_repo.go index 5e319c0..a0bae09 100644 --- a/internal/repository/material_repo.go +++ b/internal/repository/material_repo.go @@ -3,6 +3,7 @@ import ( "with_you/internal/dto" "with_you/internal/model" + "with_you/internal/pkg/utils" "gorm.io/gorm" ) @@ -119,7 +120,7 @@ func (r *materialFileRepository) List(params dto.MaterialFileQueryParams) ([]*mo query = query.Where("status = ?", params.Status) } if params.Keyword != "" { - keyword := "%" + params.Keyword + "%" + keyword := "%" + utils.EscapeLikeWildcard(params.Keyword) + "%" query = query.Where("title LIKE ? OR description LIKE ?", keyword, keyword) } @@ -223,7 +224,7 @@ func (r *materialFileRepository) Search(keyword string, page, pageSize int) ([]* query := r.db.Model(&model.MaterialFile{}).Where("status = ?", model.MaterialStatusActive) if keyword != "" { - kw := "%" + keyword + "%" + kw := "%" + utils.EscapeLikeWildcard(keyword) + "%" query = query.Where("title LIKE ? OR description LIKE ?", kw, kw) } diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index 6241e57..1b17405 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -7,6 +7,7 @@ import ( "with_you/internal/model" "with_you/internal/pkg/cursor" + "with_you/internal/pkg/utils" "gorm.io/gorm" ) @@ -510,10 +511,10 @@ func (r *postRepository) Search(keyword string, page, pageSize int) ([]*model.Po // 搜索标题和内容 if keyword != "" { if r.db.Dialector.Name() == "postgres" { - searchPattern := "%" + keyword + "%" + searchPattern := "%" + utils.EscapeLikeWildcard(keyword) + "%" query = query.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern) } else { - searchPattern := "%" + keyword + "%" + searchPattern := "%" + utils.EscapeLikeWildcard(keyword) + "%" query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern) } } @@ -877,10 +878,10 @@ func (r *postRepository) AdminList(query AdminPostListQuery) ([]*model.Post, int // 关键词搜索(标题或内容) if query.Keyword != "" { if r.db.Dialector.Name() == "postgres" { - searchPattern := "%" + query.Keyword + "%" + searchPattern := "%" + utils.EscapeLikeWildcard(query.Keyword) + "%" db = db.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern) } else { - searchPattern := "%" + query.Keyword + "%" + searchPattern := "%" + utils.EscapeLikeWildcard(query.Keyword) + "%" db = db.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern) } } @@ -1125,10 +1126,10 @@ func (r *postRepository) SearchPostsByCursor(ctx context.Context, keyword string // 搜索标题和内容 if keyword != "" { if r.db.Dialector.Name() == "postgres" { - searchPattern := "%" + keyword + "%" + searchPattern := "%" + utils.EscapeLikeWildcard(keyword) + "%" query = query.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern) } else { - searchPattern := "%" + keyword + "%" + searchPattern := "%" + utils.EscapeLikeWildcard(keyword) + "%" query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern) } } diff --git a/internal/repository/verification_repo.go b/internal/repository/verification_repo.go index 35384b8..e8596dd 100644 --- a/internal/repository/verification_repo.go +++ b/internal/repository/verification_repo.go @@ -2,6 +2,7 @@ import ( "with_you/internal/model" + "with_you/internal/pkg/utils" "gorm.io/gorm" ) @@ -69,9 +70,10 @@ func (r *verificationRepository) List(page, pageSize int, status, keyword string } if keyword != "" { + kw := "%" + utils.EscapeLikeWildcard(keyword) + "%" query = query.Joins("JOIN users ON users.id = verification_records.user_id"). Where("users.username LIKE ? OR users.nickname LIKE ? OR verification_records.real_name LIKE ?", - "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + kw, kw, kw) } query.Count(&total) diff --git a/internal/router/router.go b/internal/router/router.go index b78db3f..86d366c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -140,6 +140,7 @@ func New( // setupRoutes 设置路由 func (r *Router) setupRoutes() { // 中间件 + r.engine.Use(middleware.SecurityHeaders()) r.engine.Use(middleware.CORS()) // 健康检查 @@ -150,15 +151,16 @@ func (r *Router) setupRoutes() { // API v1 v1 := r.engine.Group("/api/v1") { - // 认证路由(公开,需要速率限制) + // 认证路由(公开,按接口类型设置不同速率限制) auth := v1.Group("/auth") - auth.Use(middleware.RateLimit(10)) // 每分钟最多10次请求 + auth.Use(middleware.GlobalAuthRateLimit()) + auth.Use(middleware.IPBanMiddleware()) { - auth.POST("/register", r.userHandler.Register) - auth.POST("/register/send-code", r.userHandler.SendRegisterCode) - auth.POST("/login", r.userHandler.Login) - auth.POST("/password/send-code", r.userHandler.SendPasswordResetCode) - auth.POST("/password/reset", r.userHandler.ResetPassword) + auth.POST("/register", middleware.RegisterRateLimit(), r.userHandler.Register) + auth.POST("/register/send-code", middleware.SendCodeRateLimit(), r.userHandler.SendRegisterCode) + auth.POST("/login", middleware.LoginRateLimit(), r.userHandler.Login) + auth.POST("/password/send-code", middleware.PasswordResetRateLimit(), r.userHandler.SendPasswordResetCode) + auth.POST("/password/reset", middleware.PasswordResetRateLimit(), r.userHandler.ResetPassword) auth.POST("/logout", r.userHandler.Logout) auth.POST("/refresh", r.userHandler.RefreshToken) } diff --git a/internal/service/comment_service.go b/internal/service/comment_service.go index f6f7678..1789664 100644 --- a/internal/service/comment_service.go +++ b/internal/service/comment_service.go @@ -6,6 +6,7 @@ import ( "log" "strings" + apperrors "with_you/internal/errors" "with_you/internal/cache" "with_you/internal/dto" "with_you/internal/model" @@ -299,16 +300,22 @@ func (s *CommentService) GetReplies(ctx context.Context, parentID string) ([]*mo } // Update 更新评论 -func (s *CommentService) Update(ctx context.Context, comment *model.Comment) error { +func (s *CommentService) Update(ctx context.Context, userID string, comment *model.Comment) error { + if comment.UserID != userID { + return apperrors.ErrForbidden + } return s.commentRepo.Update(comment) } // Delete 删除评论 -func (s *CommentService) Delete(ctx context.Context, id string) error { +func (s *CommentService) Delete(ctx context.Context, userID string, id string) error { comment, err := s.commentRepo.GetByID(id) if err != nil { return err } + if comment.UserID != userID { + return apperrors.ErrForbidden + } if err := s.commentRepo.Delete(id); err != nil { return err } diff --git a/internal/service/message_segment_image.go b/internal/service/message_segment_image.go index 4fc8728..66e1f09 100644 --- a/internal/service/message_segment_image.go +++ b/internal/service/message_segment_image.go @@ -6,6 +6,7 @@ import ( "strings" "with_you/internal/model" + "with_you/internal/pkg/netutil" ) // ValidateChatMessageImageSegments 校验聊天消息中的图片段:禁止 data:/base64 内联,仅允许引用本站 S3 公开前缀下的 URL。 @@ -45,6 +46,12 @@ func (s *UploadService) ValidateChatMessageImageSegments(segments model.MessageS if u.Host == "" { return fmt.Errorf("图片地址无效") } + if err := netutil.ValidateURL(urlStr); err != nil { + return fmt.Errorf("图片地址不安全: %w", err) + } + if err := netutil.CheckResolvedHost(u.Hostname()); err != nil { + return fmt.Errorf("图片地址不安全: %w", err) + } if !strings.HasPrefix(urlStr, prefix) { return fmt.Errorf("图片必须使用本站上传接口生成的资源地址") diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index f191a23..72da762 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -58,27 +58,21 @@ func (s *NotificationService) GetByUserID(ctx context.Context, userID string, pa return s.notificationRepo.GetByUserID(userID, page, pageSize, unreadOnly) } -// MarkAsRead 标记为已读 -func (s *NotificationService) MarkAsRead(ctx context.Context, id string) error { - err := s.notificationRepo.MarkAsRead(id) - if err != nil { - return err - } - - // 注意:这里无法获取userID,所以不在缓存中失效 - // 调用方应该使用MarkAsReadWithUserID方法 - - return nil -} - -// MarkAsReadWithUserID 标记为已读(带用户ID,用于缓存失效) +// MarkAsReadWithUserID 标记为已读(带用户ID,验证所有权) func (s *NotificationService) MarkAsReadWithUserID(ctx context.Context, id, userID string) error { - err := s.notificationRepo.MarkAsRead(id) + notification, err := s.notificationRepo.GetByID(id) + if err != nil { + return err + } + if notification.UserID != userID { + return apperrors.ErrForbidden + } + + err = s.notificationRepo.MarkAsRead(id) if err != nil { return err } - // 失效未读数缓存 cache.InvalidateUnreadSystem(s.cache, userID) return nil @@ -97,11 +91,6 @@ func (s *NotificationService) MarkAllAsRead(ctx context.Context, userID string) return nil } -// Delete 删除通知 -func (s *NotificationService) Delete(ctx context.Context, id string) error { - return s.notificationRepo.Delete(id) -} - // GetUnreadCount 获取未读数量(带缓存) func (s *NotificationService) GetUnreadCount(ctx context.Context, userID string) (int64, error) { cacheSettings := cache.GetSettings() diff --git a/internal/service/post_service.go b/internal/service/post_service.go index b37e5fc..2c8cf7b 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -8,6 +8,7 @@ import ( "strings" "time" + apperrors "with_you/internal/errors" "with_you/internal/cache" "with_you/internal/dto" "with_you/internal/model" @@ -30,7 +31,7 @@ type PostService interface { GetByID(ctx context.Context, id string) (*model.Post, error) Update(ctx context.Context, post *model.Post) error UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) error - Delete(ctx context.Context, id string) error + Delete(ctx context.Context, userID string, id string) error // 帖子列表 List(ctx context.Context, page, pageSize int, userID string, includePending bool, channelID *string) ([]*model.Post, int64, error) @@ -313,13 +314,20 @@ func (s *postServiceImpl) UpdateWithImages(ctx context.Context, post *model.Post } // Delete 删除帖子 -func (s *postServiceImpl) Delete(ctx context.Context, id string) error { - err := s.postRepo.Delete(id) +func (s *postServiceImpl) Delete(ctx context.Context, userID string, id string) error { + post, err := s.postRepo.GetByID(id) + if err != nil { + return err + } + if post.UserID != userID { + return apperrors.ErrForbidden + } + + err = s.postRepo.Delete(id) if err != nil { return err } - // 失效帖子详情缓存和列表缓存 cache.InvalidatePostDetail(s.cache, id) cache.InvalidatePostList(s.cache) diff --git a/internal/service/schedule_sync_service.go b/internal/service/schedule_sync_service.go index 9440190..df74d56 100644 --- a/internal/service/schedule_sync_service.go +++ b/internal/service/schedule_sync_service.go @@ -115,7 +115,10 @@ func (s *scheduleSyncService) convertCourses(data *pb.ScheduleResultData) []*mod for _, c := range data.Courses { for _, st := range c.Schedule { dayOfWeek := 0 - fmt.Sscanf(st.Day, "%d", &dayOfWeek) + if _, err := fmt.Sscanf(st.Day, "%d", &dayOfWeek); err != nil { + zap.L().Warn("invalid day format in schedule sync", zap.String("day", st.Day), zap.Error(err)) + dayOfWeek = 0 + } startSection := 1 endSection := 1 diff --git a/internal/service/sticker_service.go b/internal/service/sticker_service.go index d7729b0..c7ae253 100644 --- a/internal/service/sticker_service.go +++ b/internal/service/sticker_service.go @@ -1,7 +1,6 @@ package service import ( - "errors" "net/url" "strings" @@ -119,7 +118,7 @@ func (s *stickerService) DeleteSticker(userID string, stickerID string) error { return err } if sticker.UserID != userID { - return errors.New("sticker not found") + return apperrors.ErrForbidden } return s.stickerRepo.Delete(stickerID) diff --git a/internal/service/trade_service.go b/internal/service/trade_service.go index d337554..75074ca 100644 --- a/internal/service/trade_service.go +++ b/internal/service/trade_service.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + apperrors "with_you/internal/errors" "with_you/internal/dto" "with_you/internal/model" "with_you/internal/pkg/cursor" @@ -86,10 +87,10 @@ func (s *tradeService) GetByID(ctx context.Context, id string, currentUserID *st func (s *tradeService) Update(ctx context.Context, userID string, id string, req dto.UpdateTradeItemRequest) error { item, err := s.tradeRepo.GetByID(ctx, id) if err != nil { - return fmt.Errorf("trade item not found") + return apperrors.ErrNotFound } if item.UserID != userID { - return fmt.Errorf("not authorized") + return apperrors.ErrForbidden } if req.TradeType != nil { @@ -124,10 +125,10 @@ func (s *tradeService) Update(ctx context.Context, userID string, id string, req func (s *tradeService) Delete(ctx context.Context, userID string, id string) error { item, err := s.tradeRepo.GetByID(ctx, id) if err != nil { - return fmt.Errorf("trade item not found") + return apperrors.ErrNotFound } if item.UserID != userID { - return fmt.Errorf("not authorized") + return apperrors.ErrForbidden } return s.tradeRepo.Delete(ctx, id) } @@ -135,10 +136,10 @@ func (s *tradeService) Delete(ctx context.Context, userID string, id string) err func (s *tradeService) UpdateStatus(ctx context.Context, userID string, id string, status model.TradeItemStatus) error { item, err := s.tradeRepo.GetByID(ctx, id) if err != nil { - return fmt.Errorf("trade item not found") + return apperrors.ErrNotFound } if item.UserID != userID { - return fmt.Errorf("not authorized") + return apperrors.ErrForbidden } return s.tradeRepo.UpdateStatus(ctx, id, status) } diff --git a/internal/service/user_service.go b/internal/service/user_service.go index eed0b8b..97a5390 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "strconv" "strings" "time" @@ -591,19 +592,20 @@ func (s *userServiceImpl) IsBlockedBatch(ctx context.Context, blockerID string, // GetFollowingList 获取关注列表(字符串参数版本) func (s *userServiceImpl) GetFollowingList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) { - // 转换字符串参数为整数 pageInt := 1 pageSizeInt := 20 if page != "" { - _, err := fmt.Sscanf(page, "%d", &pageInt) - if err != nil { + if v, err := strconv.Atoi(page); err != nil || v < 1 { pageInt = 1 + } else { + pageInt = v } } if pageSize != "" { - _, err := fmt.Sscanf(pageSize, "%d", &pageSizeInt) - if err != nil { + if v, err := strconv.Atoi(pageSize); err != nil || v < 1 { pageSizeInt = 20 + } else { + pageSizeInt = v } } @@ -613,19 +615,20 @@ func (s *userServiceImpl) GetFollowingList(ctx context.Context, userID, page, pa // GetFollowersList 获取粉丝列表(字符串参数版本) func (s *userServiceImpl) GetFollowersList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) { - // 转换字符串参数为整数 pageInt := 1 pageSizeInt := 20 if page != "" { - _, err := fmt.Sscanf(page, "%d", &pageInt) - if err != nil { + if v, err := strconv.Atoi(page); err != nil || v < 1 { pageInt = 1 + } else { + pageInt = v } } if pageSize != "" { - _, err := fmt.Sscanf(pageSize, "%d", &pageSizeInt) - if err != nil { + if v, err := strconv.Atoi(pageSize); err != nil || v < 1 { pageSizeInt = 20 + } else { + pageSizeInt = v } } diff --git a/internal/service/vote_service.go b/internal/service/vote_service.go index 3d604fb..c8265e1 100644 --- a/internal/service/vote_service.go +++ b/internal/service/vote_service.go @@ -8,6 +8,7 @@ import ( "strings" "time" + apperrors "with_you/internal/errors" "with_you/internal/cache" "with_you/internal/dto" "with_you/internal/model" @@ -128,7 +129,16 @@ func (s *VoteService) reviewVotePostAsync(postID, userID, title, content string, } var authorID uint - fmt.Sscanf(userID, "%d", &authorID) + if _, err := fmt.Sscanf(userID, "%d", &authorID); err != nil { + log.Printf("[WARN] Invalid userID format in vote moderation: %q, err: %v", userID, err) + if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil { + log.Printf("[WARN] Failed to publish vote post %s after invalid userID: %v", postID, err) + } else { + cache.InvalidatePostDetail(s.cache, postID) + cache.InvalidatePostList(s.cache) + } + return + } result := &hook.ModerationResult{} s.hookManager.TriggerWithMetadata(context.Background(), hook.HookPostPreModerate, authorID, &hook.PostModerateHookData{ @@ -333,7 +343,7 @@ func (s *VoteService) UpdateVoteOption(ctx context.Context, postID, optionID, us // 验证用户是否为帖子作者 if post.UserID != userID { - return errors.New("只有帖子作者可以更新投票选项") + return apperrors.ErrForbidden } // 调用voteRepo.UpdateOption