---
phase: research
agent: research-advisor
agent_version: 2
generated_at: 2026-06-23T02:29:01Z
concerns:
  - "stale-skeleton-fact: W3 skeleton(2026-06-17) §코드현황5 의 '/game/** 정적 핸들러 미등록 → 서빙 미보장(QG-3)' 전제는 코드상 뒤집힘 — GameAssetController 가 /game/{gameUuid}/** 를 @Controller 핸들러로 서빙한다. 설계 진입 전 골자의 QG-3 문구 정정 필요."
concerns_checked: true
source_confidence: high
workers_spawned: 0
self_verification:
  checklist_passed: true
---

# 조사 결과 — W3-5 Unity WebGL 업로드 자동화 선행 조사

## 주제
bibimbap GameUpload(`/api/game-files/webgl-zip`) 부분구현의 선행 조사 7항목을 코드 grounding 으로 확인. 각 항목 현황(code-fact) + 정석 보강 방향 후보(해석).

> 본 조사는 라이브러리 API 조사가 아닌 **단일 코드베이스 결합점 조사**이며, 7개 항목은 사전 지정된 조사 점검표(열거형 카탈로그 아님)다. 따라서 §4.8 축-완결성 패스는 비적용. 모든 사실은 file:line 직접 확인(`확인됨`), source_confidence=high. 단 stale 골자 전제 1건 concern 기록.

## 요약 (최상단 — 핵심 3건)

1. **zip-slip 방어에 심볼릭 링크 구멍**: `extractZip` 은 `target.startsWith(targetDir)` 정규화 검증(GameUploadController.java:266-269)으로 `../` 경로탈출은 막지만, **zip 엔트리가 심볼릭 링크인 경우를 전혀 처리하지 않는다**(코드 전역 `isSymbolicLink`/`NOFOLLOW`/`toRealPath` 사용 0건). 디렉터리 생성·파일 쓰기가 기존 심링크를 따라가 targetDir 밖에 쓸 수 있다(`Files.createDirectories`/`Files.newOutputStream` 모두 기본 follow-links). → 정석 보강 후보: 엔트리별 심링크 차단 + 쓰기 전 `toRealPath` 재검증. — code-fact(부재) + 해석(보강)

2. **`/game/**` 서빙은 실재 — 골자 QG-3 전제 stale**: 골자(2026-06-17)는 "`/game/**` 핸들러 미등록 → 서빙 미보장"이라 했으나, `GameAssetController.gameAsset` 이 `@GetMapping("/game/{gameUuid}/**")` 로 직접 서빙한다(GameAssetController.java:31-69). UUID 검증·경로 boundary·Content-Type·CSP·Content-Encoding(br/gz) 까지 처리. `UploadResourceConfig` 는 `/profile/**` 만 등록(UploadResourceConfig.java:22-23)한 것이 맞으나, WebGL 은 ResourceHandler 가 아니라 전용 컨트롤러로 서빙되는 **의도된 설계**다(버그 아님). → 골자 문구 정정 필요. — code-fact

3. **업로드 권한 게이트 전무**: 세 업로드 엔드포인트(`webgl-zip`/`thumbnail`/POST root) 모두 **CSRF + 로그인(세션 userId) 체크만** 하고 권한 검사가 없다(GameUploadController.java:113-117, 173-177, 62-65). W1 RBAC 인프라(PermissionGate/RbacInterceptor)는 실재하나 `InterceptorConfig` 가 **`/admin/**` 에만** 인터셉터를 건다(InterceptorConfig.java:19) → `/api/game-files/**` 미보호. 로그인한 모든 사용자가 업로드 가능. → W1 게이트를 얹을 자리. — code-fact

## 포인트별 발견

### 포인트 1: zip-slip 방어 충분성
- 경로: `GameUploadController.java`
- 현황(code-fact):
  - 엔트리별 정규화 후 prefix 검증: `Path target = targetDir.resolve(entry.getName()).normalize(); if (!target.startsWith(targetDir)) throw ...`(266-269). `../`·절대경로(`resolve` 시 절대경로면 targetDir 밖으로 normalize → startsWith 탈락)·백슬래시는 이 검증으로 차단됨. — 확인됨
  - 디렉터리 엔트리: `Files.createDirectories(target)`(272). 파일: `Files.createDirectories(target.getParent())` + `copyZipEntry`(277-278). 둘 다 **심링크 follow 기본 동작** — NOFOLLOW/실경로 재검증 없음. — 확인됨(부재)
  - 심볼릭 링크 처리 코드 전무: `rg isSymbolicLink|NOFOLLOW|LinkOption|toRealPath` 결과 0건(전 소스). — 확인됨
  - `%2e` 등 URL 인코딩 우회: `extractZip` 은 zip 엔트리명을 디코딩하지 않고 그대로 `resolve` 하므로 `%2e` 는 리터럴 파일명이 되어 무해(zip 엔트리명은 URL 인코딩 대상 아님). 단 서빙 측 `GameAssetController.resolveAssetFile` 은 `UriUtils.decode`(GameAssetController.java:92) 후 정규화·boundary 재검증(101-103) 하므로 서빙 경로도 boundary 보호됨. — 확인됨
  - NUL 바이트: 서빙 측만 `assetPath.contains("\0")` 차단(GameAssetController.java:97). 추출 측은 미체크(JVM 이 NUL 포함 경로 쓰기 시 예외 발생하나 명시 방어 아님). — 확인됨
- 구멍 요약: (a) **심볼릭 링크 엔트리 무방비**(최대 구멍), (b) zip 폭탄 부분방어 — 누적 압축해제 크기(512MB)·엔트리 수(8000)는 막으나 **개별 엔트리 압축비(zip bomb ratio) 검사 없음**(copyZipEntry 는 누적 바이트만 — 512MB 한도 내라면 통과), (c) 엔트리명 길이/중첩깊이 상한 없음. — code-fact(a,c 부재) + 해석(b)
- 정석 보강 방향 후보(해석): 엔트리별 `target.toRealPath()` 또는 부모 디렉터리 실경로 재검증, 심링크 엔트리(`entry` 의 external attributes 또는 추출 후 `Files.isSymbolicLink`) 거부, 압축비 임계 검사.
- 신뢰도: 확인됨

### 포인트 2: WebGL 빌드 포맷 검증 범위
- 경로: `GameUploadController.java:135-139, 306-316`
- 현황(code-fact):
  - 검증하는 것: **index.html 존재만**. `findIndexFile` 이 추출 트리를 walk 하여 `index.html`(대소문자 무시) 중 **가장 얕은 경로** 1개를 선택(306-314). 없으면 `deleteRecursively` 후 400(136-139). — 확인됨
  - 검증하지 않는 것: `Build/*.data`·`*.wasm`·`*.framework.js`·`*.loader.js` 등 Unity WebGL 필수 산출물 존재 검사 **전무**(rg 결과 해당 확장자 검증 로직 없음, contentType 매핑만 GameAssetController 에 존재). — 확인됨(부재)
  - index.html 내용/구조 검증 없음 — 임의 index.html 만 있어도 통과. — 확인됨
- 정석 보강 방향 후보(해석): Unity 빌드 마커(`Build/` 디렉터리 + loader/framework/wasm/data 4종 또는 그 압축변형 `.br`/`.gz`) 존재 검증, index.html 의 loader 참조 정합 체크.
- 신뢰도: 확인됨

### 포인트 3: 업로드 크기/타입
- 경로: `GameUploadController.java:40-42, 121-123, 240-252, 254-304`; `application.properties:15-17`
- 현황(code-fact):
  - 압축해제 누적 크기 상한: `WEBGL_EXTRACTED_MAX_BYTES = 512MB`(40), `copyZipEntry` 가 누적 초과 시 throw(297-299). — 확인됨
  - 엔트리 수 상한: `WEBGL_MAX_ENTRIES = 8_000`(41), 초과 시 throw(262-263). — 확인됨 (골자 8000 언급과 일치)
  - **zip 원본(업로드) 크기 상한 — 코드 레벨 명시 없음**. 전역 `spring.servlet.multipart.max-file-size=1GB`·`max-request-size=1GB`(application.properties:15-16) + `server.tomcat.max-swallow-size=-1`(무제한 swallow) 가 유일 한도. webgl-zip 핸들러는 `file.getSize()` 상한 검사 없음(썸네일만 10MB 검사 GameUploadController.java:181). — 확인됨
  - Content-Type/확장자 이중 체크: `isZipFile` 이 MIME(application/zip·x-zip-compressed·multipart/x-zip) **또는** `.zip` 확장자 — **OR 조건**(둘 중 하나만 충족해도 통과, 240-252). 이중(AND) 아님. — 확인됨
  - **매직바이트(zip 시그니처 PK\x03\x04) 검증 없음** — 선언 MIME/확장자만 신뢰. — 확인됨(부재)
- 정석 보강 방향 후보(해석): webgl-zip 전용 원본 크기 상한(예: 512MB~1GB 명시), zip 매직바이트 검증, MIME+확장자 AND 강화 여부 검토.
- 신뢰도: 확인됨

### 포인트 4: 저장 경로 boundary
- 경로: `GameUploadController.java:44-45, 218-220, 126-130`; `dev/db.properties:6`; `application.properties:23`
- 현황(code-fact):
  - 설정 주입: `@Value("${app.upload.game-storage-path:src/main/resources/static}")`(44), default `src/main/resources/static`. dev 프로파일은 `app.upload.game-storage-path=src/main/resources/static/game`(dev/db.properties:6), `spring.config.import=optional:.../db.properties`(application.properties:23) 로 로드. — 확인됨
  - `gameRoot()` = `Paths.get(uploadStoragePath).toAbsolutePath().normalize().resolve("game").normalize()`(218-219). — 확인됨
  - **주의(해석)**: dev 설정값이 이미 `.../static/game` 인데 `gameRoot()` 가 추가로 `.resolve("game")` → 실제 루트가 `.../static/game/game` 로 **이중 중첩** 가능. default 값(`.../static`)에는 맞으나 dev override 와 어긋남. 운영 배포값 미확인 — 의도/실수 판별 필요. — code-fact(경로조합) + 해석(중첩 의심)
  - boundary 검증: webgl-zip 은 `targetDir = root.resolve(gameUuid).normalize(); if(!targetDir.startsWith(root))`(127-128) — 단 gameUuid 는 `UUID.randomUUID()`(126) 라 외부주입 아님 → 이 경로의 탈출 위험 없음. thumbnail/POST-root 는 외부 path/gameUuid 받되 normalize+startsWith 검증(199-202, 396-411). — 확인됨
  - 외부 주입 경로 탈출: `uploadStoragePath` 자체는 운영자 설정값(외부 사용자 주입 아님). 사용자 입력 path 는 POST-root 의 `path` 파라미터뿐이며 `resolveTargetFile` 가 normalize+startsWith(root) 검증(406-409). — 확인됨
- 정석 보강 방향 후보(해석): dev 의 이중 `game` 중첩 정리(설정값을 `.../static` 으로 통일하거나 `gameRoot()` 의 `.resolve("game")` 제거), 운영 배포 경로 문서화.
- 신뢰도: 확인됨 (단 경로 중첩의 의도 여부는 미확인 — 운영 배포값 부재)

### 포인트 5: /game/** 정적 핸들러 등록 (QG-3)
- 경로: `GameAssetController.java:31-69`; `UploadResourceConfig.java:18-24`
- 현황(code-fact):
  - `UploadResourceConfig.addResourceHandlers` 는 **`/profile/**` 만** 등록(22-23), `/game/**` ResourceHandler **미등록**(골자 주장 일치). — 확인됨
  - **그러나** `/game/{gameUuid}/**` 는 전용 컨트롤러 `GameAssetController.gameAsset`(`@GetMapping`, 31)이 직접 서빙: UUID 정규화(37-41)·디렉터리 boundary(101-103)·Content-Type 매핑(130-172, wasm/js/data/json/html/css/이미지)·Content-Encoding(br/gz, 119-128)·CSP+X-Content-Type-Options+Cache-Control(57-66) 처리. — 확인됨
  - → WebGL 서빙은 **실제 동작하며 의도된 설계**. 골자의 "서빙 미보장(QG-3, 의도/버그 미확인)" 전제는 **stale** — 버그 아님. — code-fact (concern 으로 격상)
- 정석 보강 방향 후보(해석): 전용 컨트롤러 방식 유지 타당(보안헤더·인코딩 협상 필요해 ResourceHandler 보다 적합). 단 ResourceHandler 미등록이 정상임을 골자에 명시.
- 신뢰도: 확인됨

### 포인트 6: 업로드 권한 게이트
- 경로: `GameUploadController.java:59-65, 106-117, 170-177`; `InterceptorConfig.java:19`; `security/PermissionKeys.java`
- 현황(code-fact):
  - webgl-zip: CSRF(106-108) + 로그인(113-117)만. 권한 검사 없음. — 확인됨
  - thumbnail: CSRF(170-172) + 로그인(173-177)만. — 확인됨
  - POST-root(`uploadGameFiles`): CSRF(59-61) + 로그인(62-65)만. — 확인됨
  - W1 RBAC 실재: `PermissionGate`·`RbacInterceptor`(클래스명 obfuscated 흔적 있으나 InterceptorConfig 는 정상 `RbacInterceptor` import)·`PermissionKeys`(GAME_JAM_MANAGE·POST_WRITE 정의). — 확인됨
  - `InterceptorConfig.addInterceptors` 는 `registry.addInterceptor(rbacInterceptor).addPathPatterns("/admin/**")` — **`/admin/**` 전용**(19). `/api/game-files/**` 미보호. — 확인됨
  - 업로드 전용 권한키 부재: PermissionKeys 에 GAME_UPLOAD 류 키 없음(GAME_JAM_MANAGE·POST_WRITE 만). — 확인됨(부재)
- 정석 보강 방향 후보(해석): 업로드 권한키 신설 + RbacInterceptor 경로 확장 또는 컨트롤러 내 PermissionGate 명시 호출. W1 게이트를 얹을 자리는 명확.
- 신뢰도: 확인됨

### 포인트 7: UUID 재사용/덮어쓰기 정책
- 경로: `GameUploadController.java:126`; `GameController.java:176-228, 240-269, 348-352`; `game-register.jsp:649-653`
- 현황(code-fact):
  - **모든 webgl-zip 업로드는 무조건 `UUID.randomUUID()` 신규 생성**(126) — 재업로드/편집 여부와 무관. 기존 UUID 재사용·덮어쓰기 로직 없음. — 확인됨
  - 편집(edit) 흐름: `game-register.jsp` 는 새 zip 업로드 시 응답의 새 gameUuid/webglPath 로 hidden 필드 교체(649-653) → `updateGame`(GameController.java:176-228) 이 새 webgl_path 로 DB UPDATE. **기존 `/game/{old-uuid}/` 디렉터리는 삭제되지 않음 → 고아 파일.** — 확인됨
  - 게임 삭제: `deleteGame` 은 `softDeleteGame`(soft delete) + 연관 soft-delete/like 삭제만(GameController.java:259-262). **파일시스템 정리 없음** → `/game/{uuid}/` 영구 잔존. — 확인됨
  - `deleteRecursively`(GameUploadController.java:385) 는 **업로드 실패 롤백 시에만** 호출(137,155,158). 정상 교체/삭제 시 미호출. — 확인됨
  - 멱등성: 동일 zip 재업로드 시 매번 다른 UUID·다른 디렉터리 → 비멱등. — 확인됨
- 정석 보강 방향 후보(해석): 편집 시 기존 UUID 유지+덮어쓰기 또는 신규 후 구버전 GC, 게임 삭제 시 자산 디렉터리 정리(soft-delete 와의 정합 — 즉시삭제 vs 유예GC), 고아 파일 배치 청소.
- 신뢰도: 확인됨

## 종합 판단
- **상위 패턴**: 업로드 파이프라인은 "정규화 prefix 검증 + 사이즈/엔트리 상한 + 전용 서빙 컨트롤러"로 1차 골격은 갖췄으나, (1) 심링크 방어 (2) Unity 빌드 포맷 검증 (3) 권한 게이트 (4) 자산 생명주기(고아 파일) 4개 축이 비어 있다.
- **충돌/정정**: 골자(2026-06-17) §코드현황5 의 "`/game/**` 미등록 → 서빙 미보장(QG-3)" 는 GameAssetController 실재로 뒤집힘 — 설계 진입 전 골자 문구 정정 필요(concern 기록). 골자의 "권한 체크 없음"·"zip-slip·크기상한·UUID 배치 됨" 은 코드와 일치.
- **갭(설계 단계로 이월)**: 운영 배포 시 `game-storage-path` 경로 중첩(static/game/game) 의도 여부, soft-delete 와 자산 즉시삭제의 정합 정책.

## 미해결
- 운영(prod) 프로파일의 `app.upload.game-storage-path` 실제값 — repo 에 dev 프로파일만 존재(dev/db.properties). 경로 이중 중첩이 운영에서도 발생하는지 미확인.
- 클래스명 obfuscation 흔적(`security.n` 등 rg 매칭) — RbacInterceptor.java 의 `public class n` 표기는 빌드/난독화 산물일 가능성. InterceptorConfig 는 정상 클래스명 사용하므로 enforcement 분석에는 영향 없음(미해결로 남김, W3-5 범위 밖).
