From 9fb98b002814ceab9a07d7084b261ea8f7c103a8 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Thu, 5 Mar 2026 11:04:45 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B3=B4=EC=95=88=20=EB=B0=8F=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=EC=84=B1=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Zip Slip 경로 검증 추가 - HTTP 상태 코드 검증 (doDownload) - HTTP 타임아웃 설정 (API/다운로드 클라이언트 분리) - 다운로드 URL 스킴 검증 (https/http만 허용) - 리다이렉트 스킴 제한 (CheckRedirect) - 다운로드 크기 제한 (2GB) - fetchServerInfo 응답 크기 제한 (1MB) - 다운로드 후 해시 검증 - 다중 인스턴스 실행 방지 (CreateMutexW) - 다운로드 취소 기능 (wmClose 핸들러) - 압축 해제 실패 시 잔여 파일 정리 (임시 디렉토리 추출) - 도달 불가능한 dead code 및 미사용 코드 제거 Co-Authored-By: Claude Opus 4.6 --- main.go | 183 +++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 160 insertions(+), 23 deletions(-) diff --git a/main.go b/main.go index 0528fa6..692622c 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,9 @@ import ( "path/filepath" "runtime" "strings" + "sync/atomic" "syscall" + "time" "unsafe" "golang.org/x/sys/windows" @@ -25,9 +27,36 @@ const ( protocolName = "a301" gameExeName = "A301.exe" serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info" - webURL = "https://a301.tolelom.xyz" ) +const maxDownloadSize = 2 << 30 // 2GB + +var checkRedirect = func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" && req.URL.Scheme != "http" { + return fmt.Errorf("허용되지 않는 리다이렉트 스킴: %s", req.URL.Scheme) + } + if len(via) >= 10 { + return fmt.Errorf("리다이렉트 횟수 초과") + } + return nil +} + +// apiClient: 서버 정보 조회 등 짧은 요청용 (전체 120초 타임아웃) +var apiClient = &http.Client{ + Timeout: 120 * time.Second, + CheckRedirect: checkRedirect, +} + +// downloadClient: 대용량 파일 다운로드용 (연결 30초 + 유휴 60초, 전체 타임아웃 없음) +var downloadClient = &http.Client{ + Transport: &http.Transport{ + TLSHandshakeTimeout: 30 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, + IdleConnTimeout: 60 * time.Second, + }, + CheckRedirect: checkRedirect, +} + // Win32 constants const ( wmDestroy uint32 = 0x0002 @@ -83,7 +112,6 @@ var ( user32 = windows.NewLazySystemDLL("user32.dll") kernel32 = windows.NewLazySystemDLL("kernel32.dll") gdi32 = windows.NewLazySystemDLL("gdi32.dll") - shell32 = windows.NewLazySystemDLL("shell32.dll") comctl32 = windows.NewLazySystemDLL("comctl32.dll") uxtheme = windows.NewLazySystemDLL("uxtheme.dll") @@ -103,7 +131,7 @@ var ( getSystemMetricsProc = user32.NewProc("GetSystemMetrics") getDpiForSystemProc = user32.NewProc("GetDpiForSystem") setProcessDpiAwarenessContextProc = user32.NewProc("SetProcessDpiAwarenessContext") - shellExecuteWProc = shell32.NewProc("ShellExecuteW") + createMutexWProc = kernel32.NewProc("CreateMutexW") getModuleHandleWProc = kernel32.NewProc("GetModuleHandleW") createFontIndirectWProc = gdi32.NewProc("CreateFontIndirectW") createSolidBrushProc = gdi32.NewProc("CreateSolidBrush") @@ -118,6 +146,7 @@ var ( progressLabelHwnd uintptr progressBarHwnd uintptr hBrushBg uintptr + downloadCancelled atomic.Bool ) type wndClassExW struct { @@ -229,18 +258,17 @@ func msgBox(title, text string, flags uintptr) int { return int(ret) } -func openBrowser(rawURL string) { - u, _ := windows.UTF16PtrFromString(rawURL) - op, _ := windows.UTF16PtrFromString("open") - shellExecuteWProc.Call(0, uintptr(unsafe.Pointer(op)), uintptr(unsafe.Pointer(u)), 0, 0, 1) -} - // ── Progress window ────────────────────────────────────────────────────────── func progressWndProc(hwnd, uMsg, wParam, lParam uintptr) uintptr { switch uint32(uMsg) { case wmClose: - return 0 // 닫기 방지 + ret := msgBox("One of the plans 런처", "다운로드를 취소하시겠습니까?", mbYesNo|mbQ) + if ret == idYes { + downloadCancelled.Store(true) + destroyWindowProc.Call(hwnd) + } + return 0 case wmDestroy: postQuitMsgProc.Call(0) return 0 @@ -372,9 +400,12 @@ func downloadWithProgress(downloadURL, destDir string) error { showWindowProc.Call(hwnd, swShow) updateWindowProc.Call(hwnd) + downloadCancelled.Store(false) + errCh := make(chan error, 1) go func() { errCh <- doDownload(downloadURL, destDir) + // 윈도우가 이미 파괴된 경우(취소) PostMessage는 무시됨 postMessageWProc.Call(hwnd, uintptr(wmAppDone), 0, 0) }() @@ -392,23 +423,36 @@ func downloadWithProgress(downloadURL, destDir string) error { } func doDownload(downloadURL, destDir string) error { - resp, err := http.Get(downloadURL) + resp, err := downloadClient.Get(downloadURL) if err != nil { return fmt.Errorf("다운로드 연결 실패: %w", err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode) + } + + total := resp.ContentLength + if total > maxDownloadSize { + return fmt.Errorf("파일이 너무 큽니다 (%d bytes)", total) + } + tmpPath := filepath.Join(os.TempDir(), "a301_game.zip") tmpFile, err := os.Create(tmpPath) if err != nil { return fmt.Errorf("임시 파일 생성 실패: %w", err) } - total := resp.ContentLength var downloaded int64 buf := make([]byte, 32*1024) for { + if downloadCancelled.Load() { + tmpFile.Close() + os.Remove(tmpPath) + return fmt.Errorf("다운로드가 취소되었습니다") + } n, err := resp.Body.Read(buf) if n > 0 { if _, werr := tmpFile.Write(buf[:n]); werr != nil { @@ -417,6 +461,11 @@ func doDownload(downloadURL, destDir string) error { return fmt.Errorf("파일 쓰기 실패: %w", werr) } downloaded += int64(n) + if downloaded > maxDownloadSize { + tmpFile.Close() + os.Remove(tmpPath) + return fmt.Errorf("다운로드 크기가 제한을 초과했습니다") + } if total > 0 { pct := int(downloaded * 100 / total) setProgress(fmt.Sprintf("다운로드 중... %d%%", pct), pct) @@ -435,7 +484,24 @@ func doDownload(downloadURL, destDir string) error { defer os.Remove(tmpPath) setProgress("압축을 해제하는 중...", -1) - return extractZip(tmpPath, destDir) + + // 임시 디렉토리에 먼저 추출 후 성공 시 이동 (실패 시 잔여 파일 방지) + tmpExtractDir, err := os.MkdirTemp("", "a301_extract_") + if err != nil { + return fmt.Errorf("임시 추출 디렉토리 생성 실패: %w", err) + } + + if err := extractZip(tmpPath, tmpExtractDir); err != nil { + os.RemoveAll(tmpExtractDir) + return err + } + + if err := moveContents(tmpExtractDir, destDir); err != nil { + os.RemoveAll(tmpExtractDir) + return fmt.Errorf("파일 이동 실패: %w", err) + } + os.RemoveAll(tmpExtractDir) + return nil } func extractZip(zipPath, destDir string) error { @@ -468,6 +534,13 @@ func extractZip(zipPath, destDir string) error { dest := filepath.Join(destDir, filepath.FromSlash(rel)) + // Zip Slip 방지: 추출 경로가 대상 디렉토리 안인지 검증 + cleanDest := filepath.Clean(dest) + cleanBase := filepath.Clean(destDir) + string(os.PathSeparator) + if !strings.HasPrefix(cleanDest, cleanBase) && cleanDest != filepath.Clean(destDir) { + return fmt.Errorf("잘못된 zip 경로: %s", rel) + } + if f.FileInfo().IsDir() { os.MkdirAll(dest, 0755) continue @@ -496,6 +569,49 @@ func extractZip(zipPath, destDir string) error { return nil } +func moveContents(srcDir, dstDir string) error { + entries, err := os.ReadDir(srcDir) + if err != nil { + return err + } + for _, e := range entries { + src := filepath.Join(srcDir, e.Name()) + dst := filepath.Join(dstDir, e.Name()) + if e.IsDir() { + if err := os.MkdirAll(dst, 0755); err != nil { + return err + } + if err := moveContents(src, dst); err != nil { + return err + } + } else { + os.Remove(dst) // 기존 파일 제거 (Rename 실패 방지) + if err := os.Rename(src, dst); err != nil { + // 드라이브가 다를 경우 복사 후 삭제 + if err := copyFile(src, dst); err != nil { + return err + } + } + } + } + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + // ── Server info ────────────────────────────────────────────────────────────── type downloadInfo struct { @@ -504,7 +620,7 @@ type downloadInfo struct { } func fetchServerInfo() (*downloadInfo, error) { - resp, err := http.Get(serverInfoURL) + resp, err := apiClient.Get(serverInfoURL) if err != nil { return nil, fmt.Errorf("서버 연결 실패: %w", err) } @@ -518,7 +634,7 @@ func fetchServerInfo() (*downloadInfo, error) { } var info downloadInfo - if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&info); err != nil { return nil, fmt.Errorf("서버 응답 파싱 실패: %w", err) } return &info, nil @@ -607,9 +723,23 @@ func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error { if serverInfo.URL == "" { return fmt.Errorf("다운로드 URL이 없습니다") } + u, err := url.Parse(serverInfo.URL) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") { + return fmt.Errorf("유효하지 않은 다운로드 URL") + } if err := downloadWithProgress(serverInfo.URL, gameDir); err != nil { return fmt.Errorf("게임 설치 실패: %w", err) } + if serverInfo.FileHash != "" { + newHash, err := hashFile(gamePath) + if err != nil { + return fmt.Errorf("다운로드 후 파일 검증 실패: %w", err) + } + if !strings.EqualFold(newHash, serverInfo.FileHash) { + os.Remove(gamePath) + return fmt.Errorf("다운로드된 파일의 해시가 일치하지 않습니다 (파일이 손상되었을 수 있습니다)") + } + } } return nil @@ -654,9 +784,23 @@ func handleURI(rawURI string) error { // ── Entry point ────────────────────────────────────────────────────────────── +func acquireSingleInstance() bool { + name, _ := windows.UTF16PtrFromString("Global\\A301LauncherMutex") + _, _, err := createMutexWProc.Call(0, 0, uintptr(unsafe.Pointer(name))) + // ERROR_ALREADY_EXISTS = 183 + if errno, ok := err.(syscall.Errno); ok && errno == 183 { + return false + } + return true +} + func main() { enableDPIAwareness() + if !acquireSingleInstance() { + return + } + if len(os.Args) < 2 { ret := msgBox("One of the plans 런처", "게임 실행을 위해 프로토콜을 등록합니다.\n계속하시겠습니까?", mbYesNo|mbQ) if ret != idYes { @@ -688,14 +832,7 @@ func main() { case strings.HasPrefix(arg, protocolName+"://"): if err := handleURI(arg); err != nil { - if strings.Contains(err.Error(), "버전이 최신이 아닙니다") { - ret := msgBox("One of the plans - 업데이트 필요", "새로운 버전이 있습니다. 다운로드 페이지로 이동할까요?", mbYesNo|mbInfo) - if ret == idYes { - openBrowser(webURL) - } - } else { - msgBox("One of the plans 런처 - 오류", fmt.Sprintf("실행 실패:\n%v", err), mbOK|mbError) - } + msgBox("One of the plans 런처 - 오류", fmt.Sprintf("실행 실패:\n%v", err), mbOK|mbError) os.Exit(1) }