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:
2026-03-05 11:04:45 +09:00
parent 10651d294a
commit 9fb98b0028

181
main.go
View File

@@ -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)
}