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:
parent
6047a39ca5
commit
08d191ff2e
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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<String, Object> 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<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
|
|
|
|||
|
|
@ -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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue