fix: 보안 강화 및 안정성 개선
All checks were successful
Server CI/CD / deploy (push) Successful in 5s

- fileHash 빈 문자열 시 게임 업로드 거부 (A301.exe 누락 zip 차단)
- Rate limiting 추가: 인증 API 10req/min, 일반 API 60req/min
- 블록체인 트랜잭션 Idempotency-Key 미들웨어 (Redis 캐싱, 10분 TTL)
- 파일 업로드 크기 제한 4GB (BodyLimit)
- Username 대소문자 정규화 (Register/Login에서 소문자 변환)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 11:10:23 +09:00
parent 4843470310
commit 26876ba8ca
7 changed files with 123 additions and 21 deletions

2
go.mod
View File

@@ -29,11 +29,13 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/tinylib/msgp v1.2.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect

4
go.sum
View File

@@ -50,6 +50,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@@ -68,6 +70,8 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=

View File

@@ -1,6 +1,10 @@
package auth
import "github.com/gofiber/fiber/v2"
import (
"strings"
"github.com/gofiber/fiber/v2"
)
type Handler struct {
svc *Service
@@ -18,6 +22,7 @@ func (h *Handler) Register(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
req.Username = strings.ToLower(strings.TrimSpace(req.Username))
if req.Username == "" || req.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"})
}
@@ -38,6 +43,7 @@ func (h *Handler) Login(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
req.Username = strings.ToLower(strings.TrimSpace(req.Username))
if req.Username == "" || req.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"})
}

View File

@@ -116,6 +116,10 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info
}
fileHash := hashGameExeFromZip(finalPath)
if fileHash == "" {
os.Remove(finalPath)
return nil, fmt.Errorf("zip 파일에 %s이(가) 포함되어 있지 않습니다", "A301.exe")
}
info, err := s.repo.GetLatest()
if err != nil {

30
main.go
View File

@@ -10,8 +10,11 @@ import (
"a301_server/pkg/config"
"a301_server/pkg/database"
"a301_server/routes"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gofiber/fiber/v2/middleware/logger"
)
@@ -81,6 +84,7 @@ func main() {
app := fiber.New(fiber.Config{
StreamRequestBody: true,
BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB
})
app.Use(logger.New())
app.Use(cors.New(cors.Config{
@@ -89,7 +93,31 @@ func main() {
AllowMethods: "GET, POST, PUT, PATCH, DELETE",
}))
routes.Register(app, authHandler, annHandler, dlHandler, chainHandler)
// Rate limiting: 인증 관련 엔드포인트 (로그인/회원가입/리프레시)
authLimiter := limiter.New(limiter.Config{
Max: 10,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "요청이 너무 많습니다. 잠시 후 다시 시도해주세요"})
},
})
// Rate limiting: 일반 API
apiLimiter := limiter.New(limiter.Config{
Max: 60,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "요청이 너무 많습니다. 잠시 후 다시 시도해주세요"})
},
})
routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, authLimiter, apiLimiter)
log.Fatal(app.Listen(":" + config.C.AppPort))
}

View File

@@ -0,0 +1,56 @@
package middleware
import (
"context"
"encoding/json"
"time"
"a301_server/pkg/database"
"github.com/gofiber/fiber/v2"
)
const idempotencyTTL = 10 * time.Minute
type cachedResponse struct {
StatusCode int `json:"s"`
Body json.RawMessage `json:"b"`
}
// Idempotency checks the Idempotency-Key header to prevent duplicate transactions.
// If the same key is seen again within the TTL, the cached response is returned.
func Idempotency(c *fiber.Ctx) error {
key := c.Get("Idempotency-Key")
if key == "" {
return c.Next()
}
redisKey := "idempotency:" + key
ctx := context.Background()
// Check if this key was already processed
cached, err := database.RDB.Get(ctx, redisKey).Bytes()
if err == nil && len(cached) > 0 {
var cr cachedResponse
if json.Unmarshal(cached, &cr) == nil {
c.Set("Content-Type", "application/json")
c.Set("X-Idempotent-Replay", "true")
return c.Status(cr.StatusCode).Send(cr.Body)
}
}
// Process the request
if err := c.Next(); err != nil {
return err
}
// Cache successful responses (2xx)
status := c.Response().StatusCode()
if status >= 200 && status < 300 {
cr := cachedResponse{StatusCode: status, Body: c.Response().Body()}
if data, err := json.Marshal(cr); err == nil {
database.RDB.Set(ctx, redisKey, data, idempotencyTTL)
}
}
return nil
}

View File

@@ -15,14 +15,16 @@ func Register(
annH *announcement.Handler,
dlH *download.Handler,
chainH *chain.Handler,
authLimiter fiber.Handler,
apiLimiter fiber.Handler,
) {
api := app.Group("/api")
api := app.Group("/api", apiLimiter)
// Auth
a := api.Group("/auth")
a.Post("/register", authH.Register)
a.Post("/login", authH.Login)
a.Post("/refresh", authH.Refresh)
a.Post("/register", authLimiter, authH.Register)
a.Post("/login", authLimiter, authH.Login)
a.Post("/refresh", authLimiter, authH.Refresh)
a.Post("/logout", middleware.Auth, authH.Logout)
a.Post("/verify", authH.VerifyToken)
@@ -57,25 +59,25 @@ func Register(
ch.Get("/market", chainH.GetMarketListings)
ch.Get("/market/:id", chainH.GetMarketListing)
// Chain - User Transactions (authenticated)
ch.Post("/transfer", chainH.Transfer)
ch.Post("/asset/transfer", chainH.TransferAsset)
ch.Post("/market/list", chainH.ListOnMarket)
ch.Post("/market/buy", chainH.BuyFromMarket)
ch.Post("/market/cancel", chainH.CancelListing)
ch.Post("/inventory/equip", chainH.EquipItem)
ch.Post("/inventory/unequip", chainH.UnequipItem)
// Chain - User Transactions (authenticated, idempotency-protected)
ch.Post("/transfer", middleware.Idempotency, chainH.Transfer)
ch.Post("/asset/transfer", middleware.Idempotency, chainH.TransferAsset)
ch.Post("/market/list", middleware.Idempotency, chainH.ListOnMarket)
ch.Post("/market/buy", middleware.Idempotency, chainH.BuyFromMarket)
ch.Post("/market/cancel", middleware.Idempotency, chainH.CancelListing)
ch.Post("/inventory/equip", middleware.Idempotency, chainH.EquipItem)
ch.Post("/inventory/unequip", middleware.Idempotency, chainH.UnequipItem)
// Chain - Admin Transactions (admin only)
// Chain - Admin Transactions (admin only, idempotency-protected)
chainAdmin := api.Group("/chain/admin", middleware.Auth, middleware.AdminOnly)
chainAdmin.Post("/mint", chainH.MintAsset)
chainAdmin.Post("/reward", chainH.GrantReward)
chainAdmin.Post("/template", chainH.RegisterTemplate)
chainAdmin.Post("/mint", middleware.Idempotency, chainH.MintAsset)
chainAdmin.Post("/reward", middleware.Idempotency, chainH.GrantReward)
chainAdmin.Post("/template", middleware.Idempotency, chainH.RegisterTemplate)
// Internal - Game server endpoints (API key auth, username-based)
// Internal - Game server endpoints (API key auth, username-based, idempotency-protected)
internal := api.Group("/internal/chain", middleware.ServerAuth)
internal.Post("/reward", chainH.InternalGrantReward)
internal.Post("/mint", chainH.InternalMintAsset)
internal.Post("/reward", middleware.Idempotency, chainH.InternalGrantReward)
internal.Post("/mint", middleware.Idempotency, chainH.InternalMintAsset)
internal.Get("/balance", chainH.InternalGetBalance)
internal.Get("/assets", chainH.InternalGetAssets)
internal.Get("/inventory", chainH.InternalGetInventory)