feat: enhance security with IP banning, ownership checks, and SSRF protection
All checks were successful
Build Backend / build (push) Successful in 1m56s
Build Backend / build-docker (push) Successful in 1m15s

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:
lafay
2026-04-30 12:26:25 +08:00
parent 67a5660952
commit b2b55ea52d
24 changed files with 517 additions and 80 deletions

View File

@@ -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
}

View File

@@ -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("图片必须使用本站上传接口生成的资源地址")

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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