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:
@@ -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:''"`
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
198
internal/download/service_test.go
Normal file
198
internal/download/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user