feat(upload): W3-5 Unity WebGL 업로드 보안 보강 — zip-slip/심링크/zip bomb/포맷검증/권한게이트/자산 생명주기/감사

- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD
This commit is contained in:
이정수 2026-06-29 12:10:55 +09:00
parent 6047a39ca5
commit 08d191ff2e
13 changed files with 1505 additions and 93 deletions

View File

@ -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" CREATE INDEX IF NOT EXISTS "idx_unity_feed_items_unack"
ON "unity_feed_items" ("is_acknowledged", "detected_at" DESC) ON "unity_feed_items" ("is_acknowledged", "detected_at" DESC)
WHERE "is_acknowledged" = false; 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';

47
docs/game-upload-ddl.sql Normal file
View File

@ -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';

View File

@ -7,6 +7,7 @@ import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GameViewsMapper; import com.pandoli365.bibimbap.mapper.GameViewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens; import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.service.GameAssetCleanupService;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -33,6 +34,7 @@ public class GameController {
private final GameCommentsMapper gameCommentsMapper; private final GameCommentsMapper gameCommentsMapper;
private final GameReviewsMapper gameReviewsMapper; private final GameReviewsMapper gameReviewsMapper;
private final GameViewsMapper gameViewsMapper; private final GameViewsMapper gameViewsMapper;
private final GameAssetCleanupService assetCleanupService;
@Value("${app.webgl.asset-origin:}") @Value("${app.webgl.asset-origin:}")
private String webglAssetOrigin; private String webglAssetOrigin;
@ -40,11 +42,13 @@ public class GameController {
public GameController(GamesMapper gamesMapper, public GameController(GamesMapper gamesMapper,
GameCommentsMapper gameCommentsMapper, GameCommentsMapper gameCommentsMapper,
GameReviewsMapper gameReviewsMapper, GameReviewsMapper gameReviewsMapper,
GameViewsMapper gameViewsMapper) { GameViewsMapper gameViewsMapper,
GameAssetCleanupService assetCleanupService) {
this.gamesMapper = gamesMapper; this.gamesMapper = gamesMapper;
this.gameCommentsMapper = gameCommentsMapper; this.gameCommentsMapper = gameCommentsMapper;
this.gameReviewsMapper = gameReviewsMapper; this.gameReviewsMapper = gameReviewsMapper;
this.gameViewsMapper = gameViewsMapper; this.gameViewsMapper = gameViewsMapper;
this.assetCleanupService = assetCleanupService;
} }
public static String webglUrlForGame(int gameId) { public static String webglUrlForGame(int gameId) {
@ -225,6 +229,7 @@ public class GameController {
return response(HttpStatus.BAD_REQUEST, "소개는 600자 이하로 입력해 주세요."); return response(HttpStatus.BAD_REQUEST, "소개는 600자 이하로 입력해 주세요.");
} }
String oldWebglPath = existing.getWebglPath();
existing.setName(normalizedName); existing.setName(normalizedName);
existing.setCreatorNote(normalizedCreatorNote); existing.setCreatorNote(normalizedCreatorNote);
existing.setGitUrl(safeExternalUrl(gitUrl)); existing.setGitUrl(safeExternalUrl(gitUrl));
@ -233,6 +238,13 @@ public class GameController {
existing.setVisible(isChecked(visible)); existing.setVisible(isChecked(visible));
gamesMapper.updateGame(existing); gamesMapper.updateGame(existing);
// 자산 생명주기(U7): webgl 경로의 UUID 교체되면 디렉터리 정리(고아 방지)
String oldUuid = gameUuidFromPath(oldWebglPath);
String newUuid = gameUuidFromPath(normalizedWebglPath);
if (!oldUuid.isBlank() && !oldUuid.equals(newUuid)) {
assetCleanupService.purgeByWebglPath(oldWebglPath);
}
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200); body.put("status", 200);
body.put("message", "게임이 수정되었습니다."); body.put("message", "게임이 수정되었습니다.");
@ -266,6 +278,8 @@ public class GameController {
gamesMapper.softDeleteGameReviews(id); gamesMapper.softDeleteGameReviews(id);
gamesMapper.deleteGameLikes(id); gamesMapper.deleteGameLikes(id);
gamesMapper.softDeleteGame(id); gamesMapper.softDeleteGame(id);
// 자산 생명주기(U7): 메타는 soft-delete, 자산은 hard-delete(재업로드로 갈음, 디스크 누수 방지)
assetCleanupService.purgeByWebglPath(existing.getWebglPath());
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200); body.put("status", 200);

View File

@ -1,6 +1,14 @@
package com.pandoli365.bibimbap.controller.api; 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.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.HttpServletRequest;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Value; 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 org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@ -29,21 +36,27 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Stream; import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@RestController @RestController
@RequestMapping("/api/game-files") @RequestMapping("/api/game-files")
public class GameUploadController { public class GameUploadController {
private static final Logger log = LoggerFactory.getLogger(GameUploadController.class); private static final Logger log = LoggerFactory.getLogger(GameUploadController.class);
private static final long WEBGL_EXTRACTED_MAX_BYTES = 512L * 1024 * 1024; private static final long WEBGL_ORIGINAL_MAX_BYTES = 512L * 1024 * 1024;
private static final int WEBGL_MAX_ENTRIES = 8_000;
private static final long THUMBNAIL_MAX_BYTES = 10L * 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}") @Value("${app.upload.game-storage-path:src/main/resources/static}")
private String uploadStoragePath; 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") @GetMapping("/ping")
public ResponseEntity<Map<String, Object>> ping() { public ResponseEntity<Map<String, Object>> ping() {
return ResponseEntity.ok(Map.of("status", "ok")); return ResponseEntity.ok(Map.of("status", "ok"));
@ -59,7 +72,7 @@ public class GameUploadController {
if (!CsrfTokens.isValid(request)) { if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
} }
if (sessionUserId(session) == null) { if (!permissionGate.isAuthenticated(session)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED) return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("message", "login is required")); .body(Map.of("message", "login is required"));
} }
@ -100,6 +113,8 @@ public class GameUploadController {
@PostMapping("/webgl-zip") @PostMapping("/webgl-zip")
public ResponseEntity<Map<String, Object>> uploadWebglZip( public ResponseEntity<Map<String, Object>> uploadWebglZip(
@RequestParam(name = "file", required = false) MultipartFile file, @RequestParam(name = "file", required = false) MultipartFile file,
@RequestParam(name = "replaceUuid", required = false) String replaceUuid,
@RequestParam(name = "mode", required = false) String mode,
HttpServletRequest request, HttpServletRequest request,
HttpSession session HttpSession session
) throws IOException { ) throws IOException {
@ -111,52 +126,142 @@ public class GameUploadController {
file == null ? null : file.getSize(), file == null ? null : file.getSize(),
file == null ? null : file.getContentType()); file == null ? null : file.getContentType());
Long userId = sessionUserId(session); Long userId = sessionUserId(session);
if (userId == null) { if (!permissionGate.isAuthenticated(session) || userId == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED) return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("message", "로그인이 필요합니다.")); .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()) { if (file == null || file.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "WebGL zip 파일을 선택해 주세요.")); 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(); Path root = gameRoot();
String gameUuid = UUID.randomUUID().toString(); Path tmpDir = root.resolve(".tmp").resolve(newUuid).normalize();
Path targetDir = root.resolve(gameUuid).normalize(); if (!tmpDir.startsWith(root)) {
if (!targetDir.startsWith(root)) { audit(userId, newUuid, "REJECTED", ZipRejectException.Reason.ZIP_SLIP.name(),
originalName, uploadBytes, null, null);
return ResponseEntity.badRequest().body(Map.of("message", "저장 경로가 올바르지 않습니다.")); 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 { try {
Files.createDirectories(targetDir); deleteRecursively(tmpDir);
ExtractResult extractResult = extractZip(file, targetDir); Files.createDirectories(tmpDir);
Path indexFile = findIndexFile(targetDir); ZipSecurity.ExtractResult extractResult = ZipSecurity.extractZip(file, tmpDir);
if (indexFile == null) {
deleteRecursively(targetDir);
return ResponseEntity.badRequest().body(Map.of("message", "zip 안에서 index.html을 찾지 못했습니다."));
}
String webglPath = "/game/" + root.relativize(indexFile).toString().replace('\\', '/'); Path indexFile = findIndexFile(tmpDir);
String deployPath = "/game/" + root.relativize(targetDir).toString().replace('\\', '/'); 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<String, Object> response = new LinkedHashMap<>(); Map<String, Object> response = new LinkedHashMap<>();
response.put("status", 200); response.put("status", 200);
response.put("message", "WebGL 파일이 압축 해제되었습니다."); response.put("message", "WebGL 파일이 압축 해제되었습니다.");
response.put("gameUuid", gameUuid); response.put("gameUuid", newUuid);
response.put("webglPath", webglPath); response.put("webglPath", webglPath);
response.put("deployPath", deployPath); response.put("deployPath", deployPath);
response.put("entryCount", extractResult.entryCount()); response.put("entryCount", extractResult.entryCount());
response.put("extractedBytes", extractResult.extractedBytes()); response.put("extractedBytes", extractResult.extractedBytes());
log.info("WebGL zip upload completed. gameUuid={}, webglPath={}, entryCount={}, 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); return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) { } catch (ZipRejectException e) {
deleteRecursively(targetDir); deleteRecursively(tmpDir);
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); 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) { } catch (IOException e) {
deleteRecursively(targetDir); deleteRecursively(tmpDir);
throw e; 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)) { if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
} }
Long userId = sessionUserId(session); if (!permissionGate.isAuthenticated(session)) {
if (userId == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED) return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("message", "로그인이 필요합니다.")); .body(Map.of("message", "로그인이 필요합니다."));
} }
@ -237,70 +341,29 @@ public class GameUploadController {
return null; return null;
} }
private boolean isZipFile(MultipartFile file) { private void audit(Long actorId, String gameUuid, String outcome, String rejectReason,
String contentType = file.getContentType(); String originalName, Long uploadBytes, Integer entryCount, Long extractedBytes) {
if (contentType != null) { try {
String normalized = contentType.toLowerCase(Locale.ROOT); GameUploadAudit a = new GameUploadAudit();
if ("application/zip".equals(normalized) a.setActorId(actorId);
|| "application/x-zip-compressed".equals(normalized) a.setGameUuid(gameUuid);
|| "multipart/x-zip".equals(normalized)) { a.setOutcome(outcome);
return true; a.setRejectReason(rejectReason);
} a.setOriginalName(originalName);
} a.setUploadBytes(uploadBytes);
String originalName = file.getOriginalFilename(); a.setEntryCount(entryCount);
return originalName != null && originalName.toLowerCase(Locale.ROOT).endsWith(".zip"); a.setExtractedBytes(extractedBytes);
} auditMapper.insertAudit(a);
} catch (Exception ex) {
private ExtractResult extractZip(MultipartFile zipFile, Path targetDir) throws IOException { log.warn("upload audit insert failed: {}", ex.getMessage());
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();
} }
} }
if (entryCount == 0) { private String sanitizeAuditName(String name) {
throw new IllegalArgumentException("비어 있는 zip 파일입니다."); if (name == null) {
return null;
} }
return new ExtractResult(entryCount, extractedBytes); return name.length() > AUDIT_NAME_MAX_LEN ? name.substring(0, AUDIT_NAME_MAX_LEN) : name;
}
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;
} }
private Path findIndexFile(Path targetDir) throws IOException { private Path findIndexFile(Path targetDir) throws IOException {
@ -418,7 +481,4 @@ public class GameUploadController {
} }
return UUID.randomUUID() + "_" + cleanName; return UUID.randomUUID() + "_" + cleanName;
} }
private record ExtractResult(int entryCount, long extractedBytes) {
}
} }

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -0,0 +1,42 @@
package com.pandoli365.bibimbap.security;
/**
* Unity WebGL zip 업로드의 보안/포맷 검증 실패를 표현하는 unchecked 예외.
*
* <p>거부 사유는 {@link Reason} enum 으로 분류한다. 컨트롤러는 {@link #getMessage()}
* 클라이언트에 노출하므로 message 한국어 사용자 메시지로 작성한다. {@link #reason()}
* 서버 로깅/감사용 코드이다.
*
* <p>{@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;
}
}

View File

@ -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 업로드의 보안/포맷 검증 정석 구현.
*
* <p>방어 대상: zip-slip(상위경로 탈출), 심링크 추적 탈출, zip bomb(엔트리 /엔트리당/누적
* 해제 크기), 포맷 위장(확장자·MIME·magic). 압축비 검사는 의도적으로 사용하지 않고
* 절대 크기 3중 상한(엔트리당 256MB + 누적 512MB + 엔트리수 8000)으로 bomb 차단한다.
* 이를 위해 {@link ZipEntry#getCompressedSize()} 호출하지 않는다(신뢰 불가 메타).
*
* <p>모든 거부는 {@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<String> 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}.
*
* <p>확장자는 필수이고, 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}.
*
* <p>스트림은 {@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중 크기 상한을 적용한다.
*
* <p>{@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}.
*
* <p>index.html 검증은 컨트롤러(NO_INDEX) 책임이며 여기서는 다루지 않는다.
*/
public static void assertUnityBuild(Path extractedRoot) throws IOException {
Set<String> fileNames = new HashSet<>();
try (Stream<Path> 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;
}
}

View File

@ -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<Path> paths = Files.walk(path)) {
for (Path target : paths.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(target);
}
}
}
}

View File

@ -5,6 +5,7 @@ import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper; import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GameTagsMapper; import com.pandoli365.bibimbap.mapper.GameTagsMapper;
import com.pandoli365.bibimbap.mapper.GameUploadAuditMapper;
import com.pandoli365.bibimbap.mapper.GameViewsMapper; import com.pandoli365.bibimbap.mapper.GameViewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.JamAwardsMapper; 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.JamRoleGate;
import com.pandoli365.bibimbap.security.PermissionGate; import com.pandoli365.bibimbap.security.PermissionGate;
import com.pandoli365.bibimbap.security.SsrfSafeFetcher; import com.pandoli365.bibimbap.security.SsrfSafeFetcher;
import com.pandoli365.bibimbap.service.GameAssetCleanupService;
import com.pandoli365.bibimbap.service.OgPreviewService; import com.pandoli365.bibimbap.service.OgPreviewService;
import com.pandoli365.bibimbap.service.PostMarkdownService; import com.pandoli365.bibimbap.service.PostMarkdownService;
import com.pandoli365.bibimbap.service.UnityFeedPoller; import com.pandoli365.bibimbap.service.UnityFeedPoller;
@ -162,6 +164,13 @@ class BibimbapApplicationTests {
@MockBean @MockBean
private UnityFeedPoller unityFeedPoller; private UnityFeedPoller unityFeedPoller;
// W3-5 Unity WebGL 업로드 보안 보강 신규 (매퍼·서비스 contextLoads 안정화)
@MockBean
private GameUploadAuditMapper gameUploadAuditMapper;
@MockBean
private GameAssetCleanupService gameAssetCleanupService;
@Test @Test
void contextLoads() { void contextLoads() {
} }

View File

@ -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 단위 테스트.
*
* <p>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", "<html><body>game</body></html>");
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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> response = controller.uploadWebglZip(
zipFile(validUnityZip()), null, null, request, session);
// 개방 유지 회귀: -jam 로그인 사용자는 게이트를 통과해 추출까지 진행되어 200.
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = response.getBody();
assertThat(body).isNotNull();
assertThat(body.get("gameUuid")).isNotNull();
assertThat((String) body.get("webglPath")).startsWith("/game/");
ArgumentCaptor<GameUploadAudit> 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<Map<String, Object>> response = controller.uploadWebglZip(
notZip, null, null, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
ArgumentCaptor<GameUploadAudit> 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<Map<String, Object>> response = controller.uploadWebglZip(
badMagic, null, null, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
ArgumentCaptor<GameUploadAudit> 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<Map<String, Object>> response = controller.uploadWebglZip(
big, null, null, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE);
ArgumentCaptor<GameUploadAudit> 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<Map<String, Object>> response = controller.uploadWebglZip(
zipFile(validUnityZip()), replaceUuid, null, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(response.getBody()).containsEntry("status", 403);
ArgumentCaptor<GameUploadAudit> 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<Map<String, Object>> response = controller.uploadWebglZip(
zipFile(validUnityZip()), replaceUuid, null, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = response.getBody();
assertThat(body).isNotNull();
assertThat(body.get("gameUuid")).isEqualTo(replaceUuid); // 멱등: 동일 uuid 재사용
ArgumentCaptor<GameUploadAudit> 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<Map<String, Object>> 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<Map<String, Object>> response = controller.uploadGameFiles(
files, null, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(response.getBody()).containsEntry("status", 403);
}
}

View File

@ -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} 보안 핵심 단위 테스트.
*
* <p>설계 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<String, byte[]> 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<String, byte[]> 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<String, byte[]> 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<String, byte[]> 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<String, byte[]> 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<String, byte[]> 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<String, byte[]> 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<String, byte[]> 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"), "<html></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"), "<html></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<String, byte[]> 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", "<html></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();
}
}