fix: 보안·안정성·동시성 개선 3차
All checks were successful
Server CI/CD / deploy (push) Successful in 1m31s

- 입력 검증 강화 (로그인/체인 핸들러 전체)
- boss raid 비관적 잠금으로 동시성 문제 해결
- SSAFY 사용자명 sanitize + 트랜잭션 처리
- constant-time API 키 비교, 보안 헤더, graceful shutdown
- 안전하지 않은 기본값 경고 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 21:40:06 +09:00
parent cc751653c4
commit d597ef2d46
11 changed files with 247 additions and 97 deletions

View File

@@ -1,12 +1,16 @@
package auth
import (
"regexp"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
)
// usernameRe allows alphanumeric, underscore, hyphen (3-50 chars).
var usernameRe = regexp.MustCompile(`^[a-z0-9_-]{3,50}$`)
type Handler struct {
svc *Service
}
@@ -27,12 +31,15 @@ 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 !usernameRe.MatchString(req.Username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디는 3~50자의 영문 소문자, 숫자, _, -만 사용 가능합니다"})
}
if len(req.Password) < 6 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "비밀번호는 6자 이상이어야 합니다"})
}
if len(req.Password) > 72 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "비밀번호는 72자 이하여야 합니다"})
}
if err := h.svc.Register(req.Username, req.Password); err != nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()})
}
@@ -51,6 +58,12 @@ func (h *Handler) Login(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": "아이디 또는 비밀번호가 올바르지 않습니다"})
}
if len(req.Password) > 72 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디 또는 비밀번호가 올바르지 않습니다"})
}
accessToken, refreshToken, user, err := h.svc.Login(req.Username, req.Password)
if err != nil {

View File

@@ -44,6 +44,13 @@ func (r *Repository) Delete(id uint) error {
return r.db.Delete(&User{}, id).Error
}
// Transaction wraps a function in a database transaction.
func (r *Repository) Transaction(fn func(txRepo *Repository) error) error {
return r.db.Transaction(func(tx *gorm.DB) error {
return fn(&Repository{db: tx})
})
}
func (r *Repository) FindBySsafyID(ssafyID string) (*User, error) {
var user User
if err := r.db.Where("ssafy_id = ?", ssafyID).First(&user).Error; err != nil {

View File

@@ -313,21 +313,36 @@ func (s *Service) SSAFYLogin(code string) (accessToken, refreshToken string, use
}
ssafyID := userInfo.UserID
username := "ssafy_" + ssafyID
// SSAFY ID에서 영문 소문자+숫자만 추출하여 안전한 username 생성
safeID := sanitizeForUsername(ssafyID)
if safeID == "" {
safeID = hex.EncodeToString(randomBytes[:8])
}
username := "ssafy_" + safeID
if len(username) > 50 {
username = username[:50]
}
var newUserID uint
err = s.repo.Transaction(func(txRepo *Repository) error {
user = &User{
Username: username,
PasswordHash: string(hash),
Role: RoleUser,
SsafyID: &ssafyID,
}
if err := s.repo.Create(user); err != nil {
return txRepo.Create(user)
})
if err != nil {
return "", "", nil, fmt.Errorf("계정 생성 실패: %v", err)
}
newUserID = user.ID
if s.walletCreator != nil {
if err := s.walletCreator(user.ID); err != nil {
log.Printf("wallet creation failed for SSAFY user %d: %v — rolling back", user.ID, err)
if delErr := s.repo.Delete(user.ID); delErr != nil {
log.Printf("WARNING: rollback delete also failed for SSAFY user %d: %v", user.ID, delErr)
if err := s.walletCreator(newUserID); err != nil {
log.Printf("wallet creation failed for SSAFY user %d: %v — rolling back", newUserID, err)
if delErr := s.repo.Delete(newUserID); delErr != nil {
log.Printf("WARNING: rollback delete also failed for SSAFY user %d: %v", newUserID, delErr)
}
return "", "", nil, fmt.Errorf("계정 초기화에 실패했습니다. 잠시 후 다시 시도해주세요")
}
@@ -373,6 +388,17 @@ func (s *Service) VerifyToken(tokenStr string) (string, error) {
return claims.Username, nil
}
// sanitizeForUsername strips characters that are not [a-z0-9_-].
func sanitizeForUsername(s string) string {
var b strings.Builder
for _, c := range strings.ToLower(s) {
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-' {
b.WriteRune(c)
}
}
return b.String()
}
func (s *Service) EnsureAdmin(username, password string) error {
if _, err := s.repo.FindByUsername(username); err == nil {
return nil

View File

@@ -4,6 +4,7 @@ import (
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Repository struct {
@@ -30,6 +31,23 @@ func (r *Repository) FindBySessionName(sessionName string) (*BossRoom, error) {
return &room, nil
}
// FindBySessionNameForUpdate acquires a row-level lock (SELECT ... FOR UPDATE)
// to prevent concurrent state transitions.
func (r *Repository) FindBySessionNameForUpdate(sessionName string) (*BossRoom, error) {
var room BossRoom
if err := r.db.Clauses(clause.Locking{Strength: "UPDATE"}).Where("session_name = ?", sessionName).First(&room).Error; err != nil {
return nil, err
}
return &room, nil
}
// Transaction wraps a function in a database transaction.
func (r *Repository) Transaction(fn func(txRepo *Repository) error) error {
return r.db.Transaction(func(tx *gorm.DB) error {
return fn(&Repository{db: tx})
})
}
// CountActiveByUsername checks if a player is already in an active boss raid.
func (r *Repository) CountActiveByUsername(username string) (int64, error) {
var count int64

View File

@@ -76,24 +76,32 @@ func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error
}
// StartRaid marks a room as in_progress.
// Uses row-level locking to prevent concurrent state transitions.
func (s *Service) StartRaid(sessionName string) (*BossRoom, error) {
room, err := s.repo.FindBySessionName(sessionName)
var resultRoom *BossRoom
err := s.repo.Transaction(func(txRepo *Repository) error {
room, err := txRepo.FindBySessionNameForUpdate(sessionName)
if err != nil {
return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
return fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
if room.Status != StatusWaiting {
return nil, fmt.Errorf("시작할 수 없는 상태입니다: %s", room.Status)
return fmt.Errorf("시작할 수 없는 상태입니다: %s", room.Status)
}
now := time.Now()
room.Status = StatusInProgress
room.StartedAt = &now
if err := s.repo.Update(room); err != nil {
return nil, fmt.Errorf("상태 업데이트 실패: %w", err)
if err := txRepo.Update(room); err != nil {
return fmt.Errorf("상태 업데이트 실패: %w", err)
}
return room, nil
resultRoom = room
return nil
})
if err != nil {
return nil, err
}
return resultRoom, nil
}
// PlayerReward describes the reward for a single player.
@@ -111,19 +119,24 @@ type RewardResult struct {
}
// CompleteRaid marks a room as completed and grants rewards via blockchain.
// Uses a database transaction with row-level locking to prevent double-completion.
func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*BossRoom, []RewardResult, error) {
room, err := s.repo.FindBySessionName(sessionName)
var resultRoom *BossRoom
var resultRewards []RewardResult
err := s.repo.Transaction(func(txRepo *Repository) error {
room, err := txRepo.FindBySessionNameForUpdate(sessionName)
if err != nil {
return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
return fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
if room.Status != StatusInProgress {
return nil, nil, fmt.Errorf("완료할 수 없는 상태입니다: %s", room.Status)
return fmt.Errorf("완료할 수 없는 상태입니다: %s", room.Status)
}
// Validate reward recipients are room players
var players []string
if err := json.Unmarshal([]byte(room.Players), &players); err != nil {
return nil, nil, fmt.Errorf("플레이어 목록 파싱 실패: %w", err)
return fmt.Errorf("플레이어 목록 파싱 실패: %w", err)
}
playerSet := make(map[string]bool, len(players))
for _, p := range players {
@@ -131,7 +144,7 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos
}
for _, r := range rewards {
if !playerSet[r.Username] {
return nil, nil, fmt.Errorf("보상 대상 %s가 방의 플레이어가 아닙니다", r.Username)
return fmt.Errorf("보상 대상 %s가 방의 플레이어가 아닙니다", r.Username)
}
}
@@ -139,12 +152,19 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos
now := time.Now()
room.Status = StatusCompleted
room.CompletedAt = &now
if err := s.repo.Update(room); err != nil {
return nil, nil, fmt.Errorf("상태 업데이트 실패: %w", err)
if err := txRepo.Update(room); err != nil {
return fmt.Errorf("상태 업데이트 실패: %w", err)
}
// Grant rewards
results := make([]RewardResult, 0, len(rewards))
resultRoom = room
return nil
})
if err != nil {
return nil, nil, err
}
// Grant rewards outside the transaction to avoid holding the lock during RPC calls
resultRewards = make([]RewardResult, 0, len(rewards))
if s.rewardGrant != nil {
for _, r := range rewards {
grantErr := s.rewardGrant(r.Username, r.TokenAmount, r.Assets)
@@ -153,32 +173,40 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos
result.Error = grantErr.Error()
log.Printf("보상 지급 실패: %s: %v", r.Username, grantErr)
}
results = append(results, result)
resultRewards = append(resultRewards, result)
}
}
return room, results, nil
return resultRoom, resultRewards, nil
}
// FailRaid marks a room as failed.
// Uses row-level locking to prevent concurrent state transitions.
func (s *Service) FailRaid(sessionName string) (*BossRoom, error) {
room, err := s.repo.FindBySessionName(sessionName)
var resultRoom *BossRoom
err := s.repo.Transaction(func(txRepo *Repository) error {
room, err := txRepo.FindBySessionNameForUpdate(sessionName)
if err != nil {
return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
return fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
if room.Status != StatusWaiting && room.Status != StatusInProgress {
return nil, fmt.Errorf("실패 처리할 수 없는 상태입니다: %s", room.Status)
return fmt.Errorf("실패 처리할 수 없는 상태입니다: %s", room.Status)
}
now := time.Now()
room.Status = StatusFailed
room.CompletedAt = &now
if err := s.repo.Update(room); err != nil {
return nil, fmt.Errorf("상태 업데이트 실패: %w", err)
if err := txRepo.Update(room); err != nil {
return fmt.Errorf("상태 업데이트 실패: %w", err)
}
return room, nil
resultRoom = room
return nil
})
if err != nil {
return nil, err
}
return resultRoom, nil
}
// GetRoom returns a room by session name.

View File

@@ -9,6 +9,7 @@ import (
)
const maxLimit = 200
const maxIDLength = 256 // max length for string IDs (assetId, listingId, etc.)
type Handler struct {
svc *Service
@@ -40,6 +41,10 @@ func parsePagination(c *fiber.Ctx) (int, int) {
return offset, limit
}
func validID(s string) bool {
return s != "" && len(s) <= maxIDLength
}
func chainError(c *fiber.Ctx, status int, userMsg string, err error) error {
log.Printf("chain error: %s: %v", userMsg, err)
return c.Status(status).JSON(fiber.Map{"error": userMsg})
@@ -90,8 +95,8 @@ func (h *Handler) GetAssets(c *fiber.Ctx) error {
func (h *Handler) GetAsset(c *fiber.Ctx) error {
assetID := c.Params("id")
if assetID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "asset id is required"})
if !validID(assetID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효한 asset id가 필요합니다"})
}
result, err := h.svc.GetAsset(assetID)
if err != nil {
@@ -126,8 +131,8 @@ func (h *Handler) GetMarketListings(c *fiber.Ctx) error {
func (h *Handler) GetMarketListing(c *fiber.Ctx) error {
listingID := c.Params("id")
if listingID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listing id is required"})
if !validID(listingID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효한 listing id가 필요합니다"})
}
result, err := h.svc.GetListing(listingID)
if err != nil {
@@ -151,7 +156,7 @@ func (h *Handler) Transfer(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.To == "" || req.Amount == 0 {
if !validID(req.To) || req.Amount == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "to와 amount는 필수입니다"})
}
result, err := h.svc.Transfer(userID, req.To, req.Amount)
@@ -173,7 +178,7 @@ func (h *Handler) TransferAsset(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.AssetID == "" || req.To == "" {
if !validID(req.AssetID) || !validID(req.To) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 to는 필수입니다"})
}
result, err := h.svc.TransferAsset(userID, req.AssetID, req.To)
@@ -195,7 +200,7 @@ func (h *Handler) ListOnMarket(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.AssetID == "" || req.Price == 0 {
if !validID(req.AssetID) || req.Price == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 price는 필수입니다"})
}
result, err := h.svc.ListOnMarket(userID, req.AssetID, req.Price)
@@ -216,7 +221,7 @@ func (h *Handler) BuyFromMarket(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.ListingID == "" {
if !validID(req.ListingID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"})
}
result, err := h.svc.BuyFromMarket(userID, req.ListingID)
@@ -237,7 +242,7 @@ func (h *Handler) CancelListing(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.ListingID == "" {
if !validID(req.ListingID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"})
}
result, err := h.svc.CancelListing(userID, req.ListingID)
@@ -259,7 +264,7 @@ func (h *Handler) EquipItem(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.AssetID == "" || req.Slot == "" {
if !validID(req.AssetID) || !validID(req.Slot) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 slot은 필수입니다"})
}
result, err := h.svc.EquipItem(userID, req.AssetID, req.Slot)
@@ -280,7 +285,7 @@ func (h *Handler) UnequipItem(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.AssetID == "" {
if !validID(req.AssetID) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId는 필수입니다"})
}
result, err := h.svc.UnequipItem(userID, req.AssetID)
@@ -301,7 +306,7 @@ func (h *Handler) MintAsset(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.TemplateID == "" || req.OwnerPubKey == "" {
if !validID(req.TemplateID) || !validID(req.OwnerPubKey) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 ownerPubKey는 필수입니다"})
}
result, err := h.svc.MintAsset(req.TemplateID, req.OwnerPubKey, req.Properties)
@@ -320,7 +325,7 @@ func (h *Handler) GrantReward(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.RecipientPubKey == "" {
if !validID(req.RecipientPubKey) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "recipientPubKey는 필수입니다"})
}
result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets)
@@ -340,7 +345,7 @@ func (h *Handler) RegisterTemplate(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.ID == "" || req.Name == "" {
if !validID(req.ID) || !validID(req.Name) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id와 name은 필수입니다"})
}
result, err := h.svc.RegisterTemplate(req.ID, req.Name, req.Schema, req.Tradeable)
@@ -362,7 +367,7 @@ func (h *Handler) InternalGrantReward(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.Username == "" {
if !validID(req.Username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
}
result, err := h.svc.GrantRewardByUsername(req.Username, req.TokenAmount, req.Assets)
@@ -382,7 +387,7 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.TemplateID == "" || req.Username == "" {
if !validID(req.TemplateID) || !validID(req.Username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 username은 필수입니다"})
}
result, err := h.svc.MintAssetByUsername(req.TemplateID, req.Username, req.Properties)
@@ -395,7 +400,7 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error {
// InternalGetBalance returns balance by username. For game server use.
func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
username := c.Query("username")
if username == "" {
if !validID(username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
}
result, err := h.svc.GetBalanceByUsername(username)
@@ -408,7 +413,7 @@ func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
// InternalGetAssets returns assets by username. For game server use.
func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
username := c.Query("username")
if username == "" {
if !validID(username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
}
offset, limit := parsePagination(c)
@@ -423,7 +428,7 @@ func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
// InternalGetInventory returns inventory by username. For game server use.
func (h *Handler) InternalGetInventory(c *fiber.Ctx) error {
username := c.Query("username")
if username == "" {
if !validID(username) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
}
result, err := h.svc.GetInventoryByUsername(username)

View File

@@ -1,6 +1,7 @@
package download
import (
"log"
"mime"
"os"
"path/filepath"
@@ -42,7 +43,8 @@ func (h *Handler) Upload(c *fiber.Ctx) error {
body := c.Request().BodyStream()
info, err := h.svc.Upload(filename, body, h.baseURL)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "업로드 실패: " + err.Error()})
log.Printf("game upload failed: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "게임 파일 업로드에 실패했습니다"})
}
return c.JSON(info)
}
@@ -65,7 +67,8 @@ func (h *Handler) UploadLauncher(c *fiber.Ctx) error {
body := c.Request().BodyStream()
info, err := h.svc.UploadLauncher(body, h.baseURL)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "업로드 실패: " + err.Error()})
log.Printf("launcher upload failed: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "런처 업로드에 실패했습니다"})
}
return c.JSON(info)
}

19
main.go
View File

@@ -2,6 +2,10 @@ package main
import (
"log"
"os"
"os/signal"
"syscall"
"time"
"a301_server/internal/announcement"
"a301_server/internal/auth"
@@ -12,8 +16,8 @@ import (
"github.com/tolelom/tolchain/core"
"a301_server/pkg/config"
"a301_server/pkg/database"
"a301_server/pkg/middleware"
"a301_server/routes"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
@@ -23,6 +27,7 @@ import (
func main() {
config.Load()
config.WarnInsecureDefaults()
if err := database.ConnectMySQL(); err != nil {
log.Fatalf("MySQL 연결 실패: %v", err)
@@ -101,6 +106,7 @@ func main() {
BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB
})
app.Use(logger.New())
app.Use(middleware.SecurityHeaders)
app.Use(cors.New(cors.Config{
AllowOrigins: "https://a301.tolelom.xyz",
AllowHeaders: "Origin, Content-Type, Authorization, Idempotency-Key, X-API-Key",
@@ -133,5 +139,16 @@ func main() {
routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, brHandler, authLimiter, apiLimiter)
// Graceful shutdown
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
log.Printf("수신된 시그널: %v — 서버 종료 중...", sig)
if err := app.ShutdownWithTimeout(10 * time.Second); err != nil {
log.Printf("서버 종료 실패: %v", err)
}
}()
log.Fatal(app.Listen(":" + config.C.AppPort))
}

View File

@@ -1,6 +1,7 @@
package config
import (
"log"
"os"
"strconv"
@@ -75,6 +76,22 @@ func Load() {
}
}
// WarnInsecureDefaults logs warnings for security-sensitive settings left at defaults.
func WarnInsecureDefaults() {
if C.JWTSecret == "secret" {
log.Println("WARNING: JWT_SECRET is using the default value — set a strong secret for production")
}
if C.RefreshSecret == "refresh-secret" {
log.Println("WARNING: REFRESH_SECRET is using the default value — set a strong secret for production")
}
if C.AdminPassword == "admin1234" {
log.Println("WARNING: ADMIN_PASSWORD is using the default value — change it for production")
}
if C.WalletEncryptionKey == "" {
log.Println("WARNING: WALLET_ENCRYPTION_KEY is empty — blockchain wallet features will fail")
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v

View File

@@ -2,6 +2,7 @@ package middleware
import (
"context"
"crypto/subtle"
"fmt"
"log"
"strings"
@@ -70,9 +71,11 @@ func AdminOnly(c *fiber.Ctx) error {
}
// ServerAuth validates X-API-Key header for server-to-server communication.
// Uses constant-time comparison to prevent timing attacks.
func ServerAuth(c *fiber.Ctx) error {
key := c.Get("X-API-Key")
if key == "" || config.C.InternalAPIKey == "" || key != config.C.InternalAPIKey {
expected := config.C.InternalAPIKey
if key == "" || expected == "" || subtle.ConstantTimeCompare([]byte(key), []byte(expected)) != 1 {
log.Printf("ServerAuth 실패: IP=%s, Path=%s, KeyPresent=%v", c.IP(), c.Path(), key != "")
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 API 키입니다"})
}

View File

@@ -0,0 +1,13 @@
package middleware
import "github.com/gofiber/fiber/v2"
// SecurityHeaders sets common HTTP security headers on every response.
func SecurityHeaders(c *fiber.Ctx) error {
c.Set("X-Content-Type-Options", "nosniff")
c.Set("X-Frame-Options", "DENY")
c.Set("X-XSS-Protection", "0")
c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
c.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
return c.Next()
}