feat: 보안 수정 + Prometheus 메트릭 + 단위 테스트 추가

보안:
- Zip Bomb 방어 (io.LimitReader 100MB)
- Redis Del 에러 로깅 (auth, idempotency)
- 로그인 실패 로그에서 username 제거
- os.Remove 에러 로깅

모니터링:
- Prometheus 메트릭 미들웨어 + /metrics 엔드포인트
- http_requests_total, http_request_duration_seconds 등 4개 메트릭

테스트:
- download (11), chain (10), bossraid (20) = 41개 단위 테스트

기타:
- DB 모델 GORM 인덱스 태그 추가
- launcherHash 필드 + hashFileToHex() 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 10:37:42 +09:00
parent 82adb37ecb
commit 844a5b264b
14 changed files with 1016 additions and 495 deletions

View File

@@ -22,4 +22,5 @@ type Info struct {
// LauncherSize is a human-readable string (e.g., "25.3 MB") for display purposes.
// Programmatic size tracking uses os.Stat on the actual file.
LauncherSize string `json:"launcherSize" gorm:"not null;default:''"`
LauncherHash string `json:"launcherHash" gorm:"not null;default:''"`
}

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
@@ -55,12 +56,16 @@ func (s *Service) UploadLauncher(body io.Reader, baseURL string) (*Info, error)
err = closeErr
}
if err != nil {
os.Remove(tmpPath)
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 저장 실패: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath)
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 이동 실패: %w", err)
}
@@ -69,12 +74,15 @@ func (s *Service) UploadLauncher(body io.Reader, baseURL string) (*Info, error)
launcherSize = fmt.Sprintf("%.1f MB", float64(n)/1024/1024)
}
launcherHash := hashFileToHex(finalPath)
info, err := s.repo.GetLatest()
if err != nil {
info = &Info{}
}
info.LauncherURL = baseURL + "/api/download/launcher"
info.LauncherSize = launcherSize
info.LauncherHash = launcherHash
return info, s.repo.Save(info)
}
@@ -97,12 +105,16 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info
err = closeErr
}
if err != nil {
os.Remove(tmpPath)
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 저장 실패: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath)
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 이동 실패: %w", err)
}
@@ -123,7 +135,9 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info
fileHash := hashGameExeFromZip(finalPath)
if fileHash == "" {
os.Remove(finalPath)
if removeErr := os.Remove(finalPath); removeErr != nil {
log.Printf("WARNING: failed to remove file %s: %v", finalPath, removeErr)
}
return nil, fmt.Errorf("zip 파일에 %s이(가) 포함되어 있지 않습니다", "A301.exe")
}
@@ -139,8 +153,21 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info
return info, s.repo.Save(info)
}
// NOTE: No size limit on decompressed entry. This is admin-only so
// the risk is minimal. For defense-in-depth, consider io.LimitReader.
func hashFileToHex(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return ""
}
return hex.EncodeToString(h.Sum(nil))
}
const maxExeSize = 100 * 1024 * 1024 // 100MB — Zip Bomb 방어
func hashGameExeFromZip(zipPath string) string {
r, err := zip.OpenReader(zipPath)
if err != nil {
@@ -155,7 +182,7 @@ func hashGameExeFromZip(zipPath string) string {
return ""
}
h := sha256.New()
_, err = io.Copy(h, rc)
_, err = io.Copy(h, io.LimitReader(rc, maxExeSize))
rc.Close()
if err != nil {
return ""

View File

@@ -0,0 +1,198 @@
package download
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
)
func TestHashFileToHex_KnownContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "testfile.bin")
content := []byte("hello world")
if err := os.WriteFile(path, content, 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
got := hashFileToHex(path)
h := sha256.Sum256(content)
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashFileToHex = %q, want %q", got, want)
}
}
func TestHashFileToHex_EmptyFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.bin")
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
got := hashFileToHex(path)
h := sha256.Sum256([]byte{})
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashFileToHex (empty) = %q, want %q", got, want)
}
}
func TestHashFileToHex_NonExistentFile(t *testing.T) {
got := hashFileToHex("/nonexistent/path/file.bin")
if got != "" {
t.Errorf("hashFileToHex (nonexistent) = %q, want empty string", got)
}
}
// createTestZip creates a zip file at zipPath containing the given files.
// files is a map of filename -> content.
func createTestZip(t *testing.T, zipPath string, files map[string][]byte) {
t.Helper()
f, err := os.Create(zipPath)
if err != nil {
t.Fatalf("failed to create zip: %v", err)
}
defer f.Close()
w := zip.NewWriter(f)
for name, data := range files {
fw, err := w.Create(name)
if err != nil {
t.Fatalf("failed to create zip entry %s: %v", name, err)
}
if _, err := fw.Write(data); err != nil {
t.Fatalf("failed to write zip entry %s: %v", name, err)
}
}
if err := w.Close(); err != nil {
t.Fatalf("failed to close zip writer: %v", err)
}
}
func TestHashGameExeFromZip_WithA301Exe(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "game.zip")
exeContent := []byte("fake A301.exe binary content for testing")
createTestZip(t, zipPath, map[string][]byte{
"GameFolder/A301.exe": exeContent,
"GameFolder/readme.txt": []byte("readme"),
})
got := hashGameExeFromZip(zipPath)
h := sha256.Sum256(exeContent)
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashGameExeFromZip = %q, want %q", got, want)
}
}
func TestHashGameExeFromZip_CaseInsensitive(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "game.zip")
exeContent := []byte("case insensitive test")
createTestZip(t, zipPath, map[string][]byte{
"build/a301.EXE": exeContent,
})
got := hashGameExeFromZip(zipPath)
h := sha256.Sum256(exeContent)
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashGameExeFromZip (case insensitive) = %q, want %q", got, want)
}
}
func TestHashGameExeFromZip_NoA301Exe(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "game.zip")
createTestZip(t, zipPath, map[string][]byte{
"GameFolder/other.exe": []byte("not A301"),
"GameFolder/readme.txt": []byte("readme"),
})
got := hashGameExeFromZip(zipPath)
if got != "" {
t.Errorf("hashGameExeFromZip (no A301.exe) = %q, want empty string", got)
}
}
func TestHashGameExeFromZip_EmptyZip(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "empty.zip")
createTestZip(t, zipPath, map[string][]byte{})
got := hashGameExeFromZip(zipPath)
if got != "" {
t.Errorf("hashGameExeFromZip (empty zip) = %q, want empty string", got)
}
}
func TestHashGameExeFromZip_InvalidZip(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "notazip.zip")
if err := os.WriteFile(zipPath, []byte("this is not a zip file"), 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}
got := hashGameExeFromZip(zipPath)
if got != "" {
t.Errorf("hashGameExeFromZip (invalid zip) = %q, want empty string", got)
}
}
func TestVersionRegex(t *testing.T) {
tests := []struct {
input string
want string
}{
{"game_v1.2.3.zip", "v1.2.3"},
{"game_v2.0.zip", "v2.0"},
{"game_v10.20.30.zip", "v10.20.30"},
{"game.zip", ""},
{"noversion", ""},
}
for _, tt := range tests {
got := versionRe.FindString(tt.input)
if got != tt.want {
t.Errorf("versionRe.FindString(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestGameFilePath(t *testing.T) {
s := NewService(nil, "/data/game")
got := s.GameFilePath()
// filepath.Join normalizes separators per OS
want := filepath.Join("/data/game", "game.zip")
if got != want {
t.Errorf("GameFilePath() = %q, want %q", got, want)
}
}
func TestLauncherFilePath(t *testing.T) {
s := NewService(nil, "/data/game")
got := s.LauncherFilePath()
want := filepath.Join("/data/game", "launcher.exe")
if got != want {
t.Errorf("LauncherFilePath() = %q, want %q", got, want)
}
}