From cc751653c4cfea2a026d2fbb757e40d604f849bc Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Fri, 13 Mar 2026 17:48:05 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=20=EB=B3=B4=EC=95=88=C2=B7=EC=95=88?= =?UTF-8?q?=EC=A0=95=EC=84=B1=20=EA=B0=9C=EC=84=A0=202=EC=B0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 보안: - RPC 응답 HTTP 상태코드 검증 (chain/client) - SSAFY OAuth 에러 응답 내부 로깅으로 변경 (제3자 상세 노출 제거) - resolveUsername에서 username 노출 제거 - LIKE 쿼리 특수문자 이스케이프 (bossraid/repository) - 파일명 경로 순회 방지 + 길이 제한 (download/handler) - ServerAuth 실패 로깅 추가 안정성: - AutoMigrate 에러 시 서버 종료 - GetLatest() 에러 시 nil 반환 (초기화 안 된 포인터 방지) - 멱등성 캐시 저장 시 새 context 사용 - SSAFY HTTP 클라이언트 타임아웃 10s - io.ReadAll/rand.Read 에러 처리 - Login에서 DB 에러/Not Found 구분 검증 강화: - 중복 플레이어 검증 (bossraid/service) - username 길이 제한 50자 (auth/handler, bossraid/handler) - 역할 변경 시 세션 무효화 - 지갑 복호화 실패 로깅 Co-Authored-By: Claude Opus 4.6 --- internal/auth/handler.go | 8 ++++++- internal/auth/service.go | 38 +++++++++++++++++++++++++-------- internal/bossraid/handler.go | 5 +++++ internal/bossraid/repository.go | 10 +++++++-- internal/bossraid/service.go | 9 ++++++++ internal/chain/client.go | 4 ++++ internal/chain/service.go | 8 ++++--- internal/download/handler.go | 6 ++++++ internal/download/repository.go | 6 ++++-- main.go | 4 +++- pkg/middleware/auth.go | 2 ++ pkg/middleware/idempotency.go | 4 +++- 12 files changed, 85 insertions(+), 19 deletions(-) diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 20aac01..9fdda2d 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -27,6 +27,9 @@ func (h *Handler) Register(c *fiber.Ctx) error { if req.Username == "" || req.Password == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"}) } + if len(req.Username) > 50 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디는 50자 이하여야 합니다"}) + } if len(req.Password) < 6 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "비밀번호는 6자 이상이어야 합니다"}) } @@ -111,9 +114,12 @@ func (h *Handler) UpdateRole(c *fiber.Ctx) error { if err := c.BodyParser(&body); err != nil || (body.Role != "admin" && body.Role != "user") { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "role은 admin 또는 user여야 합니다"}) } - if err := h.svc.UpdateRole(uint(id), Role(body.Role)); err != nil { + uid := uint(id) + if err := h.svc.UpdateRole(uid, Role(body.Role)); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "권한 변경에 실패했습니다"}) } + // 역할 변경 시 기존 세션 무효화 (새 권한으로 재로그인 유도) + _ = h.svc.Logout(uid) return c.JSON(fiber.Map{"message": "권한이 변경되었습니다"}) } diff --git a/internal/auth/service.go b/internal/auth/service.go index e8ace8e..38a02f3 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -13,6 +13,8 @@ import ( "strings" "time" + "gorm.io/gorm" + "a301_server/pkg/config" "github.com/golang-jwt/jwt/v5" "github.com/redis/go-redis/v9" @@ -21,6 +23,8 @@ import ( const refreshTokenExpiry = 7 * 24 * time.Hour +var ssafyHTTPClient = &http.Client{Timeout: 10 * time.Second} + type Claims struct { UserID uint `json:"user_id"` Username string `json:"username"` @@ -45,7 +49,10 @@ func (s *Service) SetWalletCreator(fn func(userID uint) error) { func (s *Service) Login(username, password string) (accessToken, refreshToken string, user *User, err error) { user, err = s.repo.FindByUsername(username) if err != nil { - return "", "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다") + if err == gorm.ErrRecordNotFound { + return "", "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다") + } + return "", "", nil, fmt.Errorf("로그인 처리 중 오류가 발생했습니다") } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { @@ -221,7 +228,7 @@ func (s *Service) ExchangeSSAFYCode(code string) (*SSAFYTokenResponse, error) { "code": {code}, } - resp, err := http.Post( + resp, err := ssafyHTTPClient.Post( "https://project.ssafy.com/ssafy/oauth2/token", "application/x-www-form-urlencoded;charset=utf-8", strings.NewReader(data.Encode()), @@ -231,9 +238,13 @@ func (s *Service) ExchangeSSAFYCode(code string) (*SSAFYTokenResponse, error) { } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("SSAFY 토큰 응답 읽기 실패: %v", err) + } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("SSAFY 토큰 발급 실패 (status %d): %s", resp.StatusCode, string(body)) + log.Printf("SSAFY 토큰 발급 실패 (status %d): %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("SSAFY 인증에 실패했습니다") } var tokenResp SSAFYTokenResponse @@ -245,19 +256,26 @@ func (s *Service) ExchangeSSAFYCode(code string) (*SSAFYTokenResponse, error) { // GetSSAFYUserInfo fetches user info from SSAFY using an access token. func (s *Service) GetSSAFYUserInfo(accessToken string) (*SSAFYUserInfo, error) { - req, _ := http.NewRequest("GET", "https://project.ssafy.com/ssafy/resources/userInfo", nil) + req, err := http.NewRequest("GET", "https://project.ssafy.com/ssafy/resources/userInfo", nil) + if err != nil { + return nil, fmt.Errorf("SSAFY 사용자 정보 요청 생성 실패: %v", err) + } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-type", "application/x-www-form-urlencoded;charset=utf-8") - resp, err := http.DefaultClient.Do(req) + resp, err := ssafyHTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("SSAFY 사용자 정보 요청 실패: %v", err) } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("SSAFY 사용자 정보 응답 읽기 실패: %v", err) + } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("SSAFY 사용자 정보 조회 실패 (status %d): %s", resp.StatusCode, string(body)) + log.Printf("SSAFY 사용자 정보 조회 실패 (status %d): %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("SSAFY 사용자 정보를 가져올 수 없습니다") } var userInfo SSAFYUserInfo @@ -284,7 +302,9 @@ func (s *Service) SSAFYLogin(code string) (accessToken, refreshToken string, use if err != nil { // 신규 사용자 자동 가입 randomBytes := make([]byte, 16) - rand.Read(randomBytes) + if _, err := rand.Read(randomBytes); err != nil { + return "", "", nil, fmt.Errorf("보안 난수 생성 실패: %v", err) + } randomPassword := hex.EncodeToString(randomBytes) hash, err := bcrypt.GenerateFromPassword([]byte(randomPassword), bcrypt.DefaultCost) diff --git a/internal/bossraid/handler.go b/internal/bossraid/handler.go index 34a81e2..dd468be 100644 --- a/internal/bossraid/handler.go +++ b/internal/bossraid/handler.go @@ -32,6 +32,11 @@ func (h *Handler) RequestEntry(c *fiber.Ctx) error { if len(req.Usernames) == 0 || req.BossID <= 0 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "usernames와 bossId는 필수입니다"}) } + for _, u := range req.Usernames { + if len(u) == 0 || len(u) > 50 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효하지 않은 username입니다"}) + } + } room, err := h.svc.RequestEntry(req.Usernames, req.BossID) if err != nil { diff --git a/internal/bossraid/repository.go b/internal/bossraid/repository.go index 3b788f7..4928718 100644 --- a/internal/bossraid/repository.go +++ b/internal/bossraid/repository.go @@ -1,6 +1,10 @@ package bossraid -import "gorm.io/gorm" +import ( + "strings" + + "gorm.io/gorm" +) type Repository struct { db *gorm.DB @@ -29,7 +33,9 @@ func (r *Repository) FindBySessionName(sessionName string) (*BossRoom, error) { // CountActiveByUsername checks if a player is already in an active boss raid. func (r *Repository) CountActiveByUsername(username string) (int64, error) { var count int64 - search := `"` + username + `"` + // LIKE 특수문자 이스케이프 + escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(username) + search := `"` + escaped + `"` err := r.db.Model(&BossRoom{}). Where("status IN ? AND players LIKE ?", []RoomStatus{StatusWaiting, StatusInProgress}, diff --git a/internal/bossraid/service.go b/internal/bossraid/service.go index 25a7f94..750b082 100644 --- a/internal/bossraid/service.go +++ b/internal/bossraid/service.go @@ -33,6 +33,15 @@ func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error return nil, fmt.Errorf("최대 3명까지 입장할 수 있습니다") } + // 중복 플레이어 검증 + seen := make(map[string]bool, len(usernames)) + for _, u := range usernames { + if seen[u] { + return nil, fmt.Errorf("중복된 플레이어가 있습니다: %s", u) + } + seen[u] = true + } + // Check if any player is already in an active room for _, username := range usernames { count, err := s.repo.CountActiveByUsername(username) diff --git a/internal/chain/client.go b/internal/chain/client.go index 898988f..8f7b616 100644 --- a/internal/chain/client.go +++ b/internal/chain/client.go @@ -66,6 +66,10 @@ func (c *Client) Call(method string, params any, out any) error { } defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("RPC HTTP error: status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read RPC response: %w", err) diff --git a/internal/chain/service.go b/internal/chain/service.go index d70fbc5..45d332d 100644 --- a/internal/chain/service.go +++ b/internal/chain/service.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "log" "github.com/tolelom/tolchain/core" tocrypto "github.com/tolelom/tolchain/crypto" @@ -35,11 +36,11 @@ func (s *Service) resolveUsername(username string) (string, error) { } userID, err := s.userResolver(username) if err != nil { - return "", fmt.Errorf("user not found: %s", username) + return "", fmt.Errorf("user not found") } uw, err := s.repo.FindByUserID(userID) if err != nil { - return "", fmt.Errorf("wallet not found for user: %s", username) + return "", fmt.Errorf("wallet not found") } return uw.PubKeyHex, nil } @@ -156,7 +157,8 @@ func (s *Service) loadUserWallet(userID uint) (*wallet.Wallet, string, error) { } privKey, err := s.decryptPrivKey(uw.EncryptedPrivKey, uw.EncNonce) if err != nil { - return nil, "", err + log.Printf("WARNING: wallet decryption failed for userID=%d: %v", userID, err) + return nil, "", fmt.Errorf("wallet decryption failed") } return wallet.New(privKey), uw.PubKeyHex, nil } diff --git a/internal/download/handler.go b/internal/download/handler.go index 89b3744..f687b82 100644 --- a/internal/download/handler.go +++ b/internal/download/handler.go @@ -3,6 +3,7 @@ package download import ( "mime" "os" + "path/filepath" "strings" "github.com/gofiber/fiber/v2" @@ -29,9 +30,14 @@ func (h *Handler) GetInfo(c *fiber.Ctx) error { // The filename is passed as a query parameter: ?filename=A301_v1.0.zip func (h *Handler) Upload(c *fiber.Ctx) error { filename := strings.TrimSpace(c.Query("filename", "game.zip")) + // 경로 순회 방지: 디렉토리 구분자 제거, 기본 파일명만 사용 + filename = filepath.Base(filename) if !strings.HasSuffix(strings.ToLower(filename), ".zip") { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zip 파일만 업로드 가능합니다"}) } + if len(filename) > 200 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "파일명이 너무 깁니다"}) + } body := c.Request().BodyStream() info, err := h.svc.Upload(filename, body, h.baseURL) diff --git a/internal/download/repository.go b/internal/download/repository.go index 419539b..ea73eae 100644 --- a/internal/download/repository.go +++ b/internal/download/repository.go @@ -12,8 +12,10 @@ func NewRepository(db *gorm.DB) *Repository { func (r *Repository) GetLatest() (*Info, error) { var info Info - err := r.db.Last(&info).Error - return &info, err + if err := r.db.Last(&info).Error; err != nil { + return nil, err + } + return &info, nil } func (r *Repository) Save(info *Info) error { diff --git a/main.go b/main.go index 1522365..d9e3698 100644 --- a/main.go +++ b/main.go @@ -30,7 +30,9 @@ func main() { log.Println("MySQL 연결 성공") // AutoMigrate - database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}, &bossraid.BossRoom{}) + if err := database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}, &bossraid.BossRoom{}); err != nil { + log.Fatalf("AutoMigrate 실패: %v", err) + } if err := database.ConnectRedis(); err != nil { log.Fatalf("Redis 연결 실패: %v", err) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 4625773..c27b38d 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -3,6 +3,7 @@ package middleware import ( "context" "fmt" + "log" "strings" "a301_server/pkg/config" @@ -72,6 +73,7 @@ func AdminOnly(c *fiber.Ctx) error { func ServerAuth(c *fiber.Ctx) error { key := c.Get("X-API-Key") if key == "" || config.C.InternalAPIKey == "" || key != config.C.InternalAPIKey { + log.Printf("ServerAuth 실패: IP=%s, Path=%s, KeyPresent=%v", c.IP(), c.Path(), key != "") return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 API 키입니다"}) } return c.Next() diff --git a/pkg/middleware/idempotency.go b/pkg/middleware/idempotency.go index 93d5fcb..8be6570 100644 --- a/pkg/middleware/idempotency.go +++ b/pkg/middleware/idempotency.go @@ -58,7 +58,9 @@ func Idempotency(c *fiber.Ctx) error { if status >= 200 && status < 300 { cr := cachedResponse{StatusCode: status, Body: c.Response().Body()} if data, err := json.Marshal(cr); err == nil { - if err := database.RDB.Set(ctx, redisKey, data, idempotencyTTL).Err(); err != nil { + writeCtx, writeCancel := context.WithTimeout(context.Background(), redisTimeout) + defer writeCancel() + if err := database.RDB.Set(writeCtx, redisKey, data, idempotencyTTL).Err(); err != nil { log.Printf("WARNING: idempotency cache write failed (key=%s): %v", key, err) } }