diff --git a/docs/recruit-posts-ddl.sql b/docs/recruit-posts-ddl.sql index 552457a..619cc0e 100644 --- a/docs/recruit-posts-ddl.sql +++ b/docs/recruit-posts-ddl.sql @@ -29,17 +29,41 @@ CREATE TABLE IF NOT EXISTS "recruit_posts" ( ALTER SEQUENCE "recruit_posts_id_seq" OWNED BY "recruit_posts"."id"; -ALTER TABLE "recruit_posts" - ADD CONSTRAINT "recruit_posts_user_id_fkey" - FOREIGN KEY ("user_id") REFERENCES "users" ("id"); +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'recruit_posts_user_id_fkey' + ) THEN + ALTER TABLE "recruit_posts" + ADD CONSTRAINT "recruit_posts_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id"); + END IF; +END +$$; -ALTER TABLE "recruit_posts" - ADD CONSTRAINT "recruit_posts_role_check" - CHECK ("role" IN ('기획', '아트', '프로그래머')); +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'recruit_posts_role_check' + ) THEN + ALTER TABLE "recruit_posts" + ADD CONSTRAINT "recruit_posts_role_check" + CHECK ("role" IN ('기획', '아트', '프로그래머')); + END IF; +END +$$; -ALTER TABLE "recruit_posts" - ADD CONSTRAINT "recruit_posts_participation_type_check" - CHECK ("participation_type" IN ('취미', '수익쉐어', '유급', '게임잼')); +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'recruit_posts_participation_type_check' + ) THEN + ALTER TABLE "recruit_posts" + ADD CONSTRAINT "recruit_posts_participation_type_check" + CHECK ("participation_type" IN ('취미', '수익쉐어', '유급', '게임잼')); + END IF; +END +$$; CREATE INDEX IF NOT EXISTS "idx_recruit_posts_visible_order" ON "recruit_posts" ("is_visible", "is_delete", "sort_order", "created_at" DESC, "id" DESC); diff --git a/src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java b/src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java index 7f72d10..bb69a8a 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java @@ -2,6 +2,8 @@ package com.pandoli365.bibimbap.controller; import com.pandoli365.bibimbap.data.RecruitPostData; import com.pandoli365.bibimbap.mapper.RecruitPostsMapper; +import com.pandoli365.bibimbap.security.CsrfTokens; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -57,8 +59,12 @@ public class RecruitController { @RequestParam(name = "team", required = false) String teamMembers, @RequestParam(name = "contact", required = false) String contact, @RequestParam(name = "description", required = false) String description, + HttpServletRequest request, HttpSession session ) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } Long userId = sessionUserId(session); if (userId == null) { return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java index b331266..f6d8e5d 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java @@ -25,6 +25,9 @@ public class GameAssetController { @Value("${app.upload.game-storage-path:src/main/resources/static}") private String uploadStoragePath; + @Value("${app.webgl.frame-ancestors:self}") + private String webglFrameAncestors; + @GetMapping("/game/{gameUuid}/**") public void gameAsset( @PathVariable("gameUuid") String gameUuid, @@ -53,6 +56,14 @@ public class GameAssetController { } response.setHeader(HttpHeaders.CONTENT_TYPE, contentType); response.setHeader(HttpHeaders.CACHE_CONTROL, "max-age=3600, public"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader( + "Content-Security-Policy", + "default-src 'self' blob: data:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; " + + "style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; " + + "connect-src 'self' blob: data:; worker-src 'self' blob:; media-src 'self' blob: data:; " + + "object-src 'none'; frame-ancestors " + frameAncestors() + ); response.setContentLengthLong(Files.size(assetFile)); Files.copy(assetFile, response.getOutputStream()); } @@ -61,6 +72,14 @@ public class GameAssetController { return Paths.get(uploadStoragePath).toAbsolutePath().normalize().resolve("game").normalize(); } + private String frameAncestors() { + String value = webglFrameAncestors == null ? "" : webglFrameAncestors.trim(); + if (value.isBlank() || "self".equalsIgnoreCase(value)) { + return "'self'"; + } + return value; + } + private Path resolveAssetFile(Path gameDir, String gameUuid, HttpServletRequest request) { String contextPath = request.getContextPath() == null ? "" : request.getContextPath(); String requestUri = request.getRequestURI(); diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java index 05173ed..cfed348 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java @@ -3,7 +3,10 @@ package com.pandoli365.bibimbap.controller.api; import com.pandoli365.bibimbap.data.GameData; import com.pandoli365.bibimbap.game.GameCatalog; import com.pandoli365.bibimbap.mapper.GamesMapper; +import com.pandoli365.bibimbap.security.CsrfTokens; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; @@ -16,6 +19,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; @Controller @@ -23,6 +27,9 @@ public class GameController { private final GamesMapper gamesMapper; + @Value("${app.webgl.asset-origin:}") + private String webglAssetOrigin; + public GameController(GamesMapper gamesMapper) { this.gamesMapper = gamesMapper; } @@ -40,8 +47,12 @@ public class GameController { @RequestParam(name = "webglPath", required = false) String webglPath, @RequestParam(name = "thumbnailUrl", required = false) String thumbnailUrl, @RequestParam(name = "visible", required = false) String visible, + HttpServletRequest request, HttpSession session ) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } Long userId = sessionUserId(session); if (userId == null) { return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); @@ -70,7 +81,7 @@ public class GameController { game.setUserId(userId); game.setName(normalizedName); game.setCreatorNote(normalizedCreatorNote); - game.setGitUrl(trimToEmpty(gitUrl)); + game.setGitUrl(safeExternalUrl(gitUrl)); game.setWebglPath(normalizedWebglPath); game.setThumbnailUrl(normalizedThumbnailUrl == null ? "" : normalizedThumbnailUrl); game.setVisible(isChecked(visible)); @@ -109,8 +120,9 @@ public class GameController { model.addAttribute("likeCount", GameCatalog.LIKE_COUNTS[idx]); model.addAttribute("likeCountFormatted", String.format("%,d", GameCatalog.LIKE_COUNTS[idx])); model.addAttribute("creatorNote", GameCatalog.CREATOR_NOTES[idx]); - model.addAttribute("gitUrl", GameCatalog.GIT_URLS[idx]); + model.addAttribute("gitUrl", safeExternalUrl(GameCatalog.GIT_URLS[idx])); model.addAttribute("webglUrl", webglUrlForGame(intId)); + model.addAttribute("webglFrameSrc", webglFrameSrc(webglUrlForGame(intId))); model.addAttribute("webglDeployPath", webglUrlForGame(intId)); model.addAttribute("owner", false); return "game-detail"; @@ -153,8 +165,12 @@ public class GameController { @RequestParam(name = "webglPath", required = false) String webglPath, @RequestParam(name = "thumbnailUrl", required = false) String thumbnailUrl, @RequestParam(name = "visible", required = false) String visible, + HttpServletRequest request, HttpSession session ) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } Long userId = sessionUserId(session); if (userId == null) { return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); @@ -189,7 +205,7 @@ public class GameController { existing.setName(normalizedName); existing.setCreatorNote(normalizedCreatorNote); - existing.setGitUrl(trimToEmpty(gitUrl)); + existing.setGitUrl(safeExternalUrl(gitUrl)); existing.setWebglPath(normalizedWebglPath); existing.setThumbnailUrl(normalizedThumbnailUrl == null ? "" : normalizedThumbnailUrl); existing.setVisible(isChecked(visible)); @@ -205,7 +221,12 @@ public class GameController { @DeleteMapping("/game/{id}") @Transactional - public ResponseEntity> deleteGame(@PathVariable("id") long id, HttpSession session) { + public ResponseEntity> deleteGame(@PathVariable("id") long id, + HttpServletRequest request, + HttpSession session) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } Long userId = sessionUserId(session); if (userId == null) { return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); @@ -239,12 +260,34 @@ public class GameController { model.addAttribute("likeCount", likeCount); model.addAttribute("likeCountFormatted", String.format("%,d", likeCount)); model.addAttribute("creatorNote", trimToEmpty(game.getCreatorNote())); - model.addAttribute("gitUrl", trimToEmpty(game.getGitUrl())); + model.addAttribute("gitUrl", safeExternalUrl(game.getGitUrl())); model.addAttribute("webglUrl", webglPath); + model.addAttribute("webglFrameSrc", webglFrameSrc(webglPath)); model.addAttribute("webglDeployPath", webglPath); model.addAttribute("owner", currentUserId != null && currentUserId.equals(game.getUserId())); } + private String webglFrameSrc(String path) { + String normalizedPath = trimToEmpty(path); + String origin = trimToEmpty(webglAssetOrigin); + if (origin.isBlank()) { + return normalizedPath; + } + while (origin.endsWith("/")) { + origin = origin.substring(0, origin.length() - 1); + } + return origin + (normalizedPath.startsWith("/") ? normalizedPath : "/" + normalizedPath); + } + + private String safeExternalUrl(String value) { + String text = trimToNull(value); + if (text == null) { + return ""; + } + String lower = text.toLowerCase(Locale.ROOT); + return lower.startsWith("http://") || lower.startsWith("https://") ? text : ""; + } + private Long sessionUserId(HttpSession session) { if (session == null) { return null; diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java index eab79ce..e614ab0 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java @@ -1,5 +1,7 @@ package com.pandoli365.bibimbap.controller.api; +import com.pandoli365.bibimbap.security.CsrfTokens; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; @@ -51,8 +53,12 @@ public class GameUploadController { public ResponseEntity> uploadGameFiles( @RequestParam("files") MultipartFile[] files, @RequestParam(name = "path", required = false) String path, + HttpServletRequest request, HttpSession session ) throws IOException { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } if (sessionUserId(session) == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("message", "login is required")); @@ -94,8 +100,12 @@ public class GameUploadController { @PostMapping("/webgl-zip") public ResponseEntity> uploadWebglZip( @RequestParam(name = "file", required = false) MultipartFile file, + HttpServletRequest request, HttpSession session ) throws IOException { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } log.info("WebGL zip upload request received. fileName={}, size={}, contentType={}", file == null ? null : file.getOriginalFilename(), file == null ? null : file.getSize(), @@ -154,8 +164,12 @@ public class GameUploadController { public ResponseEntity> uploadThumbnail( @RequestParam(name = "file", required = false) MultipartFile file, @RequestParam(name = "gameUuid", required = false) String gameUuid, + HttpServletRequest request, HttpSession session ) throws IOException { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } Long userId = sessionUserId(session); if (userId == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java index e12fcbd..f4d95dc 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java @@ -4,6 +4,7 @@ import com.pandoli365.bibimbap.data.UserAuthIdentityData; import com.pandoli365.bibimbap.data.UserData; import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper; import com.pandoli365.bibimbap.mapper.UsersMapper; +import com.pandoli365.bibimbap.security.CsrfTokens; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import org.springframework.beans.factory.annotation.Value; @@ -159,8 +160,13 @@ public class UserController { } @PostMapping("/logout") - public String logout(HttpSession session) { - session.invalidate(); + public String logout(HttpServletRequest request, HttpSession session) { + if (!CsrfTokens.isValid(request)) { + return "redirect:/"; + } + if (session != null) { + session.invalidate(); + } return "redirect:/"; } @@ -170,6 +176,9 @@ public class UserController { @RequestParam(name = "displayName", required = false) String displayName, HttpServletRequest request ) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new AuthResult(403, "요청 보안 토큰이 유효하지 않습니다.")); + } HttpSession session = request.getSession(false); Long userId = sessionUserId(session); if (userId == null) { @@ -203,6 +212,9 @@ public class UserController { @RequestParam(name = "avatar", required = false) MultipartFile avatar, HttpServletRequest request ) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new AuthResult(403, "요청 보안 토큰이 유효하지 않습니다.")); + } HttpSession session = request.getSession(false); Long userId = sessionUserId(session); if (userId == null) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1956720..351d00b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -19,6 +19,8 @@ server.tomcat.max-swallow-size=-1 # common spring.config.import=optional:classpath:${spring.profiles.active}/db.properties,optional:file:src/main/resources/${spring.profiles.active}/db.properties +app.webgl.asset-origin= +app.webgl.frame-ancestors=self # log mybatis.configuration.log-impl=org.apache.ibatis.logging.slf4j.Slf4jImpl diff --git a/src/main/resources/dev/db.properties.example b/src/main/resources/dev/db.properties.example index 9faf221..0fb9646 100644 --- a/src/main/resources/dev/db.properties.example +++ b/src/main/resources/dev/db.properties.example @@ -4,3 +4,5 @@ spring.datasource.username=your_username spring.datasource.password=your_password app.upload.game-storage-path=src/main/resources/static/game +app.webgl.asset-origin= +app.webgl.frame-ancestors=self diff --git a/src/main/resources/live/db.properties.example b/src/main/resources/live/db.properties.example index b628c06..2387247 100644 --- a/src/main/resources/live/db.properties.example +++ b/src/main/resources/live/db.properties.example @@ -4,3 +4,5 @@ spring.datasource.username=your_username spring.datasource.password=your_password app.upload.game-storage-path=src/main/resources/static/game +app.webgl.asset-origin= +app.webgl.frame-ancestors=self diff --git a/src/main/webapp/WEB-INF/views/game-detail.jsp b/src/main/webapp/WEB-INF/views/game-detail.jsp index 605762c..a2a003c 100644 --- a/src/main/webapp/WEB-INF/views/game-detail.jsp +++ b/src/main/webapp/WEB-INF/views/game-detail.jsp @@ -1,6 +1,24 @@ <%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> +<%@ page import="org.springframework.web.util.HtmlUtils" %> <% boolean owner = Boolean.TRUE.equals(request.getAttribute("owner")); + String ctx = request.getContextPath(); + String gameIdValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("gameId"))); + String gameNameValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("gameName"))); + String creatorValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("creator"))); + String likeCountFormattedValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("likeCountFormatted"))); + String creatorNoteValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("creatorNote"))); + String gitUrlValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("gitUrl"))); + String webglFrameSrc = String.valueOf(request.getAttribute("webglFrameSrc")); + if (webglFrameSrc == null || webglFrameSrc.isBlank() || "null".equals(webglFrameSrc)) { + webglFrameSrc = String.valueOf(request.getAttribute("webglUrl")); + } + if (webglFrameSrc == null || webglFrameSrc.isBlank() || "null".equals(webglFrameSrc)) { + webglFrameSrc = ""; + } else if (webglFrameSrc.startsWith("/")) { + webglFrameSrc = ctx + webglFrameSrc; + } + String webglFrameSrcValue = HtmlUtils.htmlEscape(webglFrameSrc); %> @@ -8,7 +26,7 @@ - ${gameName} — bibimbap + <%= gameNameValue %> — bibimbap