bibimbap/.atp/work-session/20260623-104307/implementation/W3-5-upload-design.md

40 KiB

phase agent agent_version generated_at workstream concerns concerns_checked self_verification references
design design-advisor 1 2026-06-23T12:10:00+09:00 W3-5-Unity WebGL 빌드 업로드 자동화 (보안 보강)
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) 방식 준수.
true
checklist_passed
true
requirements research adrs
docs/work-log/2026-06-17-w3-feature-skeletons.md .atp/work-session/20260623-104307/research/W3-5-upload-research.md
.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.shdocs/*-ddl.sql 글롭 알파벳순 멱등 적용(ON_ERROR_STOP, search_path=dev). schema.sql 에 동기 사본. 업로드 메타는 games.webgl_path 활용 — games 테이블 변경 0.

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

-- 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/**InterceptorConfigRbacInterceptor등록하지 않는다. 이유: 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 방지)

// 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): normalizeAndValidateEntrytargetDir 는 경계검증(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.sqldb/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).