From 08d191ff2eb3ba65157897507cf63f44d174da22 Mon Sep 17 00:00:00 2001 From: art Date: Mon, 29 Jun 2026 12:10:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(upload):=20W3-5=20Unity=20WebGL=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B3=B4=EC=95=88=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95=20=E2=80=94=20zip-slip/=EC=8B=AC=EB=A7=81=ED=81=AC/zi?= =?UTF-8?q?p=20bomb/=ED=8F=AC=EB=A7=B7=EA=B2=80=EC=A6=9D/=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=EA=B2=8C=EC=9D=B4=ED=8A=B8/=EC=9E=90=EC=82=B0=20?= =?UTF-8?q?=EC=83=9D=EB=AA=85=EC=A3=BC=EA=B8=B0/=EA=B0=90=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ZipSecurity 추출 코어: validateEntryName 사전거부(절대/UNC/백슬래시/NUL/`..`/길이>255/깊이>32) + normalize startsWith 경계 + assertParentNotSymlink(부모 walk) + 쓰기후 toRealPath 재검증(zip-slip 심층방어). 기존 골격 보강(재작성 아님) - zip bomb 3중 상한(엔트리당 256MB/누적 512MB/엔트리수 8000), 실제 읽은 바이트 누적(getCompressedSize 미사용). 포맷: 매직바이트 PK + MIME·확장자 AND + Unity 산출물(loader/framework/data/wasm + index.html, .br/.gz 변형 허용) - 권한 게이트 통일: 진입부 PermissionGate.isAuthenticated(임시 role 직접체크 0, 신규 게이트 메서드 0) + mode=jam 시 GAME_JAM_MANAGE 옵션. 일반 업로드 개방 유지 - 원자적 교체: 임시추출(.tmp/{uuid})→검증→ATOMIC_MOVE swap(미지원 REPLACE 폴백 + finally 복원). replaceUuid 소유검증(UUID 정규화→LIKE 와일드카드 주입 차단, 불일치/비소유 403) - 자산 생명주기: GameAssetCleanupService.purgeByWebglPath(경계검증) + GameController updateGame/deleteGame 훅(고아 정리) - 감사: game_upload_audit_log(outcome CHECK·reject_reason 11코드 enum↔DDL 정합) docs/game-upload-ddl.sql 권위 + schema.sql 동기. GameUploadAuditMapper #{} only - /game/** 서빙 현행 유지(GameAssetController 불변, 신설 안 함 — 조사 stale 정정) 검증: 컨테이너 ./mvnw -o test 318/318 GREEN(신규 31: ZipSecurityTest 20·GameUploadControllerSecurityTest 11), 회귀 0. L2 격리 throwaway DB contract PASS(insertAudit 8컬럼·outcome CHECK·FK·findGameByWebglUuid alias). adversarial zip-slip 감사 SOUND(심링크 TOCTOU 구조적 불가·경로탈출/유니코드/zipbomb/LIKE주입 전수 차단). 실 dev DB 무접촉. 알려진 잔여(배포 하드닝, LOW): 업로드 저장루트(static/game)가 WAR 정적 서빙트리 내 → 기본 classpath:/static/** 핸들러로 transient .tmp 노출 가능성. 저장루트를 서빙트리 밖으로 이전 권고(gameRoot 경로중첩 정합과 함께). transient+검증콘텐츠라 영향 제한. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD --- db/schema.sql | 46 +++ docs/game-upload-ddl.sql | 47 +++ .../controller/api/GameController.java | 16 +- .../controller/api/GameUploadController.java | 244 +++++++----- .../bibimbap/data/GameAssetOwner.java | 23 ++ .../bibimbap/data/GameUploadAudit.java | 77 ++++ .../mapper/GameUploadAuditMapper.java | 35 ++ .../bibimbap/security/ZipRejectException.java | 42 ++ .../bibimbap/security/ZipSecurity.java | 279 +++++++++++++ .../service/GameAssetCleanupService.java | 78 ++++ .../bibimbap/BibimbapApplicationTests.java | 9 + .../api/GameUploadControllerSecurityTest.java | 335 ++++++++++++++++ .../bibimbap/security/ZipSecurityTest.java | 367 ++++++++++++++++++ 13 files changed, 1505 insertions(+), 93 deletions(-) create mode 100644 docs/game-upload-ddl.sql create mode 100644 src/main/java/com/pandoli365/bibimbap/data/GameAssetOwner.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/GameUploadAudit.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/GameUploadAuditMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/security/ZipRejectException.java create mode 100644 src/main/java/com/pandoli365/bibimbap/security/ZipSecurity.java create mode 100644 src/main/java/com/pandoli365/bibimbap/service/GameAssetCleanupService.java create mode 100644 src/test/java/com/pandoli365/bibimbap/controller/api/GameUploadControllerSecurityTest.java create mode 100644 src/test/java/com/pandoli365/bibimbap/security/ZipSecurityTest.java diff --git a/db/schema.sql b/db/schema.sql index 031c3fa..fd3108a 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1021,3 +1021,49 @@ CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_items_source_guid" CREATE INDEX IF NOT EXISTS "idx_unity_feed_items_unack" ON "unity_feed_items" ("is_acknowledged", "detected_at" DESC) WHERE "is_acknowledged" = false; + +-- W3-5 Unity WebGL 업로드 감사 로그 (docs/game-upload-ddl.sql 동기 사본) +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/ENTRY_TOO_LARGE/EXTRACTED_TOO_LARGE/ZIP_SLIP/SYMLINK/BAD_ENTRY_NAME/NO_INDEX/INCOMPLETE_BUILD'; diff --git a/docs/game-upload-ddl.sql b/docs/game-upload-ddl.sql new file mode 100644 index 0000000..16db77c --- /dev/null +++ b/docs/game-upload-ddl.sql @@ -0,0 +1,47 @@ +-- 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/ENTRY_TOO_LARGE/EXTRACTED_TOO_LARGE/ZIP_SLIP/SYMLINK/BAD_ENTRY_NAME/NO_INDEX/INCOMPLETE_BUILD'; diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java index 67173a9..e56a2ef 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java @@ -7,6 +7,7 @@ import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GameViewsMapper; import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.security.CsrfTokens; +import com.pandoli365.bibimbap.service.GameAssetCleanupService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import org.springframework.beans.factory.annotation.Value; @@ -33,6 +34,7 @@ public class GameController { private final GameCommentsMapper gameCommentsMapper; private final GameReviewsMapper gameReviewsMapper; private final GameViewsMapper gameViewsMapper; + private final GameAssetCleanupService assetCleanupService; @Value("${app.webgl.asset-origin:}") private String webglAssetOrigin; @@ -40,11 +42,13 @@ public class GameController { public GameController(GamesMapper gamesMapper, GameCommentsMapper gameCommentsMapper, GameReviewsMapper gameReviewsMapper, - GameViewsMapper gameViewsMapper) { + GameViewsMapper gameViewsMapper, + GameAssetCleanupService assetCleanupService) { this.gamesMapper = gamesMapper; this.gameCommentsMapper = gameCommentsMapper; this.gameReviewsMapper = gameReviewsMapper; this.gameViewsMapper = gameViewsMapper; + this.assetCleanupService = assetCleanupService; } public static String webglUrlForGame(int gameId) { @@ -225,6 +229,7 @@ public class GameController { return response(HttpStatus.BAD_REQUEST, "소개는 600자 이하로 입력해 주세요."); } + String oldWebglPath = existing.getWebglPath(); existing.setName(normalizedName); existing.setCreatorNote(normalizedCreatorNote); existing.setGitUrl(safeExternalUrl(gitUrl)); @@ -233,6 +238,13 @@ public class GameController { existing.setVisible(isChecked(visible)); gamesMapper.updateGame(existing); + // 자산 생명주기(U7): webgl 경로의 UUID 가 교체되면 구 디렉터리 정리(고아 방지) + String oldUuid = gameUuidFromPath(oldWebglPath); + String newUuid = gameUuidFromPath(normalizedWebglPath); + if (!oldUuid.isBlank() && !oldUuid.equals(newUuid)) { + assetCleanupService.purgeByWebglPath(oldWebglPath); + } + Map body = new LinkedHashMap<>(); body.put("status", 200); body.put("message", "게임이 수정되었습니다."); @@ -266,6 +278,8 @@ public class GameController { gamesMapper.softDeleteGameReviews(id); gamesMapper.deleteGameLikes(id); gamesMapper.softDeleteGame(id); + // 자산 생명주기(U7): 메타는 soft-delete, 자산은 hard-delete(재업로드로 갈음, 디스크 누수 방지) + assetCleanupService.purgeByWebglPath(existing.getWebglPath()); Map body = new LinkedHashMap<>(); body.put("status", 200); diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java index e614ab0..d3e2102 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java @@ -1,6 +1,14 @@ package com.pandoli365.bibimbap.controller.api; +import com.pandoli365.bibimbap.data.GameAssetOwner; +import com.pandoli365.bibimbap.data.GameUploadAudit; +import com.pandoli365.bibimbap.mapper.GameUploadAuditMapper; import com.pandoli365.bibimbap.security.CsrfTokens; +import com.pandoli365.bibimbap.security.PermissionGate; +import com.pandoli365.bibimbap.security.PermissionKeys; +import com.pandoli365.bibimbap.security.Roles; +import com.pandoli365.bibimbap.security.ZipRejectException; +import com.pandoli365.bibimbap.security.ZipSecurity; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import org.springframework.beans.factory.annotation.Value; @@ -16,7 +24,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -29,21 +36,27 @@ import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.stream.Stream; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; @RestController @RequestMapping("/api/game-files") public class GameUploadController { private static final Logger log = LoggerFactory.getLogger(GameUploadController.class); - private static final long WEBGL_EXTRACTED_MAX_BYTES = 512L * 1024 * 1024; - private static final int WEBGL_MAX_ENTRIES = 8_000; + private static final long WEBGL_ORIGINAL_MAX_BYTES = 512L * 1024 * 1024; private static final long THUMBNAIL_MAX_BYTES = 10L * 1024 * 1024; + private static final int AUDIT_NAME_MAX_LEN = 255; @Value("${app.upload.game-storage-path:src/main/resources/static}") private String uploadStoragePath; + private final PermissionGate permissionGate; + private final GameUploadAuditMapper auditMapper; + + public GameUploadController(PermissionGate permissionGate, GameUploadAuditMapper auditMapper) { + this.permissionGate = permissionGate; + this.auditMapper = auditMapper; + } + @GetMapping("/ping") public ResponseEntity> ping() { return ResponseEntity.ok(Map.of("status", "ok")); @@ -59,7 +72,7 @@ public class GameUploadController { if (!CsrfTokens.isValid(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); } - if (sessionUserId(session) == null) { + if (!permissionGate.isAuthenticated(session)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("message", "login is required")); } @@ -100,6 +113,8 @@ public class GameUploadController { @PostMapping("/webgl-zip") public ResponseEntity> uploadWebglZip( @RequestParam(name = "file", required = false) MultipartFile file, + @RequestParam(name = "replaceUuid", required = false) String replaceUuid, + @RequestParam(name = "mode", required = false) String mode, HttpServletRequest request, HttpSession session ) throws IOException { @@ -111,52 +126,142 @@ public class GameUploadController { file == null ? null : file.getSize(), file == null ? null : file.getContentType()); Long userId = sessionUserId(session); - if (userId == null) { + if (!permissionGate.isAuthenticated(session) || userId == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("message", "로그인이 필요합니다.")); } + if ("jam".equalsIgnoreCase(mode) + && !permissionGate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("status", 403, "message", "권한이 없습니다.")); + } if (file == null || file.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("message", "WebGL zip 파일을 선택해 주세요.")); } - if (!isZipFile(file)) { - return ResponseEntity.badRequest().body(Map.of("message", "zip 파일만 업로드할 수 있습니다.")); + + String originalName = sanitizeAuditName(file.getOriginalFilename()); + long uploadBytes = file.getSize(); + + try { + ZipSecurity.assertZipType(file); + } catch (ZipRejectException e) { + audit(userId, null, "REJECTED", e.reason().name(), originalName, uploadBytes, null, null); + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + try { + ZipSecurity.assertMagic(file); + } catch (ZipRejectException e) { + audit(userId, null, "REJECTED", e.reason().name(), originalName, uploadBytes, null, null); + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + if (uploadBytes > WEBGL_ORIGINAL_MAX_BYTES) { + audit(userId, null, "REJECTED", ZipRejectException.Reason.TOO_LARGE.name(), + originalName, uploadBytes, null, null); + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(Map.of("message", "업로드 파일 크기가 허용 한도를 초과했습니다.")); + } + + String newUuid; + String normalizedReplace = normalizeGameUuid(replaceUuid); + if (normalizedReplace != null) { + GameAssetOwner owner = auditMapper.findGameByWebglUuid(normalizedReplace); + boolean isAdmin = Roles.ADMIN.equals(session.getAttribute("role")); + if (owner == null || (!userId.equals(owner.getUserId()) && !isAdmin)) { + audit(userId, normalizedReplace, "REJECTED", null, originalName, uploadBytes, null, null); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("status", 403, + "message", "교체 대상 게임의 소유자만 재업로드할 수 있습니다.")); + } + newUuid = normalizedReplace; + } else { + newUuid = UUID.randomUUID().toString(); } Path root = gameRoot(); - String gameUuid = UUID.randomUUID().toString(); - Path targetDir = root.resolve(gameUuid).normalize(); - if (!targetDir.startsWith(root)) { + Path tmpDir = root.resolve(".tmp").resolve(newUuid).normalize(); + if (!tmpDir.startsWith(root)) { + audit(userId, newUuid, "REJECTED", ZipRejectException.Reason.ZIP_SLIP.name(), + originalName, uploadBytes, null, null); return ResponseEntity.badRequest().body(Map.of("message", "저장 경로가 올바르지 않습니다.")); } + Path finalDir = root.resolve(newUuid).normalize(); + if (!finalDir.startsWith(root)) { + audit(userId, newUuid, "REJECTED", ZipRejectException.Reason.ZIP_SLIP.name(), + originalName, uploadBytes, null, null); + return ResponseEntity.badRequest().body(Map.of("message", "저장 경로가 올바르지 않습니다.")); + } + Path backupDir = root.resolve(".tmp").resolve(newUuid + ".old").normalize(); try { - Files.createDirectories(targetDir); - ExtractResult extractResult = extractZip(file, targetDir); - Path indexFile = findIndexFile(targetDir); - if (indexFile == null) { - deleteRecursively(targetDir); - return ResponseEntity.badRequest().body(Map.of("message", "zip 안에서 index.html을 찾지 못했습니다.")); - } + deleteRecursively(tmpDir); + Files.createDirectories(tmpDir); + ZipSecurity.ExtractResult extractResult = ZipSecurity.extractZip(file, tmpDir); - String webglPath = "/game/" + root.relativize(indexFile).toString().replace('\\', '/'); - String deployPath = "/game/" + root.relativize(targetDir).toString().replace('\\', '/'); + Path indexFile = findIndexFile(tmpDir); + if (indexFile == null) { + deleteRecursively(tmpDir); + audit(userId, newUuid, "REJECTED", ZipRejectException.Reason.NO_INDEX.name(), + originalName, uploadBytes, null, null); + return ResponseEntity.badRequest() + .body(Map.of("message", "zip 안에서 index.html을 찾지 못했습니다.")); + } + ZipSecurity.assertUnityBuild(tmpDir); + + String relIndex = tmpDir.relativize(indexFile).toString().replace('\\', '/'); + + Files.createDirectories(finalDir.getParent()); + boolean hadExisting = Files.exists(finalDir); + if (hadExisting) { + deleteRecursively(backupDir); + Files.move(finalDir, backupDir); + } + try { + Files.move(tmpDir, finalDir, StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException ex) { + Files.move(tmpDir, finalDir, StandardCopyOption.REPLACE_EXISTING); + } + deleteRecursively(backupDir); + + String webglPath = "/game/" + newUuid + "/" + relIndex; + String deployPath = "/game/" + newUuid + "/"; Map response = new LinkedHashMap<>(); response.put("status", 200); response.put("message", "WebGL 파일이 압축 해제되었습니다."); - response.put("gameUuid", gameUuid); + response.put("gameUuid", newUuid); response.put("webglPath", webglPath); response.put("deployPath", deployPath); response.put("entryCount", extractResult.entryCount()); response.put("extractedBytes", extractResult.extractedBytes()); log.info("WebGL zip upload completed. gameUuid={}, webglPath={}, entryCount={}, extractedBytes={}", - gameUuid, webglPath, extractResult.entryCount(), extractResult.extractedBytes()); + newUuid, webglPath, extractResult.entryCount(), extractResult.extractedBytes()); + audit(userId, newUuid, "SUCCESS", null, originalName, uploadBytes, + extractResult.entryCount(), extractResult.extractedBytes()); return ResponseEntity.ok(response); - } catch (IllegalArgumentException e) { - deleteRecursively(targetDir); - return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } catch (ZipRejectException e) { + deleteRecursively(tmpDir); + audit(userId, newUuid, "REJECTED", e.reason().name(), originalName, uploadBytes, null, null); + HttpStatus status = e.reason() == ZipRejectException.Reason.TOO_LARGE + ? HttpStatus.PAYLOAD_TOO_LARGE + : HttpStatus.BAD_REQUEST; + return ResponseEntity.status(status).body(Map.of("message", e.getMessage())); } catch (IOException e) { - deleteRecursively(targetDir); + deleteRecursively(tmpDir); throw e; + } finally { + // 불변식: 실패 시 tmp/backup 만 정리하고 기존 finalDir 는 무손상 유지. + // swap 도중 구버전만 backup 으로 대피된 상태(finalDir 비어있음)면 복원한다. + try { + deleteRecursively(tmpDir); + if (Files.exists(backupDir)) { + if (!Files.exists(finalDir)) { + Files.move(backupDir, finalDir); + } else { + deleteRecursively(backupDir); + } + } + } catch (IOException cleanupEx) { + log.warn("webgl-zip cleanup failed for {}: {}", newUuid, cleanupEx.getMessage()); + } } } @@ -170,8 +275,7 @@ public class GameUploadController { if (!CsrfTokens.isValid(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); } - Long userId = sessionUserId(session); - if (userId == null) { + if (!permissionGate.isAuthenticated(session)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("message", "로그인이 필요합니다.")); } @@ -237,70 +341,29 @@ public class GameUploadController { return null; } - private boolean isZipFile(MultipartFile file) { - String contentType = file.getContentType(); - if (contentType != null) { - String normalized = contentType.toLowerCase(Locale.ROOT); - if ("application/zip".equals(normalized) - || "application/x-zip-compressed".equals(normalized) - || "multipart/x-zip".equals(normalized)) { - return true; - } + private void audit(Long actorId, String gameUuid, String outcome, String rejectReason, + String originalName, Long uploadBytes, Integer entryCount, Long extractedBytes) { + try { + GameUploadAudit a = new GameUploadAudit(); + a.setActorId(actorId); + a.setGameUuid(gameUuid); + a.setOutcome(outcome); + a.setRejectReason(rejectReason); + a.setOriginalName(originalName); + a.setUploadBytes(uploadBytes); + a.setEntryCount(entryCount); + a.setExtractedBytes(extractedBytes); + auditMapper.insertAudit(a); + } catch (Exception ex) { + log.warn("upload audit insert failed: {}", ex.getMessage()); } - String originalName = file.getOriginalFilename(); - return originalName != null && originalName.toLowerCase(Locale.ROOT).endsWith(".zip"); } - private ExtractResult extractZip(MultipartFile zipFile, Path targetDir) throws IOException { - long extractedBytes = 0; - int entryCount = 0; - - try (ZipInputStream zipInput = new ZipInputStream(zipFile.getInputStream())) { - ZipEntry entry; - while ((entry = zipInput.getNextEntry()) != null) { - entryCount++; - if (entryCount > WEBGL_MAX_ENTRIES) { - throw new IllegalArgumentException("zip 안의 파일 수가 너무 많습니다."); - } - - Path target = targetDir.resolve(entry.getName()).normalize(); - if (!target.startsWith(targetDir)) { - throw new IllegalArgumentException("zip 안에 올바르지 않은 경로가 포함되어 있습니다."); - } - - if (entry.isDirectory()) { - Files.createDirectories(target); - } else { - if (target.getParent() == null) { - throw new IllegalArgumentException("zip 안에 올바르지 않은 파일이 포함되어 있습니다."); - } - Files.createDirectories(target.getParent()); - extractedBytes += copyZipEntry(zipInput, target, extractedBytes); - } - zipInput.closeEntry(); - } + private String sanitizeAuditName(String name) { + if (name == null) { + return null; } - - if (entryCount == 0) { - throw new IllegalArgumentException("비어 있는 zip 파일입니다."); - } - return new ExtractResult(entryCount, extractedBytes); - } - - private long copyZipEntry(InputStream input, Path target, long bytesBeforeEntry) throws IOException { - byte[] buffer = new byte[8192]; - long copied = 0; - try (var output = Files.newOutputStream(target)) { - int read; - while ((read = input.read(buffer)) != -1) { - copied += read; - if (bytesBeforeEntry + copied > WEBGL_EXTRACTED_MAX_BYTES) { - throw new IllegalArgumentException("압축 해제된 파일 크기가 너무 큽니다."); - } - output.write(buffer, 0, read); - } - } - return copied; + return name.length() > AUDIT_NAME_MAX_LEN ? name.substring(0, AUDIT_NAME_MAX_LEN) : name; } private Path findIndexFile(Path targetDir) throws IOException { @@ -418,7 +481,4 @@ public class GameUploadController { } return UUID.randomUUID() + "_" + cleanName; } - - private record ExtractResult(int entryCount, long extractedBytes) { - } } diff --git a/src/main/java/com/pandoli365/bibimbap/data/GameAssetOwner.java b/src/main/java/com/pandoli365/bibimbap/data/GameAssetOwner.java new file mode 100644 index 0000000..3f29139 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/GameAssetOwner.java @@ -0,0 +1,23 @@ +package com.pandoli365.bibimbap.data; + +public class GameAssetOwner { + + private Long gameId; + private Long userId; + + public Long getGameId() { + return gameId; + } + + public void setGameId(Long gameId) { + this.gameId = gameId; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/GameUploadAudit.java b/src/main/java/com/pandoli365/bibimbap/data/GameUploadAudit.java new file mode 100644 index 0000000..140ee42 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/GameUploadAudit.java @@ -0,0 +1,77 @@ +package com.pandoli365.bibimbap.data; + +public class GameUploadAudit { + + private Long actorId; + private String gameUuid; + private String outcome; + private String rejectReason; + private String originalName; + private Long uploadBytes; + private Integer entryCount; + private Long extractedBytes; + + public Long getActorId() { + return actorId; + } + + public void setActorId(Long actorId) { + this.actorId = actorId; + } + + public String getGameUuid() { + return gameUuid; + } + + public void setGameUuid(String gameUuid) { + this.gameUuid = gameUuid; + } + + public String getOutcome() { + return outcome; + } + + public void setOutcome(String outcome) { + this.outcome = outcome; + } + + public String getRejectReason() { + return rejectReason; + } + + public void setRejectReason(String rejectReason) { + this.rejectReason = rejectReason; + } + + public String getOriginalName() { + return originalName; + } + + public void setOriginalName(String originalName) { + this.originalName = originalName; + } + + public Long getUploadBytes() { + return uploadBytes; + } + + public void setUploadBytes(Long uploadBytes) { + this.uploadBytes = uploadBytes; + } + + public Integer getEntryCount() { + return entryCount; + } + + public void setEntryCount(Integer entryCount) { + this.entryCount = entryCount; + } + + public Long getExtractedBytes() { + return extractedBytes; + } + + public void setExtractedBytes(Long extractedBytes) { + this.extractedBytes = extractedBytes; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GameUploadAuditMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GameUploadAuditMapper.java new file mode 100644 index 0000000..c3fa441 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GameUploadAuditMapper.java @@ -0,0 +1,35 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.GameAssetOwner; +import com.pandoli365.bibimbap.data.GameUploadAudit; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface GameUploadAuditMapper { + + @Insert(""" + INSERT INTO game_upload_audit_log ( + actor_id, game_uuid, outcome, reject_reason, + original_name, upload_bytes, entry_count, extracted_bytes + ) VALUES ( + #{actorId}, #{gameUuid}, #{outcome}, #{rejectReason}, + #{originalName}, #{uploadBytes}, #{entryCount}, #{extractedBytes} + ) + """) + int insertAudit(GameUploadAudit audit); + + // replaceUuid 소유검증: webgl_path 가 '/game/{uuid}/...' 형태인 게임의 (id, user_id) 조회. + // null = 대상 없음(= 교체대상 게임 미존재). 컨트롤러가 user_id 와 세션 userId 비교. + @Select(""" + SELECT g.id AS gameId, g.user_id AS userId + FROM games g + WHERE g.webgl_path LIKE CONCAT('/game/', #{gameUuid}, '/%') + AND g.is_delete IS NOT TRUE + ORDER BY g.id ASC + LIMIT 1 + """) + GameAssetOwner findGameByWebglUuid(@Param("gameUuid") String gameUuid); +} diff --git a/src/main/java/com/pandoli365/bibimbap/security/ZipRejectException.java b/src/main/java/com/pandoli365/bibimbap/security/ZipRejectException.java new file mode 100644 index 0000000..3b90c24 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/security/ZipRejectException.java @@ -0,0 +1,42 @@ +package com.pandoli365.bibimbap.security; + +/** + * Unity WebGL zip 업로드의 보안/포맷 검증 실패를 표현하는 unchecked 예외. + * + *

거부 사유는 {@link Reason} enum 으로 분류한다. 컨트롤러는 {@link #getMessage()} 를 + * 클라이언트에 노출하므로 message 는 한국어 사용자 메시지로 작성한다. {@link #reason()} 은 + * 서버 로깅/감사용 코드이다. + * + *

{@link Reason} 멤버는 게임 업로드 거부 사유 DDL COMMENT 와 1:1 정합한다(검증 AC-T1: + * enum 멤버 수 == 11). 순서·이름을 임의로 바꾸지 않는다. + */ +public class ZipRejectException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** zip 업로드 거부 사유 코드. DDL COMMENT 와 1:1 정합(총 11개). */ + public enum 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 + } + + private final Reason reason; + + public ZipRejectException(Reason reason, String message) { + super(message); + this.reason = reason; + } + + public Reason reason() { + return reason; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/security/ZipSecurity.java b/src/main/java/com/pandoli365/bibimbap/security/ZipSecurity.java new file mode 100644 index 0000000..9d19364 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/security/ZipSecurity.java @@ -0,0 +1,279 @@ +package com.pandoli365.bibimbap.security; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.springframework.web.multipart.MultipartFile; + +import com.pandoli365.bibimbap.security.ZipRejectException.Reason; + +/** + * Unity WebGL zip 업로드의 보안/포맷 검증 정석 구현. + * + *

방어 대상: zip-slip(상위경로 탈출), 심링크 추적 탈출, zip bomb(엔트리 수/엔트리당/누적 + * 해제 크기), 포맷 위장(확장자·MIME·magic). 압축비 검사는 의도적으로 사용하지 않고 + * 절대 크기 3중 상한(엔트리당 256MB + 누적 512MB + 엔트리수 8000)으로 bomb 를 차단한다. + * 이를 위해 {@link ZipEntry#getCompressedSize()} 는 호출하지 않는다(신뢰 불가 메타). + * + *

모든 거부는 {@link ZipRejectException} (unchecked) 으로 한국어 사용자 메시지와 함께 + * throw 한다. 인스턴스화하지 않는 static 유틸이다. + */ +public final class ZipSecurity { + + /** 엔트리당 해제 상한(256MB). */ + private static final long ENTRY_MAX_BYTES = 256L * 1024 * 1024; + /** 누적 해제 상한(512MB). */ + private static final long EXTRACTED_MAX_BYTES = 512L * 1024 * 1024; + /** 엔트리 수 상한. */ + private static final int MAX_ENTRIES = 8_000; + /** 엔트리명 최대 길이. */ + private static final int MAX_ENTRY_NAME_LEN = 255; + /** 엔트리 경로 분절 깊이 상한. */ + private static final int MAX_ENTRY_DEPTH = 32; + + /** 복사 버퍼 크기. */ + private static final int COPY_BUFFER = 8192; + + /** MIME 화이트리스트(소문자 비교). octet-stream 은 브라우저가 zip 을 보낼 때 흔하므로 확장자로 보완. */ + private static final Set ALLOWED_MIME = Set.of( + "application/zip", + "application/x-zip-compressed", + "multipart/x-zip", + "application/octet-stream"); + + /** 윈도우 드라이브 문자 접두(C: 등). 백슬래시는 별도 거부하므로 콜론 형태만 검사. */ + private static final Pattern WINDOWS_DRIVE = Pattern.compile("^[A-Za-z]:"); + + private ZipSecurity() { + } + + /** 검증을 통과한 zip 해제 결과. */ + public record ExtractResult(int entryCount, long extractedBytes) { + } + + /** + * MIME(화이트리스트 또는 null) AND 확장자(.zip) 검증. 실패 시 {@link Reason#NOT_ZIP}. + * + *

확장자는 필수이고, MIME 가 non-null 이면서 화이트리스트에 없으면 거부한다. MIME 가 + * null 인 경우(클라이언트 미설정)는 확장자로 보완 허용한다. + */ + public static void assertZipType(MultipartFile file) { + String originalFilename = file.getOriginalFilename(); + boolean hasZipExt = originalFilename != null + && originalFilename.toLowerCase(Locale.ROOT).endsWith(".zip"); + if (!hasZipExt) { + throw new ZipRejectException(Reason.NOT_ZIP, "zip 파일만 업로드할 수 있습니다."); + } + + String contentType = file.getContentType(); + if (contentType != null && !ALLOWED_MIME.contains(contentType.toLowerCase(Locale.ROOT))) { + throw new ZipRejectException(Reason.NOT_ZIP, "zip 파일만 업로드할 수 있습니다."); + } + } + + /** + * 첫 4바이트 magic 검사. {@code PK\x03\x04}(일반 zip) 또는 {@code PK\x05\x06}(빈 zip) 만 + * 통과. 그 외/4바이트 미만 → {@link Reason#MAGIC_FAIL}. + * + *

스트림은 {@link MultipartFile#getInputStream()} 으로 새로 열어 4바이트만 peek 한다. + */ + public static void assertMagic(MultipartFile file) throws IOException { + byte[] head = new byte[4]; + int read; + try (InputStream in = file.getInputStream()) { + read = in.readNBytes(head, 0, 4); + } + if (read < 4) { + throw new ZipRejectException(Reason.MAGIC_FAIL, "올바른 zip 형식이 아닙니다."); + } + boolean localFile = (head[0] & 0xFF) == 0x50 && (head[1] & 0xFF) == 0x4B + && (head[2] & 0xFF) == 0x03 && (head[3] & 0xFF) == 0x04; + boolean emptyArchive = (head[0] & 0xFF) == 0x50 && (head[1] & 0xFF) == 0x4B + && (head[2] & 0xFF) == 0x05 && (head[3] & 0xFF) == 0x06; + if (!localFile && !emptyArchive) { + throw new ZipRejectException(Reason.MAGIC_FAIL, "올바른 zip 형식이 아닙니다."); + } + } + + /** + * zip 을 {@code targetDir} 하위로 해제한다. zip-slip + 심링크 추적 + 3중 크기 상한을 적용한다. + * + *

{@code targetDir} 은 호출자가 이미 경계검증한 임시 디렉터리여야 한다. + * + * @return 엔트리 수와 누적 해제 바이트 + */ + public static ExtractResult extractZip(MultipartFile file, Path targetDir) throws IOException { + long extractedBytes = 0; + int entryCount = 0; + + try (ZipInputStream zip = new ZipInputStream(file.getInputStream())) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + entryCount++; + if (entryCount > MAX_ENTRIES) { + throw new ZipRejectException(Reason.TOO_MANY_ENTRIES, "zip 파일의 항목 수가 너무 많습니다."); + } + + String name = entry.getName(); + validateEntryName(name); + + Path target = targetDir.resolve(name).normalize(); + if (!target.startsWith(targetDir)) { + throw new ZipRejectException(Reason.ZIP_SLIP, "허용되지 않은 경로의 항목이 포함되어 있습니다."); + } + + if (entry.isDirectory()) { + assertParentNotSymlink(target, targetDir); + Files.createDirectories(target); + } else { + Path parent = target.getParent(); + if (parent == null) { + throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다."); + } + Files.createDirectories(parent); + assertParentNotSymlink(target, targetDir); + + long copied = copyEntry(zip, target, extractedBytes); + extractedBytes += copied; + + // 쓰기 후 실경로 재검증: 부모가 심링크로 targetDir 밖을 가리키면 차단. + if (!target.toRealPath().startsWith(targetDir.toRealPath())) { + throw new ZipRejectException(Reason.SYMLINK, "심볼릭 링크 항목은 허용되지 않습니다."); + } + } + zip.closeEntry(); + } + } + + if (entryCount == 0) { + throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "비어 있는 zip 파일입니다."); + } + return new ExtractResult(entryCount, extractedBytes); + } + + /** + * Unity WebGL 빌드 산출물 4종(loader / framework / data / wasm)이 각각 ≥1개 존재하는지 + * 검증한다. {@code .br}/{@code .gz} 압축 변형은 마커 substring 포함으로 허용한다. 하나라도 + * 없으면 {@link Reason#INCOMPLETE_BUILD}. + * + *

index.html 검증은 컨트롤러(NO_INDEX) 책임이며 여기서는 다루지 않는다. + */ + public static void assertUnityBuild(Path extractedRoot) throws IOException { + Set fileNames = new HashSet<>(); + try (Stream walk = Files.walk(extractedRoot)) { + walk.filter(Files::isRegularFile) + .map(p -> p.getFileName().toString().toLowerCase(Locale.ROOT)) + .forEach(fileNames::add); + } + + boolean hasLoader = false; + boolean hasFramework = false; + boolean hasData = false; + boolean hasWasm = false; + for (String fileName : fileNames) { + if (fileName.contains("loader")) { + hasLoader = true; + } + if (fileName.contains("framework")) { + hasFramework = true; + } + if (fileName.contains(".data")) { + hasData = true; + } + if (fileName.contains(".wasm")) { + hasWasm = true; + } + } + + if (!hasLoader || !hasFramework || !hasData || !hasWasm) { + throw new ZipRejectException(Reason.INCOMPLETE_BUILD, "Unity WebGL 빌드 산출물이 누락되었습니다."); + } + } + + /** + * 엔트리명을 정규화 전 단계에서 사전 거부한다(zip-slip 보조 + 경로 위장 차단). 위반 시 + * {@link Reason#BAD_ENTRY_NAME}. + */ + private static void validateEntryName(String name) { + if (name == null || name.isBlank()) { + throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다."); + } + if (name.indexOf('\0') >= 0) { + throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다."); + } + // 백슬래시: 윈도우 경로 우회 차단(UNC \\..\\ 형태도 동시 차단). + if (name.indexOf('\\') >= 0) { + throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다."); + } + // 절대경로 / UNC(//) / 드라이브(C:) 명시 거부. + if (name.startsWith("/")) { + throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다."); + } + if (WINDOWS_DRIVE.matcher(name).find()) { + throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다."); + } + if (name.length() > MAX_ENTRY_NAME_LEN) { + throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다."); + } + + String[] segments = name.split("/"); + if (segments.length > MAX_ENTRY_DEPTH) { + throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다."); + } + for (String segment : segments) { + if (segment.equals("..")) { + throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다."); + } + } + } + + /** + * target 의 조상 디렉터리(targetDir 제외) 중 심링크가 있으면 {@link Reason#SYMLINK}. + * 쓰기 직전 호출해 심링크가 가리키는 외부로 파일이 새어 나가는 것을 차단한다. + */ + private static void assertParentNotSymlink(Path target, Path targetDir) { + Path p = target.getParent(); + while (p != null && p.startsWith(targetDir) && !p.equals(targetDir)) { + if (Files.exists(p) && Files.isSymbolicLink(p)) { + throw new ZipRejectException(Reason.SYMLINK, "심볼릭 링크 항목은 허용되지 않습니다."); + } + p = p.getParent(); + } + } + + /** + * 엔트리 본문을 target 으로 복사하며 엔트리당/누적 해제 상한을 강제한다. 압축비/선언 크기는 + * 신뢰하지 않고 실제 read 바이트만 누적 카운트한다. + * + * @param bytesBefore 이번 엔트리 이전까지의 누적 해제 바이트 + * @return 이번 엔트리에서 해제한 바이트 + */ + private static long copyEntry(InputStream in, Path target, long bytesBefore) throws IOException { + byte[] buf = new byte[COPY_BUFFER]; + long copied = 0; + try (OutputStream out = Files.newOutputStream(target)) { + int read; + while ((read = in.read(buf)) != -1) { + copied += read; + if (copied > ENTRY_MAX_BYTES) { + throw new ZipRejectException(Reason.ENTRY_TOO_LARGE, "개별 항목의 크기가 너무 큽니다."); + } + if (bytesBefore + copied > EXTRACTED_MAX_BYTES) { + throw new ZipRejectException(Reason.EXTRACTED_TOO_LARGE, "압축 해제 크기가 허용 한도를 초과했습니다."); + } + out.write(buf, 0, read); + } + } + return copied; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/service/GameAssetCleanupService.java b/src/main/java/com/pandoli365/bibimbap/service/GameAssetCleanupService.java new file mode 100644 index 0000000..b017977 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/service/GameAssetCleanupService.java @@ -0,0 +1,78 @@ +package com.pandoli365.bibimbap.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.UUID; +import java.util.stream.Stream; + +@Service +public class GameAssetCleanupService { + + private static final Logger log = LoggerFactory.getLogger(GameAssetCleanupService.class); + + @Value("${app.upload.game-storage-path:src/main/resources/static}") + private String uploadStoragePath; + + /** + * webgl_path("/game/{uuid}/index.html")의 UUID 디렉터리를 경계 내에서 hard-delete 한다. + * 입력이 비정상이거나 대상이 없으면 무동작(멱등). 정리 실패는 호출 트랜잭션을 깨지 않도록 삼킨다. + */ + public void purgeByWebglPath(String webglPath) { + if (webglPath == null || webglPath.isBlank()) { + return; + } + if (!webglPath.startsWith("/game/")) { + return; + } + String[] parts = webglPath.split("/"); + if (parts.length <= 2 || parts[2].isBlank()) { + return; + } + + String normalizedUuid; + try { + // 비-UUID 토큰(경계 밖/조작 입력)은 거부 + normalizedUuid = UUID.fromString(parts[2]).toString(); + } catch (IllegalArgumentException e) { + return; + } + + Path root = gameRoot(); + Path target = root.resolve(normalizedUuid).normalize(); + if (!target.startsWith(root) || target.equals(root)) { + return; + } + if (!Files.isDirectory(target)) { + return; + } + + try { + deleteRecursively(target); + } catch (IOException e) { + log.warn("게임 자산 정리 실패. webglPath={}, target={}", webglPath, target, e); + } + } + + private Path gameRoot() { + return Paths.get(uploadStoragePath).toAbsolutePath().normalize().resolve("game").normalize(); + } + + private void deleteRecursively(Path path) throws IOException { + if (!Files.exists(path)) { + return; + } + try (Stream paths = Files.walk(path)) { + for (Path target : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(target); + } + } + } +} diff --git a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java index cf47146..a6e6f9c 100644 --- a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java +++ b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java @@ -5,6 +5,7 @@ import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper; import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper; import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GameTagsMapper; +import com.pandoli365.bibimbap.mapper.GameUploadAuditMapper; import com.pandoli365.bibimbap.mapper.GameViewsMapper; import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.mapper.JamAwardsMapper; @@ -34,6 +35,7 @@ import com.pandoli365.bibimbap.mapper.UsersMapper; import com.pandoli365.bibimbap.security.JamRoleGate; import com.pandoli365.bibimbap.security.PermissionGate; import com.pandoli365.bibimbap.security.SsrfSafeFetcher; +import com.pandoli365.bibimbap.service.GameAssetCleanupService; import com.pandoli365.bibimbap.service.OgPreviewService; import com.pandoli365.bibimbap.service.PostMarkdownService; import com.pandoli365.bibimbap.service.UnityFeedPoller; @@ -162,6 +164,13 @@ class BibimbapApplicationTests { @MockBean private UnityFeedPoller unityFeedPoller; + // W3-5 Unity WebGL 업로드 보안 보강 신규 빈(매퍼·서비스 — contextLoads 안정화) + @MockBean + private GameUploadAuditMapper gameUploadAuditMapper; + + @MockBean + private GameAssetCleanupService gameAssetCleanupService; + @Test void contextLoads() { } diff --git a/src/test/java/com/pandoli365/bibimbap/controller/api/GameUploadControllerSecurityTest.java b/src/test/java/com/pandoli365/bibimbap/controller/api/GameUploadControllerSecurityTest.java new file mode 100644 index 0000000..5a3ac38 --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/controller/api/GameUploadControllerSecurityTest.java @@ -0,0 +1,335 @@ +package com.pandoli365.bibimbap.controller.api; + +import com.pandoli365.bibimbap.data.GameAssetOwner; +import com.pandoli365.bibimbap.data.GameUploadAudit; +import com.pandoli365.bibimbap.mapper.GameUploadAuditMapper; +import com.pandoli365.bibimbap.security.PermissionGate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Map; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * GameUploadController 보안 게이트(VP-4/5/6, AC-5/6/8/9/T3/T4) L1 단위 테스트. + * + *

CsrfTokens.isValid 는 static 이라 mock 불가 → 실제 MockHttpServletRequest + + * MockHttpSession 으로 세션 csrfToken 과 헤더 X-CSRF-Token 을 일치시켜 충족시킨다. + * PermissionGate / GameUploadAuditMapper 는 Mockito mock 이다. @SpringBootTest 없이 + * 컨트롤러를 직접 인스턴스화하고 uploadStoragePath 만 임시 디렉터리로 주입한다. + */ +class GameUploadControllerSecurityTest { + + private static final String TOKEN = "tok"; + + private PermissionGate gate; + private GameUploadAuditMapper auditMapper; + private GameUploadController controller; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + gate = mock(PermissionGate.class); + auditMapper = mock(GameUploadAuditMapper.class); + controller = new GameUploadController(gate, auditMapper); + ReflectionTestUtils.setField(controller, "uploadStoragePath", tempDir.toString()); + } + + // ---- 헬퍼: 세션/요청 ---- + + private MockHttpSession authedSession() { + MockHttpSession session = new MockHttpSession(); + session.setAttribute("csrfToken", TOKEN); + session.setAttribute("userId", 1L); + return session; + } + + /** CSRF 헤더가 세션 토큰과 일치하는 유효 요청. */ + private MockHttpServletRequest validCsrfRequest(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + request.addHeader("X-CSRF-Token", TOKEN); + return request; + } + + /** CSRF 헤더가 없는(불충족) 요청. */ + private MockHttpServletRequest noCsrfRequest(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + return request; + } + + // ---- 헬퍼: zip 바이트 ---- + + /** + * assertUnityBuild 4종(loader/framework/data/wasm) + index.html 을 모두 포함한 + * 통과 가능한 Unity WebGL 빌드 zip 을 메모리에 생성한다. + */ + private byte[] validUnityZip() { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(baos)) { + putEntry(zip, "index.html", "game"); + putEntry(zip, "Build/x.loader.js", "// loader"); + putEntry(zip, "Build/x.framework.js.br", "framework"); + putEntry(zip, "Build/x.data.br", "data"); + putEntry(zip, "Build/x.wasm.br", "wasm"); + zip.finish(); + return baos.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("test zip build failed", e); + } + } + + private void putEntry(ZipOutputStream zip, String name, String content) throws IOException { + zip.putNextEntry(new ZipEntry(name)); + zip.write(content.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + + private MockMultipartFile zipFile(byte[] bytes) { + return new MockMultipartFile("file", "build.zip", "application/zip", bytes); + } + + // ================================================================== + // 게이트 (VP-5, AC-8) + // ================================================================== + + @Test + void webglZip_returns403_whenCsrfInvalid() throws IOException { + MockHttpSession session = authedSession(); + MockHttpServletRequest request = noCsrfRequest(session); + + ResponseEntity> response = controller.uploadWebglZip( + zipFile(validUnityZip()), null, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).containsEntry("status", 403); + verify(auditMapper, never()).insertAudit(any()); + } + + @Test + void webglZip_returns401_whenNotAuthenticated() throws IOException { + MockHttpSession session = new MockHttpSession(); + session.setAttribute("csrfToken", TOKEN); + // userId 미설정 → 컨트롤러 sessionUserId == null → 401 + when(gate.isAuthenticated(any())).thenReturn(false); + MockHttpServletRequest request = validCsrfRequest(session); + + ResponseEntity> response = controller.uploadWebglZip( + zipFile(validUnityZip()), null, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody()).containsEntry("message", "로그인이 필요합니다."); + verify(auditMapper, never()).insertAudit(any()); + } + + @Test + void webglZip_returns403_whenJamModeWithoutPermission() throws IOException { + MockHttpSession session = authedSession(); + when(gate.isAuthenticated(any())).thenReturn(true); + when(gate.has(any(), eq("GAME_JAM_MANAGE"))).thenReturn(false); + MockHttpServletRequest request = validCsrfRequest(session); + + ResponseEntity> response = controller.uploadWebglZip( + zipFile(validUnityZip()), null, "jam", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).containsEntry("status", 403); + verify(auditMapper, never()).insertAudit(any()); + } + + @Test + void webglZip_passesGate_whenAuthenticatedNonJam() throws IOException { + MockHttpSession session = authedSession(); + when(gate.isAuthenticated(any())).thenReturn(true); + MockHttpServletRequest request = validCsrfRequest(session); + + ResponseEntity> response = controller.uploadWebglZip( + zipFile(validUnityZip()), null, null, request, session); + + // 개방 유지 회귀: 비-jam 로그인 사용자는 게이트를 통과해 추출까지 진행되어 200. + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + Map body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body.get("gameUuid")).isNotNull(); + assertThat((String) body.get("webglPath")).startsWith("/game/"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(GameUploadAudit.class); + verify(auditMapper).insertAudit(captor.capture()); + assertThat(captor.getValue().getOutcome()).isEqualTo("SUCCESS"); + } + + // ================================================================== + // 포맷/크기 (VP-4, AC-5/6) + // ================================================================== + + @Test + void webglZip_returns400_whenNotZipExtension() throws IOException { + MockHttpSession session = authedSession(); + when(gate.isAuthenticated(any())).thenReturn(true); + MockHttpServletRequest request = validCsrfRequest(session); + MultipartFile notZip = new MockMultipartFile( + "file", "build.txt", "text/plain", validUnityZip()); + + ResponseEntity> response = controller.uploadWebglZip( + notZip, null, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + ArgumentCaptor captor = ArgumentCaptor.forClass(GameUploadAudit.class); + verify(auditMapper).insertAudit(captor.capture()); + assertThat(captor.getValue().getOutcome()).isEqualTo("REJECTED"); + assertThat(captor.getValue().getRejectReason()).isEqualTo("NOT_ZIP"); + } + + @Test + void webglZip_returns400_whenBadMagic() throws IOException { + MockHttpSession session = authedSession(); + when(gate.isAuthenticated(any())).thenReturn(true); + MockHttpServletRequest request = validCsrfRequest(session); + // 확장자/MIME 는 zip 이지만 내용 magic 이 PK\x03\x04 가 아님 → MAGIC_FAIL + MultipartFile badMagic = new MockMultipartFile( + "file", "build.zip", "application/zip", "not a zip".getBytes(StandardCharsets.UTF_8)); + + ResponseEntity> response = controller.uploadWebglZip( + badMagic, null, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + ArgumentCaptor captor = ArgumentCaptor.forClass(GameUploadAudit.class); + verify(auditMapper).insertAudit(captor.capture()); + assertThat(captor.getValue().getOutcome()).isEqualTo("REJECTED"); + assertThat(captor.getValue().getRejectReason()).isEqualTo("MAGIC_FAIL"); + } + + @Test + void webglZip_returns413_whenOriginalTooLarge() throws IOException { + MockHttpSession session = authedSession(); + when(gate.isAuthenticated(any())).thenReturn(true); + MockHttpServletRequest request = validCsrfRequest(session); + // 내용은 validUnityZip(magic OK) 이되 getSize() 만 한도 초과로 override → 413 도달. + MultipartFile big = new MockMultipartFile( + "file", "build.zip", "application/zip", validUnityZip()) { + @Override + public long getSize() { + return 513L * 1024 * 1024; + } + }; + + ResponseEntity> response = controller.uploadWebglZip( + big, null, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE); + ArgumentCaptor captor = ArgumentCaptor.forClass(GameUploadAudit.class); + verify(auditMapper).insertAudit(captor.capture()); + assertThat(captor.getValue().getOutcome()).isEqualTo("REJECTED"); + assertThat(captor.getValue().getRejectReason()).isEqualTo("TOO_LARGE"); + } + + // ================================================================== + // replaceUuid 소유검증 (VP-6, AC-9) + // ================================================================== + + @Test + void webglZip_returns403_whenReplaceUuidNotOwned() throws IOException { + MockHttpSession session = authedSession(); // userId=1L, role 미설정(non-ADMIN) + when(gate.isAuthenticated(any())).thenReturn(true); + MockHttpServletRequest request = validCsrfRequest(session); + String replaceUuid = UUID.randomUUID().toString(); + + GameAssetOwner owner = new GameAssetOwner(); + owner.setGameId(10L); + owner.setUserId(2L); // 다른 소유자 + when(auditMapper.findGameByWebglUuid(eq(replaceUuid))).thenReturn(owner); + + ResponseEntity> response = controller.uploadWebglZip( + zipFile(validUnityZip()), replaceUuid, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).containsEntry("status", 403); + ArgumentCaptor captor = ArgumentCaptor.forClass(GameUploadAudit.class); + verify(auditMapper).insertAudit(captor.capture()); + assertThat(captor.getValue().getOutcome()).isEqualTo("REJECTED"); + assertThat(captor.getValue().getGameUuid()).isEqualTo(replaceUuid); + } + + @Test + void webglZip_allowsReplace_whenOwner() throws IOException { + MockHttpSession session = authedSession(); // userId=1L + when(gate.isAuthenticated(any())).thenReturn(true); + MockHttpServletRequest request = validCsrfRequest(session); + String replaceUuid = UUID.randomUUID().toString(); + + GameAssetOwner owner = new GameAssetOwner(); + owner.setGameId(10L); + owner.setUserId(1L); // 세션과 동일 소유자 + when(auditMapper.findGameByWebglUuid(eq(replaceUuid))).thenReturn(owner); + + ResponseEntity> response = controller.uploadWebglZip( + zipFile(validUnityZip()), replaceUuid, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + Map body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body.get("gameUuid")).isEqualTo(replaceUuid); // 멱등: 동일 uuid 재사용 + ArgumentCaptor captor = ArgumentCaptor.forClass(GameUploadAudit.class); + verify(auditMapper).insertAudit(captor.capture()); + assertThat(captor.getValue().getOutcome()).isEqualTo("SUCCESS"); + } + + // ================================================================== + // CSRF 전수 (AC-T3) — thumbnail / root 핸들러 + // ================================================================== + + @Test + void thumbnail_returns403_whenCsrfInvalid() throws IOException { + MockHttpSession session = authedSession(); + MockHttpServletRequest request = noCsrfRequest(session); + MultipartFile image = new MockMultipartFile( + "file", "thumb.png", "image/png", new byte[]{1, 2, 3}); + + ResponseEntity> response = controller.uploadThumbnail( + image, UUID.randomUUID().toString(), request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).containsEntry("status", 403); + } + + @Test + void root_returns403_whenCsrfInvalid() throws IOException { + MockHttpSession session = authedSession(); + MockHttpServletRequest request = noCsrfRequest(session); + MultipartFile[] files = { + new MockMultipartFile("files", "a.txt", "text/plain", new byte[]{1}) + }; + + ResponseEntity> response = controller.uploadGameFiles( + files, null, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).containsEntry("status", 403); + } +} diff --git a/src/test/java/com/pandoli365/bibimbap/security/ZipSecurityTest.java b/src/test/java/com/pandoli365/bibimbap/security/ZipSecurityTest.java new file mode 100644 index 0000000..e14bdfd --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/security/ZipSecurityTest.java @@ -0,0 +1,367 @@ +package com.pandoli365.bibimbap.security; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; + +import com.pandoli365.bibimbap.security.ZipRejectException.Reason; + +/** + * {@link ZipSecurity} 보안 핵심 단위 테스트. + * + *

설계 VP-1(zip-slip/심링크/엔트리명), VP-2(zip bomb 3중 상한), VP-3/VP-4(포맷·magic· + * Unity 빌드 완전성)를 거부코드 단위로 커버한다. 순수 단위 테스트(@SpringBootTest 미사용). + */ +class ZipSecurityTest { + + private static final int ONE_MB = 1024 * 1024; + + // --------------------------------------------------------------------- + // VP-1: zip-slip / 엔트리명 사전거부 (BAD_ENTRY_NAME) + // --------------------------------------------------------------------- + + @Test + void extractZip_rejectsParentTraversal(@TempDir Path tempDir) throws IOException { + LinkedHashMap entries = new LinkedHashMap<>(); + entries.put("../escape.txt", "x".getBytes()); + MockMultipartFile file = zipFile("build.zip", makeZip(entries)); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir)); + assertEquals(Reason.BAD_ENTRY_NAME, ex.reason()); + + // 대상 디렉터리 밖 파일 0 검증: 상위 디렉터리에 escape.txt 가 생성되지 않아야 한다. + assertFalse(Files.exists(tempDir.getParent().resolve("escape.txt"))); + } + + @Test + void extractZip_rejectsAbsolutePath(@TempDir Path tempDir) throws IOException { + LinkedHashMap entries = new LinkedHashMap<>(); + entries.put("/etc/passwd", "x".getBytes()); + MockMultipartFile file = zipFile("build.zip", makeZip(entries)); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir)); + assertEquals(Reason.BAD_ENTRY_NAME, ex.reason()); + } + + @Test + void extractZip_rejectsBackslash(@TempDir Path tempDir) throws IOException { + LinkedHashMap entries = new LinkedHashMap<>(); + entries.put("..\\win.txt", "x".getBytes()); + MockMultipartFile file = zipFile("build.zip", makeZip(entries)); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir)); + assertEquals(Reason.BAD_ENTRY_NAME, ex.reason()); + } + + @Test + void extractZip_rejectsDriveLetter(@TempDir Path tempDir) throws IOException { + LinkedHashMap entries = new LinkedHashMap<>(); + entries.put("C:evil.txt", "x".getBytes()); + MockMultipartFile file = zipFile("build.zip", makeZip(entries)); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir)); + assertEquals(Reason.BAD_ENTRY_NAME, ex.reason()); + } + + @Test + void extractZip_rejectsNulByte(@TempDir Path tempDir) throws IOException { + LinkedHashMap entries = new LinkedHashMap<>(); + entries.put("a\0b.txt", "x".getBytes()); + MockMultipartFile file = zipFile("build.zip", makeZip(entries)); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir)); + assertEquals(Reason.BAD_ENTRY_NAME, ex.reason()); + } + + @Test + void extractZip_rejectsTooLongName(@TempDir Path tempDir) throws IOException { + String longName = "a".repeat(256) + ".txt"; // 255자 초과 + LinkedHashMap entries = new LinkedHashMap<>(); + entries.put(longName, "x".getBytes()); + MockMultipartFile file = zipFile("build.zip", makeZip(entries)); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir)); + assertEquals(Reason.BAD_ENTRY_NAME, ex.reason()); + } + + @Test + void extractZip_rejectsTooDeepName(@TempDir Path tempDir) throws IOException { + // "a/" x 33 + "f.txt" → split("/") 분절 수 34 > 32. + String deepName = "a/".repeat(33) + "f.txt"; + LinkedHashMap entries = new LinkedHashMap<>(); + entries.put(deepName, "x".getBytes()); + MockMultipartFile file = zipFile("build.zip", makeZip(entries)); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir)); + assertEquals(Reason.BAD_ENTRY_NAME, ex.reason()); + } + + @Test + void extractZip_rejectsEmptyZip(@TempDir Path tempDir) throws IOException { + // 엔트리 0 → 빈 zip(PK\x05\x06). extractZip 은 BAD_ENTRY_NAME. + MockMultipartFile file = zipFile("build.zip", makeZip(new LinkedHashMap<>())); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir)); + assertEquals(Reason.BAD_ENTRY_NAME, ex.reason()); + } + + // --------------------------------------------------------------------- + // VP-1: 심링크 추적 탈출 (SYMLINK) — 플랫폼 가드 적용 + // --------------------------------------------------------------------- + + @Test + void extractZip_rejectsSymlinkParent(@TempDir Path tempDir) throws IOException { + Path outside = null; + try { + outside = Files.createDirectory(tempDir.getParent().resolve("outside-" + UUID.randomUUID())); + Path linkDir = tempDir.resolve("link"); + try { + Files.createSymbolicLink(linkDir, outside); + } catch (UnsupportedOperationException | IOException e) { + // 심링크 미지원/권한 부족 플랫폼 → skip. + Assumptions.assumeTrue(false, "symlink not supported on this platform"); + } + + LinkedHashMap entries = new LinkedHashMap<>(); + entries.put("link/evil.txt", "x".getBytes()); + MockMultipartFile file = zipFile("build.zip", makeZip(entries)); + + ZipRejectException ex = assertThrows(ZipRejectException.class, + () -> ZipSecurity.extractZip(file, tempDir)); + assertEquals(Reason.SYMLINK, ex.reason()); + + // 심링크가 가리키는 외부 디렉터리에 파일이 새어 나가지 않아야 한다. + assertFalse(Files.exists(outside.resolve("evil.txt"))); + } finally { + // @TempDir 는 tempDir 만 정리하므로 상위에 만든 outside 는 직접 정리한다. + if (outside != null) { + Files.deleteIfExists(outside); + } + } + } + + // --------------------------------------------------------------------- + // VP-2: zip bomb 3중 상한 + // --------------------------------------------------------------------- + + @Test + void extractZip_rejectsTooManyEntries(@TempDir Path tempDir) throws IOException { + // entryCount++ 후 > 8000 검사 → 8001개째에서 트리거. + byte[] zipBytes; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos)) { + for (int i = 0; i < 8001; i++) { + zos.putNextEntry(new ZipEntry("f" + i + ".txt")); + zos.closeEntry(); + } + zos.finish(); + zipBytes = baos.toByteArray(); + } + MockMultipartFile file = zipFile("build.zip", zipBytes); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir)); + assertEquals(Reason.TOO_MANY_ENTRIES, ex.reason()); + } + + @Test + void extractZip_rejectsEntryTooLarge(@TempDir Path tempDir) throws IOException { + // 단일 엔트리 257MB(0 채움) → 256MB 시점에서 ENTRY_TOO_LARGE. + byte[] zipBytes = makeLargeSingleEntryZip("big.bin", 257); + MockMultipartFile file = zipFile("build.zip", zipBytes); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir)); + assertEquals(Reason.ENTRY_TOO_LARGE, ex.reason()); + } + + @Test + void extractZip_rejectsExtractedTooLarge(@TempDir Path tempDir) throws IOException { + // 200MB 엔트리 3개 = 600MB. 각각은 256MB 미만이라 ENTRY_TOO_LARGE 안 걸리고 + // 3번째 쓰는 중 누적 512MB 초과 → EXTRACTED_TOO_LARGE. + byte[] zipBytes = makeMultiLargeEntryZip(200, 3); + MockMultipartFile file = zipFile("build.zip", zipBytes); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir)); + assertEquals(Reason.EXTRACTED_TOO_LARGE, ex.reason()); + } + + // --------------------------------------------------------------------- + // VP-3 / VP-4: 포맷 (NOT_ZIP / MAGIC_FAIL / INCOMPLETE_BUILD) + // --------------------------------------------------------------------- + + @Test + void assertZipType_rejectsNonZipExtension() { + MockMultipartFile file = new MockMultipartFile("file", "build.txt", "application/zip", "x".getBytes()); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.assertZipType(file)); + assertEquals(Reason.NOT_ZIP, ex.reason()); + } + + @Test + void assertZipType_rejectsBadMime() { + MockMultipartFile file = new MockMultipartFile("file", "build.zip", "text/html", "x".getBytes()); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.assertZipType(file)); + assertEquals(Reason.NOT_ZIP, ex.reason()); + } + + @Test + void assertZipType_acceptsNullMimeWithZipExt() { + MockMultipartFile file = new MockMultipartFile("file", "build.zip", null, "x".getBytes()); + + assertDoesNotThrow(() -> ZipSecurity.assertZipType(file)); + } + + @Test + void assertMagic_rejectsNonZipMagic() { + MockMultipartFile file = zipFile("build.zip", "not a zip".getBytes()); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.assertMagic(file)); + assertEquals(Reason.MAGIC_FAIL, ex.reason()); + } + + @Test + void assertMagic_acceptsValidZip() throws IOException { + MockMultipartFile file = zipFile("build.zip", validUnityZip()); + + assertDoesNotThrow(() -> ZipSecurity.assertMagic(file)); + } + + @Test + void assertUnityBuild_rejectsIndexOnly(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("index.html"), ""); + + ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.assertUnityBuild(tempDir)); + assertEquals(Reason.INCOMPLETE_BUILD, ex.reason()); + } + + @Test + void assertUnityBuild_acceptsFullBuildWithBrVariants(@TempDir Path tempDir) throws IOException { + Path build = Files.createDirectory(tempDir.resolve("Build")); + Files.writeString(tempDir.resolve("index.html"), ""); + Files.writeString(build.resolve("x.loader.js"), "loader"); + Files.writeString(build.resolve("x.framework.js.br"), "framework"); + Files.writeString(build.resolve("x.data.br"), "data"); + Files.writeString(build.resolve("x.wasm.br"), "wasm"); + + assertDoesNotThrow(() -> ZipSecurity.assertUnityBuild(tempDir)); + } + + // --------------------------------------------------------------------- + // 정상 통과 (회귀 기준) + // --------------------------------------------------------------------- + + @Test + void extractZip_acceptsValidUnityBuild(@TempDir Path tempDir) throws IOException { + MockMultipartFile file = zipFile("build.zip", validUnityZip()); + + ZipSecurity.ExtractResult result = ZipSecurity.extractZip(file, tempDir); + assertTrue(result.entryCount() > 0); + + assertDoesNotThrow(() -> ZipSecurity.assertUnityBuild(tempDir)); + } + + // --------------------------------------------------------------------- + // 헬퍼 + // --------------------------------------------------------------------- + + /** + * (엔트리명 → 바이트) 맵으로 메모리 zip 을 생성한다. 이름이 "/" 로 끝나면 디렉터리 엔트리로 + * putNextEntry 만 수행한다. + */ + private byte[] makeZip(LinkedHashMap entries) throws IOException { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos)) { + for (var e : entries.entrySet()) { + zos.putNextEntry(new ZipEntry(e.getKey())); + if (!e.getKey().endsWith("/") && e.getValue() != null) { + zos.write(e.getValue()); + } + zos.closeEntry(); + } + zos.finish(); + return baos.toByteArray(); + } + } + + /** contentType "application/zip" 의 MockMultipartFile 생성. */ + private MockMultipartFile zipFile(String filename, byte[] content) { + return new MockMultipartFile("file", filename, "application/zip", content); + } + + /** + * 단일 엔트리 zip 을 메모리에 만들되, 본문은 1MB 0 채움 버퍼를 {@code sizeMb}회 write 한다. + * 0 바이트는 압축이 잘 되어 zip 자체는 작지만, 해제 시 sizeMb MB 를 read 하게 된다. + */ + private byte[] makeLargeSingleEntryZip(String name, int sizeMb) throws IOException { + byte[] buf = new byte[ONE_MB]; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos)) { + zos.putNextEntry(new ZipEntry(name)); + for (int i = 0; i < sizeMb; i++) { + zos.write(buf); + } + zos.closeEntry(); + zos.finish(); + return baos.toByteArray(); + } + } + + /** + * 각 {@code sizeMb} MB 의 0 채움 엔트리를 {@code count}개 담은 zip 을 만든다. 누적 해제 + * 상한(512MB) 검증용. + */ + private byte[] makeMultiLargeEntryZip(int sizeMb, int count) throws IOException { + byte[] buf = new byte[ONE_MB]; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos)) { + for (int n = 0; n < count; n++) { + zos.putNextEntry(new ZipEntry("big" + n + ".bin")); + for (int i = 0; i < sizeMb; i++) { + zos.write(buf); + } + zos.closeEntry(); + } + zos.finish(); + return baos.toByteArray(); + } + } + + /** + * 정상 Unity WebGL 빌드 zip(index.html + Build/x.loader.js + x.framework.js.br + + * x.data.br + x.wasm.br). assertMagic / assertUnityBuild / extractZip 모두 통과. + */ + private byte[] validUnityZip() throws IOException { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos)) { + writeEntry(zos, "index.html", "".getBytes()); + writeEntry(zos, "Build/x.loader.js", "loader".getBytes()); + writeEntry(zos, "Build/x.framework.js.br", "framework".getBytes()); + writeEntry(zos, "Build/x.data.br", "data".getBytes()); + writeEntry(zos, "Build/x.wasm.br", "wasm".getBytes()); + zos.finish(); + return baos.toByteArray(); + } + } + + private void writeEntry(ZipOutputStream zos, String name, byte[] content) throws IOException { + zos.putNextEntry(new ZipEntry(name)); + zos.write(content); + zos.closeEntry(); + } +}