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
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("图片必须使用本站上传接口生成的资源地址")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user