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

14 KiB

phase agent agent_version generated_at concerns concerns_checked source_confidence workers_spawned self_verification
research research-advisor 2 2026-06-23T02:29:01Z
stale-skeleton-fact: W3 skeleton(2026-06-17) §코드현황5 의 '/game/** 정적 핸들러 미등록 → 서빙 미보장(QG-3)' 전제는 코드상 뒤집힘 — GameAssetController 가 /game/{gameUuid}/** 를 @Controller 핸들러로 서빙한다. 설계 진입 전 골자의 QG-3 문구 정정 필요.
true high 0
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 방어에 심볼릭 링크 구멍: extractZiptarget.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.resolveAssetFileUriUtils.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.addInterceptorsregistry.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}/ 디렉터리는 삭제되지 않음 → 고아 파일. — 확인됨
    • 게임 삭제: deleteGamesoftDeleteGame(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 범위 밖).