fix: 보안 및 안정성 보강
- Zip Slip 경로 검증 추가 - HTTP 상태 코드 검증 (doDownload) - HTTP 타임아웃 설정 (API/다운로드 클라이언트 분리) - 다운로드 URL 스킴 검증 (https/http만 허용) - 리다이렉트 스킴 제한 (CheckRedirect) - 다운로드 크기 제한 (2GB) - fetchServerInfo 응답 크기 제한 (1MB) - 다운로드 후 해시 검증 - 다중 인스턴스 실행 방지 (CreateMutexW) - 다운로드 취소 기능 (wmClose 핸들러) - 압축 해제 실패 시 잔여 파일 정리 (임시 디렉토리 추출) - 도달 불가능한 dead code 및 미사용 코드 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
181
main.go
181
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)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user