---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-23T12:10:00+09:00
workstream: W3-5-Unity WebGL 빌드 업로드 자동화 (보안 보강)
concerns:
  - "PermissionGate 신규 메서드 require(session, key)→boolean 은 이미 실재(PermissionGate.java:64). 본 설계는 신규 시그니처를 만들지 않고 기존 has/require 를 컨트롤러 진입부에서 호출만 한다 — 구현 시 신규 게이트 메서드 추가 금지(dead method 방지)."
  - "ZipSecurity 헬퍼의 신규 시그니처(validateEntryName/assertWithinBoundary/isWindowsAbsoluteOrUnc)는 최소 인자로 명세. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고, 프로토콜 §11.2). 특히 totalEntryBytes 누적값은 extractZip 루프 지역변수로 충분할 수 있어 헬퍼 인자로 들지 말 것."
  - "엔트리별 압축비(ratio) 임계 검사는 ZipInputStream 으로는 개별 엔트리의 '압축 후 크기'를 직접 못 얻는다(getCompressedSize() 는 stored/일부 deflate 에서 -1). 본 설계는 압축비 임계 대신 '엔트리당 해제 크기 상한 + 누적 해제 상한 + 엔트리 수 상한' 3중 상한으로 zip bomb 을 방어한다(ratio 미사용 정석). 구현 시 getCompressedSize() 의존 금지."
  - "gameRoot() 의 dev 프로파일 경로 이중중첩(app.upload.game-storage-path=src/main/resources/static/game → resolve('game') → .../static/game/game) 은 본 W3-5 보안 스코프 밖의 설정 정합 이슈. 보안 설계는 'gameRoot() 가 무엇이든 그 canonical 경계 내'만 보장한다. 경로 중첩 정정은 별도 운영 정합 작업으로 분리(orchestrator 에스컬레이션) — 본 설계는 건드리지 않음."
  - "심볼릭 링크 거부는 (a)엔트리 external-attributes 의 심링크 모드 비트 검사 + (b)쓰기 후 toRealPath 재검증 2중. JVM ZipEntry 표준 API 는 external attributes 를 직접 노출 안 하므로(java.util.zip.ZipEntry 에 getUnixMode 부재), 본 설계는 (b) 쓰기 직전 부모 디렉터리 실경로(toRealPath) 경계 재검증 + 디렉터리 생성 시 기존 심링크 거부로 정석화. ZipFile+0x... 비트 직접 파싱은 over-engineering 으로 미채택 — 구현 시 (b) 방식 준수."
concerns_checked: true
self_verification:
  checklist_passed: true
references:
  requirements: docs/work-log/2026-06-17-w3-feature-skeletons.md
  research: .atp/work-session/20260623-104307/research/W3-5-upload-research.md
  adrs:
    - .atp/work-session/20260622-180054/implementation/W1-design.md
    - docs/work-log/2026-06-17-jam-platform-roadmap.md
    - docs/development/verification-strategies.md
---

# 설계: W3-5 — Unity WebGL 빌드 업로드 자동화 (보안 보강 — zip-slip / zip bomb / 포맷검증 / 권한게이트 / 자산 생명주기)

## 목표 / 비목표

### 목표 (FR/NFR 추적)
- **U1 zip-slip 정석 방어** (FR-보안 / NFR-업로드 boundary): 조사 포인트1 의 구멍 전수 보강 — 심볼릭 링크 엔트리/디렉터리 거부, 쓰기 직전 실경로(canonical) 경계 재검증, 절대경로·UNC·백슬래시·NUL·드라이브 prefix 엔트리 거부, 엔트리명 길이/중첩깊이 상한.
- **U2 WebGL 포맷 검증** (FR-기능 정합): index.html 단독 존재 검증을 넘어 Unity 빌드 필수 산출물(`Build/*.loader.js` + `*.framework.js(.br/.gz)` + `*.data(.br/.gz)` + `*.wasm(.br/.gz)`) 존재 검증. 누락 시 거부 + 디렉터리 롤백.
- **U3 크기/타입 이중·zip bomb 방어** (NFR-보안): zip 원본 크기 상한(코드 레벨 명시) + zip 매직바이트(PK\x03\x04) 검증 + MIME·확장자 이중(AND) + 엔트리당 해제 크기 상한 + 누적 해제 상한(기존 512MB 유지) + 엔트리 수 상한(기존 8000 유지).
- **U4 저장 경로 boundary** (NFR-보안): `gameRoot()` canonical 경계 검증 유지·강화(rawTargetDir.toRealPath() 또는 부모 실경로 대조). UUID 기반 `/game/{uuid}/` 배치 유지.
- **U5 업로드 권한 게이트** (FR-9 흡수 / QG-1 W3-5 부분): 현 "CSRF + 로그인" 위에 W1 게이트 훅을 얹는다. 일반 게임 업로드는 모든 로그인 유저 개방을 **유지하되**, 게이트 진입점을 표준화(`PermissionGate.isAuthenticated` 명시 호출)하고, 잼 출품 업로드 분기를 위한 권한키 훅(`GAME_JAM_MANAGE` 옵션 게이트) 설계를 명시.
- **U6 UUID 재사용/멱등 교체** (FR-자산 생명주기): 같은 게임 자산 재업로드 = 임시 디렉터리에 추출·검증 후 원자적 swap(기존 디렉터리 교체) + 구버전 정리. 업로드 실패 시 임시 디렉터리만 삭제(기존 자산 무손상).
- **U7 자산 정리(고아 파일)** (FR-자산 생명주기): 편집 시 교체된 구 UUID 디렉터리 정리 + 게임 soft-delete 시 자산 디렉터리 정리 정책 확정.
- **U8 감사 로그** (NFR-감사): 업로드 성공/거부(사유)를 `game_upload_audit_log` 에 기록(보안 사고 추적 — zip-slip/bomb 거부 패턴 가시성).
- **NFR**: 상태변경 CSRF 전수 보존, MyBatis `#{}` 바인딩(`${}` 0), 입력 sanitize, 비파괴 마이그레이션.

### 비목표 (스코프 밖)
- **`/game/**` 서빙 핸들러 신설** — 조사 포인트5 로 **stale 확정**: `GameAssetController.gameAsset(@GetMapping("/game/{gameUuid}/**"))` 가 이미 UUID 정규화·boundary·Content-Type·Content-Encoding(br/gz)·CSP 까지 서빙(GameAssetController.java:31-69). ResourceHandler 미등록은 의도된 설계(보안헤더·인코딩 협상 필요로 전용 컨트롤러 채택). 본 설계는 **신설하지 않고 현황 유지·기록만**. 골자 QG-3 문구는 documentation-advisor 가 정정.
- `gameRoot()` 경로 이중중첩(static/game/game) 정정 — 별도 운영 정합 작업(concern 4).
- 게임 메타 등록/편집 본체(`GameController.createGame/updateGame`) 재설계 — 본 설계는 webgl-zip 업로드 파이프라인 보강 + 자산 생명주기 훅만. 단 자산 정리 호출지점은 명시.
- 새 잼 출품 워크플로 본체(W2) — 권한키 훅만 열어두고 잼 분기 본체는 W2 소관.
- 클라이언트 업로드 UI(game-register.jsp) 재설계 — 응답 계약 변경분만 명시(documentation/구현 연계).

---

## 개요

bibimbap 의 WebGL 업로드는 `GameUploadController`(`/api/game-files/**`)에 1차 골격이 구현돼 있다(zip 추출·prefix 검증·엔트리/누적 상한·UUID 배치·전용 서빙 컨트롤러). 조사(7항목)는 이 골격이 4개 축에서 비어 있음을 code-fact 로 확정했다: (1) 심볼릭 링크 방어 전무, (2) Unity 포맷 검증이 index.html 단독, (3) 권한 게이트 부재(로그인만), (4) 자산 생명주기(고아 파일) 미정의. 추가로 zip 매직바이트·원본 크기 상한·MIME+확장자 AND 가 비어 있다.

본 설계는 위 골격을 **재작성하지 않고 보강**한다. 핵심 구조 결정은 다음과 같다.

1. **zip-slip 정석 = 정규화 prefix 검증(기존) + 쓰기 직전 부모 실경로(toRealPath) 경계 재검증(신규) + 심링크 거부(신규) + 엔트리명 사전 거부 규칙(신규)**. 심링크는 표준 ZipEntry API 가 모드 비트를 노출하지 않으므로 "쓰기 경로의 실경로가 targetDir 밖으로 새는지" 를 차단하는 방식으로 정석화(concern 6).
2. **추출 → 임시 디렉터리, 검증 통과 후 원자적 swap**. 기존 코드는 최종 디렉터리에 직접 추출 후 실패 시 통째 삭제하나, 재업로드 멱등 교체(U6)와 zip bomb 부분추출 잔여 방지를 위해 **임시 추출 디렉터리(`{root}/.tmp/{uuid}`) → 검증 → `Files.move(ATOMIC_MOVE)` 로 최종 위치 교체** 로 바꾼다. 실패 시 임시만 삭제 → 기존 자산 무손상.
3. **권한 게이트는 W1 인프라 재사용**. 신규 게이트 메서드를 만들지 않고(`PermissionGate` 는 이미 has/require/isAuthenticated 보유), 컨트롤러 진입부에서 `isAuthenticated` 를 명시 호출. 잼 출품 분기는 `has(session, GAME_JAM_MANAGE.name())` 옵션 게이트 훅으로 설계만 열어둔다(미연결 enum 키 `GAME_JAM_MANAGE` 의 첫 enforcement 소비처 후보).
4. **자산 생명주기는 webgl_path 기반 UUID 추출로 정리**. games 테이블은 변경하지 않고(`webgl_path` 활용), 편집/삭제 시 `webgl_path` 에서 UUID 를 파싱해 디렉터리를 정리.

신규 DDL 은 감사 로그 1테이블만(`game_upload_audit_log`) — 업로드 메타는 games.`webgl_path` 를 그대로 활용한다(정석상 메타 중복 회피).

---

## 핵심 결정 요약 (전제 — 재논의 금지)

| 결정 | 확정값 | 본 설계의 구체화 |
|---|---|---|
| D1 zip-slip | canonical 경계 + 심링크 거부 + 엔트리명 사전거부 | 정규화 prefix(기존) + 쓰기 직전 부모 toRealPath 경계 재검증 + 디렉터리 심링크 거부 + 절대/UNC/백슬래시/NUL/드라이브-prefix/`..`/길이/깊이 사전거부 |
| D2 포맷검증 | Unity 빌드 필수 산출물 존재 | index.html + (loader.js OR loader.js.br/.gz) + framework + data + wasm 4종(압축변형 허용) 존재. 누락 시 거부 |
| D3 크기/타입 | 다중 상한 + 매직바이트 + MIME·확장자 AND | 원본 ≤512MB + PK 시그니처 + (MIME ∈ 화이트리스트 AND .zip 확장자) + 엔트리당 해제 ≤256MB + 누적 ≤512MB(기존) + 엔트리수 ≤8000(기존) |
| D4 경로 boundary | gameRoot() canonical 경계 | rawTargetDir.normalize().startsWith(root)(기존) + 쓰기 시 부모 실경로 재검증. gameRoot() 정의 불변(중첩 정정은 스코프 밖) |
| D5 권한 게이트 | W1 인프라 재사용 + 일반업로드 개방 유지 | `PermissionGate.isAuthenticated` 명시 + 잼출품 분기 `has(GAME_JAM_MANAGE)` 옵션 훅. 신규 게이트 메서드 0 |
| D6 UUID 재사용 | 임시추출 → 검증 → 원자적 swap | `{root}/.tmp/{newUuid}` 추출·검증 후 `Files.move(ATOMIC_MOVE)`. 실패 시 임시만 삭제 |
| D7 자산 정리 | webgl_path UUID 파싱 정리 | 편집 교체 시 구 UUID dir 삭제 + soft-delete 시 자산 dir 삭제(즉시). 외부 입력 UUID 는 UUID.fromString 검증 후 경계 내만 |
| D8 감사로그 | 신규 game_upload_audit_log | 성공/거부(사유코드) 기록. games 메타는 webgl_path 재사용(신규 메타테이블 없음) |
| /game/** 서빙 | 현행 유지(stale 정정) | GameAssetController 실재 — 신설 안 함. 골자 문구만 정정(doc) |

---

## 데이터 모델 (DDL)

> 권위 워크플로: 신규 테이블은 **신규 `docs/game-upload-ddl.sql` 파일**(권위)로 제안. `db/apply-local-ddl.sh` 가 `docs/*-ddl.sql` 글롭 알파벳순 멱등 적용(ON_ERROR_STOP, search_path=dev). schema.sql 에 동기 사본. 업로드 메타는 games.webgl_path 활용 — **games 테이블 변경 0**.

### 신규 파일: `docs/game-upload-ddl.sql` (권위 DDL)

```sql
-- W3-5 Unity WebGL 업로드 감사 로그. 멱등. db/apply-local-ddl.sh 로 비파괴 적용.
-- 업로드 성공/거부 추적(zip-slip/zip-bomb/포맷거부 패턴 가시성). games 메타는 games.webgl_path 활용 — 신규 메타테이블 없음.

CREATE SEQUENCE IF NOT EXISTS "game_upload_audit_log_id_seq";

CREATE TABLE IF NOT EXISTS "game_upload_audit_log" (
    "id"            bigint DEFAULT nextval('game_upload_audit_log_id_seq'::regclass) NOT NULL,
    "actor_id"      bigint NOT NULL,                 -- 업로드 수행 사용자 users.id
    "game_uuid"     character varying(36),           -- 생성/교체된 UUID(거부 시 null 가능)
    "outcome"       character varying(20) NOT NULL,  -- SUCCESS / REJECTED
    "reject_reason" character varying(40),           -- REJECTED 시 사유코드(아래 reason 카탈로그)
    "original_name" character varying(255),          -- 업로드 파일 원본명(sanitize 후 저장)
    "upload_bytes"  bigint,                          -- zip 원본 크기
    "entry_count"   integer,                         -- 추출 엔트리 수(성공 시)
    "extracted_bytes" bigint,                        -- 누적 해제 크기(성공 시)
    "created_at"    timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "game_upload_audit_log_id_seq" OWNED BY "game_upload_audit_log"."id";

DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_upload_audit_log_actor_id_fkey') THEN
        ALTER TABLE "game_upload_audit_log"
            ADD CONSTRAINT "game_upload_audit_log_actor_id_fkey"
            FOREIGN KEY ("actor_id") REFERENCES "users" ("id");
    END IF;
END
$$;

DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_upload_audit_log_outcome_check') THEN
        ALTER TABLE "game_upload_audit_log"
            ADD CONSTRAINT "game_upload_audit_log_outcome_check"
            CHECK ("outcome" IN ('SUCCESS', 'REJECTED'));
    END IF;
END
$$;

CREATE INDEX IF NOT EXISTS "idx_game_upload_audit_actor"
    ON "game_upload_audit_log" ("actor_id", "created_at" DESC);
CREATE INDEX IF NOT EXISTS "idx_game_upload_audit_outcome"
    ON "game_upload_audit_log" ("outcome", "created_at" DESC);

COMMENT ON TABLE "game_upload_audit_log" IS 'WebGL 업로드 감사 로그(성공/거부 추적, W3-5)';
COMMENT ON COLUMN "game_upload_audit_log"."reject_reason" IS 'REJECTED 사유코드: NOT_ZIP/MAGIC_FAIL/TOO_LARGE/TOO_MANY_ENTRIES/ZIP_SLIP/SYMLINK/ENTRY_TOO_LARGE/BAD_ENTRY_NAME/NO_INDEX/INCOMPLETE_BUILD';
```

### 거부 사유코드 카탈로그 (reject_reason — 코드 상수와 1:1)
| 코드 | 트리거 | HTTP |
|---|---|---|
| `NOT_ZIP` | MIME·확장자 AND 실패 | 400 |
| `MAGIC_FAIL` | PK\x03\x04 시그니처 불일치 | 400 |
| `TOO_LARGE` | zip 원본 > 512MB | 413 |
| `TOO_MANY_ENTRIES` | 엔트리 > 8000 | 400 |
| `ENTRY_TOO_LARGE` | 엔트리당 해제 > 256MB | 400 |
| `EXTRACTED_TOO_LARGE` | 누적 해제 > 512MB | 400 |
| `ZIP_SLIP` | 정규화/실경로 경계 탈출 | 400 |
| `SYMLINK` | 심링크 엔트리/디렉터리 | 400 |
| `BAD_ENTRY_NAME` | 절대/UNC/백슬래시/NUL/드라이브/`..`/길이/깊이 | 400 |
| `NO_INDEX` | index.html 부재 | 400 |
| `INCOMPLETE_BUILD` | Unity 필수 산출물 누락 | 400 |

### `db/schema.sql` 반영
- `recruit_posts` 블록 뒤(또는 마지막 테이블 블록 뒤)에 `game_upload_audit_log` 블록 신설(rbac-ddl → schema.sql 동기 선례와 동일 — docs/game-upload-ddl.sql 이 권위, schema.sql 은 사본).

---

## 외부 계약 (API)

> 공통: 상태변경은 `CsrfTokens.isValid(request)`(없으면 403 + `CsrfTokens.errorBody()`). 응답은 기존 패턴 — webgl-zip 은 `Map<String,Object>`(status/message). 인증 401, 권한 403.

### 401 vs 403 정책 (확정 — W1 정책 일치)
- **미인증**(세션 `userId` 없음): API 엔드포인트는 **401** JSON `{message:"로그인이 필요합니다."}`(기존 동작 보존).
- **인증·미인가**(잼 출품 업로드에 `GAME_JAM_MANAGE` 없음 등): **403** JSON `{status:403, message:"권한이 없습니다."}`. **단 일반 게임 업로드는 미인가 없음**(모든 로그인 유저 개방 — D5).
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`(기존 보존).

### `POST /api/game-files/webgl-zip` (보강 — 시그니처 불변, 검증 강화)
| 항목 | 값 |
|---|---|
| method/path | `POST /api/game-files/webgl-zip` |
| 권한 | CSRF + 로그인(isAuthenticated). 일반 업로드 = 추가 권한 없음(개방 유지). `?mode=jam` 옵션 시 `GAME_JAM_MANAGE` 게이트(W2 연계 훅 — 본 W3-5 에서 enforcement 활성화 여부는 D5 참조) |
| 요청 | `multipart/form-data`: `file`(zip, 필수), `replaceUuid`(선택 — 재업로드 교체 대상 기존 UUID. 없으면 신규 생성), `mode`(선택, `jam`이면 잼 게이트) |
| 응답(200) | `{status:200, message, gameUuid, webglPath, deployPath, entryCount, extractedBytes}`(기존 + replaceUuid 처리 시 동일 UUID 반환) |
| 에러 | 400(reject_reason 별 message), 401(미인증), 403(CSRF/잼게이트), 413(TOO_LARGE) |

- **`replaceUuid` 추가 근거(U6)**: 기존은 무조건 신규 UUID(GameUploadController.java:126) → 재업로드마다 고아 디렉터리. `replaceUuid` 가 유효 UUID 이고 (a)경계 내 (b)요청자가 해당 게임 소유자(또는 ADMIN) 이면 같은 UUID 로 원자적 교체. 없으면 기존처럼 신규. **소유권 검증**: `replaceUuid` → games 에서 `webgl_path LIKE '/game/{uuid}/%'` 인 게임의 user_id == 세션 userId(또는 ADMIN). 불일치 시 403.
- **하위호환**: `replaceUuid`/`mode` 모두 선택 파라미터 — 미전송 시 기존 동작(신규 UUID, 일반 업로드) 100% 보존. 기존 game-register.jsp 클라이언트 무변경 동작.

### `POST /api/game-files/thumbnail` (권한 게이트만 명시 추가 — 검증 로직 기존 보존)
- 기존 CSRF + 로그인 유지. `gameUuid` 는 이미 `UUID.fromString` 정규화 + boundary 검증(GameUploadController.java:192-205). 변경: `isAuthenticated` 명시 호출로 통일(동작 동일).

### `POST /api/game-files` (root, uploadGameFiles)
- 본 설계 범위: 권한 게이트 진입점 통일(`isAuthenticated`)만. path 검증은 기존 `resolveTargetFile` 보존(이미 normalize+startsWith). 단 zip-slip 헬퍼 공통화 시 이 경로의 path 검증도 같은 `ZipSecurity` 규칙 일부(BAD_ENTRY_NAME) 재사용 권장(구현 판단).

---

## 인터셉터/게이트 연동

### 보호 경로 매핑 (택1 확정: **컨트롤러 진입부 게이트 헬퍼** — 인터셉터 등록 안 함)
- `/api/game-files/**` 를 `InterceptorConfig` 의 `RbacInterceptor` 에 **등록하지 않는다**. 이유: RbacInterceptor 는 `/admin/**` ADMIN-only 게이트 전용(RbacInterceptor.java:34 `isAdmin`)이라, 업로드처럼 "로그인 개방 + 옵션 권한키" 분기에는 부적합. W1 설계 선례(콘솔=URL패턴 / 소비액션=게이트 헬퍼)대로 **업로드는 게이트 헬퍼 방식**.
- 컨트롤러 진입부:
  1. `CsrfTokens.isValid(request)` (기존)
  2. `permissionGate.isAuthenticated(session)` → false 면 401 (기존 `sessionUserId==null` 을 게이트 호출로 통일)
  3. (옵션, `mode=jam`) `permissionGate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())` → false 면 403
- **신규 게이트 메서드 0**(concern 1): `PermissionGate.isAuthenticated`/`has` 는 이미 실재(PermissionGate.java:47,22). 그대로 호출만.

### 일반 업로드 개방 유지 논증 (D5)
- 골자·요구상 일반 게임 업로드 = 모든 로그인 유저 개방. 본 설계는 이 정책을 바꾸지 않고 **게이트 진입점을 표준화**(임의 role 직접체크 금지 — `session.getAttribute("role")` 직접비교 안 함). 잼 출품(W2)이 권한 제한을 요구하면 `mode=jam` 분기에서 `GAME_JAM_MANAGE` 게이트를 켠다. 이로써 `GAME_JAM_MANAGE`(현 소비처 0) 의 첫 enforcement 자리를 W3-5 가 훅으로 마련(활성화 본체는 W2).

---

## 시퀀스

### S1. webgl-zip 업로드 (신규/교체 공통 — 임시추출 → 검증 → 원자적 swap)
```
[로그인 세션] POST /api/game-files/webgl-zip (CSRF, file=build.zip, replaceUuid?, mode?)
  → CsrfTokens.isValid                                  아니면 403 + errorBody
  → permissionGate.isAuthenticated(session)             아니면 401
  → if mode=jam: permissionGate.has(session, GAME_JAM_MANAGE)  아니면 403
  → file null/empty 체크                                아니면 400
  → ZipSecurity.assertZipType(file)                     # MIME ∈ 화이트리스트 AND .zip 확장자
        실패 → audit(REJECTED, NOT_ZIP) → 400
  → ZipSecurity.assertMagic(file)                       # 첫 4바이트 PK\x03\x04 (또는 빈zip PK\x05\x06)
        실패 → audit(REJECTED, MAGIC_FAIL) → 400
  → if file.getSize() > 512MB                           audit(REJECTED, TOO_LARGE) → 413
  → root = gameRoot()
  → newUuid = (replaceUuid 유효·소유검증 통과) ? replaceUuid : UUID.randomUUID()
        replaceUuid 소유검증: gamesMapper 로 webgl_path 의 game.user_id == userId (또는 ADMIN)
            불일치 → 403
  → tmpDir = root.resolve(".tmp").resolve(newUuid)      # 임시 추출지
  → assertWithinBoundary(tmpDir, root)                  아니면 400 ZIP_SLIP
  → Files.createDirectories(tmpDir)
  → ExtractResult = ZipSecurity.extractZip(file, tmpDir):  # 보강된 추출(아래 S2)
        엔트리별: validateEntryName → boundary 재검증 → 심링크 거부 → 쓰기 → toRealPath 재검증
        상한: 엔트리수 8000 / 엔트리당 256MB / 누적 512MB
        위반 → IllegalArgument(reasonCode) → tmpDir 삭제 → audit(REJECTED, reason) → 400
  → indexFile = findIndexFile(tmpDir)                   없으면 tmpDir삭제 + audit(NO_INDEX) → 400
  → ZipSecurity.assertUnityBuild(tmpDir)                # loader/framework/data/wasm 존재
        실패 → tmpDir삭제 + audit(INCOMPLETE_BUILD) → 400
  → finalDir = root.resolve(newUuid)
  → if Files.exists(finalDir):                          # 교체(U6)
        backupDir = root.resolve(".tmp").resolve(newUuid + ".old")
        Files.move(finalDir → backupDir)                # 구버전 대피
  → Files.move(tmpDir → finalDir, ATOMIC_MOVE)          # 원자적 swap
  → deleteRecursively(backupDir)                        # 구버전 정리(U7)
  → webglPath = "/game/{newUuid}/" + 상대 index 경로
  → audit(SUCCESS, newUuid, entryCount, extractedBytes)
  → 200 {gameUuid:newUuid, webglPath, deployPath, entryCount, extractedBytes}

[실패 롤백 불변식] tmpDir/backupDir 는 finally 에서 잔여 시 삭제 → 기존 finalDir 자산 무손상.
```

### S2. ZipSecurity.extractZip 엔트리 루프 (zip-slip + zip bomb 정석)
```
extractedBytes=0, entryCount=0
while (entry = zip.getNextEntry()) != null:
    entryCount++
    if entryCount > 8000: throw(TOO_MANY_ENTRIES)
    name = entry.getName()
    validateEntryName(name):                           # BAD_ENTRY_NAME 사전거부
        - null/blank → reject
        - 절대경로: name.startsWith("/") || 드라이브(^[A-Za-z]:) || UNC(\\\\ 또는 //) → reject
        - 백슬래시 포함('\\') → reject (윈도우 경로 우회 차단)
        - NUL('\0') 포함 → reject
        - 경로 분절에 ".." 존재 → reject (normalize 전 명시 차단)
        - 분절 깊이 > 32 또는 name 길이 > 255 → reject
    target = targetDir.resolve(name).normalize()
    if !target.startsWith(targetDir): throw(ZIP_SLIP)
    if entry.isDirectory():
        assertParentNotSymlink(target, targetDir)      # 부모 경로상 심링크 거부
        Files.createDirectories(target)
    else:
        parent = target.getParent(); if null throw(BAD_ENTRY_NAME)
        Files.createDirectories(parent)
        assertParentNotSymlink(target, targetDir)      # SYMLINK: 쓰기 직전 부모 실경로 검증
        copied = copyEntry(zip, target, extractedBytes) # 엔트리당 256MB + 누적 512MB
        extractedBytes += copied
        # 쓰기 후 실경로 재검증(심링크가 새로 생겼거나 따라간 경우 차단)
        if !target.toRealPath().startsWith(targetDir.toRealPath()): throw(SYMLINK)
    zip.closeEntry()
if entryCount==0: throw(empty)

assertParentNotSymlink(target, targetDir):
    # target 의 부모부터 targetDir 까지 각 구간이 심링크가 아님을 확인
    p = target.getParent()
    while p != null && p.startsWith(targetDir) && !p.equals(targetDir):
        if Files.exists(p) && Files.isSymbolicLink(p): throw(SYMLINK)
        p = p.getParent()
```

### S3. 자산 정리(U7) — 편집 교체 / soft-delete
```
[편집 교체] S1 의 replaceUuid 경로가 동일 UUID 교체 → 구 디렉터리 자동 정리(별도 GC 불요).
            replaceUuid 미전송으로 신규 UUID 가 생성된 경우(기존 클라이언트 흐름):
              GameController.updateGame 에서 old webgl_path UUID != new UUID 이면
              assetCleanupService.purge(oldUuid) 호출(구 디렉터리 즉시 삭제, 경계검증 후).

[게임 삭제] GameController.deleteGame (CSRF + 소유/ADMIN, 기존):
            softDeleteGame 직후 assetCleanupService.purge(uuidFromWebglPath(game.webglPath))
            → /game/{uuid}/ 디렉터리 즉시 삭제(soft-delete 와 정합: 메타는 soft, 자산은 hard).
            근거: 자산은 복원 대상 아님(재업로드로 갈음), 디스크 누수 방지 우선.
```

---

## 파일 영향 맵

> 소유권 분할(implementation-advisor worker 단위 후보):
> **U-DDL**(감사 DDL/schema 동기) · **U-ZIPSEC**(ZipSecurity 헬퍼 + 추출 보강) · **U-UPLOAD**(GameUploadController 보강 + 게이트/감사) · **U-LIFECYCLE**(자산 정리 서비스 + GameController 훅) · **U-AUDIT-MAPPER**(감사 매퍼).
> 의존: U-DDL → U-AUDIT-MAPPER → U-UPLOAD. U-ZIPSEC 는 U-UPLOAD 선행. U-LIFECYCLE 는 U-UPLOAD 와 병렬 가능(독립 호출지점).

| 변경 유형 | 경로 | 역할 | 소유 |
|---|---|---|---|
| 신규 | `docs/game-upload-ddl.sql` | 권위 DDL(game_upload_audit_log). apply-local-ddl.sh 자동적용 | U-DDL |
| 수정 | `db/schema.sql` | game_upload_audit_log 블록 추가(ddl 사본) | U-DDL |
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/ZipSecurity.java` | zip-slip/심링크/엔트리명/zip bomb 검증 + 추출 코어(정석 보강). 거부 시 reasonCode 담은 예외 | U-ZIPSEC |
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/ZipRejectException.java` | reasonCode(거부 사유 enum) + message 담은 unchecked 예외. 컨트롤러가 사유코드→audit/HTTP 매핑 | U-ZIPSEC |
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java` | 진입부 게이트 통일(isAuthenticated) + 매직바이트/원본크기/MIME·확장자 AND + ZipSecurity 위임 + 임시추출→swap(U6) + replaceUuid 소유검증 + 감사기록 | U-UPLOAD |
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/GameAssetCleanupService.java` | webgl_path UUID 파싱 → 경계검증 → 디렉터리 정리(purge). 편집/삭제 훅 | U-LIFECYCLE |
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java` | updateGame(구 UUID != 신 UUID 시 purge) + deleteGame(soft-delete 후 자산 purge) 훅 | U-LIFECYCLE |
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/GameUploadAuditMapper.java` | `@Mapper` 감사 insert + (소유검증용) findGameByWebglUuid 조회. `#{}` only | U-AUDIT-MAPPER |
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/GameUploadAudit.java` | 감사 행 POJO(actorId/gameUuid/outcome/rejectReason/originalName/uploadBytes/entryCount/extractedBytes) | U-AUDIT-MAPPER |
| 수정 | `src/main/java/com/pandoli365/bibimbap/config/UploadResourceConfig.java` | **변경 없음(현황 기록)** — /game/** 는 GameAssetController 서빙(비목표). `.tmp` 디렉터리가 정적노출 안 되도록 주석 명시(ResourceHandler 미추가 유지) | (기록) |
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 매퍼(GameUploadAuditMapper)·서비스 @MockBean 등록(contextLoads — verification §30) | (검증) |
| 신규 | `src/test/.../ZipSecurityTest.java` | zip-slip/심링크/엔트리명/zip bomb/포맷검증 단위(악성 zip 픽스처) | (검증) |
| 신규 | `src/test/.../GameUploadControllerSecurityTest.java` | 게이트(401/403)/CSRF/매직바이트/원본크기/replaceUuid 소유검증/감사기록 | (검증) |

> SSR 호출지점 전수(verification-strategies 영향맵): webgl-zip 응답 JSON 키(gameUuid/webglPath/deployPath/entryCount/extractedBytes) 불변 → game-register.jsp 클라이언트(649-653 hidden 필드 교체) 무변경 동작. `replaceUuid`/`mode` 는 신규 선택 파라미터라 기존 호출 깨짐 0.

### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
```java
// ZipSecurity — zip 검증·추출 코어. static 유틸 + 추출은 인스턴스 불요(상한은 상수).
// 추출 결과(entryCount/extractedBytes)는 record 반환.
ExtractResult extractZip(MultipartFile file,   // zip 입력 스트림 출처
                         Path targetDir)        // 추출 목적지(이미 경계검증된 tmpDir)
  throws ZipRejectException                     // 거부 시 reasonCode 전파(컨트롤러가 매핑)

void assertZipType(MultipartFile file)          // MIME ∈ 화이트리스트 AND .zip 확장자(NOT_ZIP)
void assertMagic(MultipartFile file)            // 첫 4바이트 PK\x03\x04(또는 PK\x05\x06 빈zip) (MAGIC_FAIL)
void assertUnityBuild(Path extractedRoot)       // loader/framework/data/wasm 존재(INCOMPLETE_BUILD)

// 내부(private) — 인자 최소화. targetDir 경계는 호출자가 1회 검증 후 루프 내 재사용.
String normalizeAndValidateEntry(String entryName, Path targetDir)  // 사전거부+경계, 반환=정규화된 target 경로 문자열은 불요 → Path 반환 검토(구현 1보)

// GameAssetCleanupService — webgl_path 기반 자산 정리. gameRoot 는 @Value 주입(컨트롤러와 동일 경로).
void purgeByWebglPath(String webglPath)         // webgl_path 에서 UUID 파싱 → 경계 내 디렉터리 삭제

// GameUploadAuditMapper (@Mapper, #{} only)
int insertAudit(GameUploadAudit audit)          // 단일 POJO 인자(필드 다수 → 1 POJO 가 정석)
GameAssetOwner findGameByWebglUuid(String gameUuid)  // replaceUuid 소유검증용(user_id 반환). null=대상없음
```
> inflate 마킹(concern 2): `normalizeAndValidateEntry` 의 `targetDir` 는 경계검증(startsWith) 에 필요하므로 사용 확정. 단 반환 타입(String vs Path)은 호출 루프가 Path 를 바로 쓰는지로 결정 — 구현 1보에서 Path 반환으로 시작하고 String 가공이 불요하면 그대로. `extractZip` 의 상한값(8000/256MB/512MB)은 인자가 아니라 ZipSecurity 상수로 둔다(호출자가 매번 안 넘김 — inflate 방지).

---

## 대안 비교

| 주제 | 안 | 장점 | 단점 | 채택 |
|---|---|---|---|---|
| 심링크 방어 | (A) 쓰기 직전 부모 toRealPath 경계 재검증 + isSymbolicLink 거부 | 표준 NIO API 만, 플랫폼 독립, 정석 | 디렉터리 walk 비용(엔트리당) | **채택** |
| | (B) ZipFile + external attributes 모드비트 직접 파싱(0xA000 심링크) | 추출 전 차단 | java.util.zip.ZipEntry 가 unix mode 미노출(직접 바이트파싱 필요), over-engineering | 기각 |
| zip bomb | (A) 엔트리당 해제상한 + 누적상한 + 엔트리수상한(3중) | ZipInputStream 만으로 측정 가능, 정석 | 압축비 직접 미측정 | **채택** |
| | (B) 엔트리 압축비(compressed/uncompressed) 임계 | 폭탄 조기탐지 | getCompressedSize() 가 -1 반환 케이스(stored/stream) → 신뢰불가 | 기각(concern 3) |
| 재업로드 교체 | (A) 임시추출 → 검증 → ATOMIC_MOVE swap | 실패 시 기존 자산 무손상, 부분추출 잔여 0, 멱등 | tmp 디렉터리·디스크 일시 2배 | **채택** |
| | (B) 최종 디렉터리 직접 추출(기존) + 실패 시 통째 삭제 | 단순 | 교체 중 실패 시 기존 자산 손실, 부분추출 노출 | 기각 |
| 권한 게이트 | (A) 컨트롤러 진입부 게이트 헬퍼(isAuthenticated + 옵션 has) | 개방/제한 분기 유연, W1 인프라 재사용, 신규 0 | 호출지점 결합 | **채택** |
| | (B) RbacInterceptor 에 /api/game-files/** 등록 | 선언적 | isAdmin 전용이라 로그인개방 부적합, 인터셉터 개조 필요 | 기각 |
| 업로드 메타 | (A) games.webgl_path 재사용 + 감사로그만 신설 | 메타중복 0, 정규화 정석 | 감사는 별도 | **채택** |
| | (B) game_uploads 메타테이블 신설 | 업로드 이력 풍부 | webgl_path 와 중복, over-engineering | 기각 |

---

## 롤아웃 / 마이그레이션

### 순서
1. **스키마 적용**: `docs/game-upload-ddl.sql` → `db/apply-local-ddl.sh`(로컬) / 운영 동일 멱등 DDL. game_upload_audit_log 신설(추가만, 파괴 0). 기존 데이터 무영향.
2. **코드 배포**: ZipSecurity/ZipRejectException → GameUploadAuditMapper/POJO → GameUploadController 보강 → GameAssetCleanupService + GameController 훅.
3. **검증**: 악성 zip 픽스처(심링크/`../`/백슬래시/bomb/비-Unity) 거부 + 정상 Unity 빌드 통과 L1 + L3 스모크(실제 zip 업로드→서빙).

### 역호환
- webgl-zip 응답 JSON 키 불변 → game-register.jsp 클라이언트 무변경. `replaceUuid`/`mode` 미전송 시 기존 동작(신규 UUID, 일반 업로드) 100% 보존.
- 기존 업로드된 `/game/{uuid}/` 자산은 그대로 서빙(GameAssetController 불변). 기존 고아 디렉터리는 본 배포가 소급 정리하지 않음(신규 업로드/삭제부터 정리 적용 — 소급 정리는 별도 배치, 비목표).
- 검증 강화로 **기존엔 통과하던 비정상 zip(예: index.html 만 있고 Build 없는 zip)이 거부**될 수 있다 → 이는 의도된 보강(정석). 운영 공지 필요(documentation-advisor).

### 롤백
- 코드 롤백: ZipSecurity 위임 전 직접 추출 로직으로 복귀 시 심링크/포맷검증만 사라짐(zip-slip prefix 검증은 기존부터 존재). 게이트 호출은 isAuthenticated → sessionUserId==null 복귀.
- 스키마 롤백: game_upload_audit_log 는 추가 전용 → drop 없이 잔존 무해(비파괴).
- 자산 정리(purge) 롤백: 훅 제거 시 고아 파일 재발생(기능 후퇴)뿐 데이터 손상 없음.

---

## AC 매핑

| AC | 요구 | 만족 설계 요소 | 비고 |
|---|---|---|---|
| AC-1 | zip-slip(`../`/절대/백슬래시) 거부 | validateEntryName 사전거부 + normalize startsWith + toRealPath 재검증 | S2, ZIP_SLIP/BAD_ENTRY_NAME |
| AC-2 | **심볼릭 링크 엔트리/디렉터리 거부** | assertParentNotSymlink + 쓰기 후 toRealPath 경계 재검증 | S2, SYMLINK (조사 최대 구멍 보강) |
| AC-3 | zip bomb 방어 | 엔트리당 256MB + 누적 512MB + 엔트리수 8000 (3중상한) | S2, ENTRY/EXTRACTED/TOO_MANY |
| AC-4 | Unity 빌드 포맷 검증 | assertUnityBuild(loader+framework+data+wasm 존재, 압축변형 허용) + index.html | S1, INCOMPLETE_BUILD/NO_INDEX |
| AC-5 | 매직바이트 + MIME·확장자 AND | assertMagic(PK) + assertZipType(MIME AND .zip) | S1, MAGIC_FAIL/NOT_ZIP |
| AC-6 | 원본 크기 상한 | file.getSize() > 512MB → 413 | S1, TOO_LARGE |
| AC-7 | 저장 경로 boundary | tmpDir/finalDir assertWithinBoundary(gameRoot canonical) | S1, D4 |
| AC-8 | 권한 게이트(로그인 개방 유지 + 잼 옵션) | isAuthenticated 명시 + mode=jam 시 has(GAME_JAM_MANAGE) | §게이트연동, D5 |
| AC-9 | UUID 재업로드 원자적 교체 + 멱등 | replaceUuid → 임시추출 → ATOMIC_MOVE swap → 구버전 정리 | S1, U6 |
| AC-10 | 자산 정리(고아 방지) | 편집 UUID 변경 시 purge + soft-delete 시 purge | S3, U7 |
| AC-11 | 업로드 성공/거부 감사 | game_upload_audit_log insert(outcome + reject_reason) | §데이터모델, U8 |
| AC-12 | CSRF 전수 + `${}` 0 | CsrfTokens.isValid(기존) + 감사 매퍼 `#{}` only | §외부계약, AC-T 아래 |
| AC-13 | /game/** 서빙 현행 유지 | GameAssetController 불변, ResourceHandler 미추가 기록 | 비목표, 조사 포인트5 |

---

## 검증 포인트 (verification-advisor 점검 대상)

> L레벨 매핑(verification-strategies.md): 보안 입력검증/경로 boundary/권한 = **L1+L2+L3**. zip 추출은 실파일시스템 의존 → **L1(악성 zip 픽스처 단위) + L3(실 업로드→서빙 스모크)**. 신규 매퍼 SQL = **L1+L2(DB-방언 계약)**. 신규 매퍼·서비스 의존 → full `./mvnw -o test` + @MockBean(verification §30).

### 시나리오 검증
- **VP-1 (AC-1/2 경로탈출·심링크, L1)**: ZipSecurityTest — `../escape`, `/abs/path`, `..\\win`, 심링크 엔트리(부모가 심링크인 케이스 포함) zip 픽스처가 각각 ZIP_SLIP/BAD_ENTRY_NAME/SYMLINK 로 거부 + 대상 디렉터리 밖에 파일 0.
- **VP-2 (AC-3 zip bomb, L1)**: 9000 엔트리 / 단일 300MB 엔트리 / 누적 600MB zip 이 각각 TOO_MANY_ENTRIES/ENTRY_TOO_LARGE/EXTRACTED_TOO_LARGE 로 거부. 부분추출 잔여(tmpDir) 정리 확인.
- **VP-3 (AC-4 포맷, L1)**: index.html 만 있고 Build 없는 zip → INCOMPLETE_BUILD. 정상 Unity 빌드(loader+framework+data+wasm, .br 변형 포함) → 통과.
- **VP-4 (AC-5/6 타입·크기, L1)**: 비-zip(MIME 위조 또는 매직 불일치) → NOT_ZIP/MAGIC_FAIL. 513MB → TOO_LARGE(413).
- **VP-5 (AC-8 게이트, L1+L3)**: 미인증 → 401. mode=jam + GAME_JAM_MANAGE 없음 → 403. 일반 업로드(로그인만) → 통과(개방 유지 회귀).
- **VP-6 (AC-9 교체 멱등, L1+L3)**: replaceUuid 로 재업로드 → 같은 UUID, 구 파일 교체, 디렉터리 1개만 잔존. 교체 중 검증실패 시 기존 자산 무손상.
- **VP-7 (AC-10 정리, L1)**: GameAssetCleanupService.purgeByWebglPath — 경계 내 UUID 만 삭제, 경계 밖/비-UUID 입력은 무동작(거부).
- **VP-8 (AC-11 감사, L1+L2)**: 성공/각 거부사유가 game_upload_audit_log 에 outcome+reject_reason 으로 기록(DB-방언 계약: insertAudit 컬럼↔POJO 정합).
- **VP-9 (contextLoads, L1)**: BibimbapApplicationTests 에 GameUploadAuditMapper·GameAssetCleanupService @MockBean 등록 후 PASS(§30).

### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
> self-audit(시점): 아래 카운트는 모두 **본 W3-5 가 신규 생성하는 정적 산출물**(reason 카탈로그/거부분기/매퍼)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리(work-session) 카운트는 사용 안 함.
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 — reason 코드는 **enum 멤버 ↔ DDL CHECK 주석 ↔ 컨트롤러 매핑** 의 구조적 동등성에 앵커. enum values() 순회로 추가 시 자동 동기되는 불변식 우선.

- **AC-T1 거부 사유코드 전수 11종 정합** — reject_reason 카탈로그(NOT_ZIP/MAGIC_FAIL/TOO_LARGE/TOO_MANY_ENTRIES/ENTRY_TOO_LARGE/EXTRACTED_TOO_LARGE/ZIP_SLIP/SYMLINK/BAD_ENTRY_NAME/NO_INDEX/INCOMPLETE_BUILD) 11종이 **ZipRejectException 의 reason enum 멤버 수 == 본 설계 카탈로그 표 행수 == 11**. 검증: enum 멤버 `grep -c` == 11 AND DDL COMMENT 의 사유 나열 토큰 수 == 11(`docs/game-upload-ddl.sql` 의 reject_reason COMMENT 내 `/` 구분 토큰 == 11). 코드 enum 이 단일 정의처 — 사유 추가 시 enum/표/DDL주석 3곳 동기 누락을 갯수 1로 동시 검출.
- **AC-T2 zip 검증 게이트 전수 — webgl-zip 핸들러가 검증 단계를 모두 통과 후 추출** : assertZipType/assertMagic/원본크기/extractZip(내부 4상한+slip+symlink) 의 진입 호출이 컨트롤러에 존재. 검증: GameUploadController.uploadWebglZip 본문에 `assertZipType`·`assertMagic`·`extractZip` 호출 grep 각 ≥1 (3개 호출지점 전수). 누락 시 검증 우회.
- **AC-T3 상태변경 엔드포인트 CSRF 가드 전수** — GameUploadController 의 상태변경 핸들러(uploadGameFiles/uploadWebglZip/uploadThumbnail) 전수 `CsrfTokens.isValid` 선검증: `grep -c 'CsrfTokens.isValid' GameUploadController.java` == @PostMapping 핸들러 수(3). 핸들러 추가 시 가드 누락 동시 검출.
- **AC-T4 권한 게이트 진입 전수** — 상태변경 핸들러 3개 전수 `permissionGate.isAuthenticated`(또는 동치 로그인 게이트) 호출: 각 핸들러에 인증 게이트 1회 존재(임의 role 직접체크 0). 검증: `permissionGate.isAuthenticated` grep ≥ 상태변경 핸들러 수, AND `session.getAttribute("role")` 직접 비교 0건(임시체크 금지 — 정석 원칙).
- **AC-T5 업로드/감사 매퍼 `${}` 0건** — 신규 매퍼(GameUploadAuditMapper) 에 `${` 매치 0: `grep -c '\${' GameUploadAuditMapper.java` == 0 (AC-12, #{} only 표준).
- **AC-T6 Unity 필수 산출물 검증 집합 전수 4종** — assertUnityBuild 가 검사하는 산출물 카테고리(loader/framework/data/wasm) 4종이 코드 검사 목록에 전수 존재. 검증: assertUnityBuild 내 마커 문자열(`loader`/`framework`/`.data`/`.wasm`) 4종 grep 각 ≥1. 카테고리 추가/삭제 시 동시 검출.

---

## 잔여 오픈 질문
없음(0). 확정 결정 D1~D8 + /game/** 현행유지(조사 stale 정정)로 전 항목 닫음. 다음은 오픈 질문이 아니라 **구현 단계 점검사항**으로 `concerns` 에 이관:
- 신규 게이트 메서드 추가 금지(기존 has/require 재사용, concern 1).
- ZipSecurity 헬퍼 시그니처 dead parameter 재확인(concern 2).
- 압축비 미사용·getCompressedSize 의존 금지(concern 3).
- gameRoot() 경로 이중중첩 정정은 스코프 밖(concern 4 — orchestrator 에스컬레이션).
- 심링크 거부 구현 방식은 toRealPath 재검증 채택, external-attributes 파싱 미채택(concern 6).
