diff --git a/.atp/work-session b/.atp/work-session new file mode 100644 index 0000000..555aff7 --- /dev/null +++ b/.atp/work-session @@ -0,0 +1,47 @@ +# ATP Work Session: 2026-06-16 Project Analysis + +## Objective + +Introduce a docs/ workflow scaffold and record a read-only full-project analysis focused on architecture, security, quality, and domain behavior. + +## Constraints + +- Do not modify `src/`. +- Do not modify `pom.xml`. +- Documentation and project guidance files only. + +## Files Added + +- `docs/index.md` +- `docs/analysis/README.md` +- `docs/analysis/2026-06-16-project-analysis.md` +- `docs/security/README.md` +- `docs/security/security-remediation-checklist.md` +- `docs/architecture/README.md` +- `docs/development/README.md` +- `docs/adr/README.md` +- `CLAUDE.md` +- `.atp/work-session` +- `.serena/project.yml` + +## Files Updated + +- `docs/db-update-query-generator.md` +- `docs/user-signup-schema.md` + +## Findings + +- SQL injection surface was not found in scanned mapper/controller code because MyBatis mapper SQL uses `#{}` binding and no `${}` dynamic replacement was found. +- Password storage uses PBKDF2-SHA256 with 210,000 iterations. +- Most state-changing endpoints use `CsrfTokens.isValid`. +- `POST /login` and `POST /signup` do not validate CSRF and are the primary MED security gap. +- Prototype leftovers include `abstracts`, `header.jspf`, and empty `GameCatalog`. +- Likes/comments have mapper/schema traces but the game detail UI persists them only in `localStorage`. +- Tests are currently insufficient for security regressions. + +## Next Work + +- B1: Add CSRF validation to login/signup. +- B2: Remove or document prototype dead code. +- B3: Connect likes/comments to server persistence after policy decisions. +- B4: Pin Spring Boot release version, harden session cookies, and add dependency CVE scanning. diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..30f2d70 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,18 @@ +project_name: bibimbap +language: java +build_system: maven +frameworks: + - Spring Boot + - JSP + - MyBatis +package_type: war +docs: + index: docs/index.md + latest_analysis: docs/analysis/2026-06-16-project-analysis.md + security_checklist: docs/security/security-remediation-checklist.md +guidelines: + - For documentation-only analysis, do not modify src/ or pom.xml. + - Record security findings with file:line evidence. + - Prefer rg for code and document search. + - Preserve user changes in the working tree. + - Split security remediation into small PRs with tests. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5b73339 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,33 @@ +# CLAUDE.md + +이 파일은 AI 코딩 에이전트가 bibimbap 프로젝트에서 작업할 때 따를 프로젝트 지침이다. + +## 프로젝트 개요 + +- Java 21, Spring Boot, JSP, MyBatis 기반 WAR 애플리케이션이다. +- 화면은 `/WEB-INF/views/*.jsp`를 사용한다. +- DB 접근은 annotation 기반 MyBatis mapper를 사용한다. +- 업로드된 프로필 이미지는 `/profile/**`, 게임 WebGL asset은 `/game/{gameUuid}/**`로 제공된다. + +## 작업 원칙 + +- 사용자 변경을 보호한다. 작업 전후로 `git status --short`를 확인한다. +- 검색은 우선 `rg`를 사용한다. +- 문서-only 분석 요청에서는 `src/`와 `pom.xml`을 수정하지 않는다. +- 보안 발견 사항은 `docs/analysis/`에 `file:line` 근거와 함께 기록한다. +- 보안 개선 작업은 `docs/security/security-remediation-checklist.md`의 완료 조건을 기준으로 분리한다. + +## 보안 원칙 + +- 상태 변경 요청은 CSRF 검증을 적용한다. +- MyBatis SQL은 `#{}` 바인딩을 사용하고 `${}` 동적 치환을 피한다. +- 업로드 파일은 크기, 타입, normalize된 저장 경로 boundary를 모두 검증한다. +- JSP 출력은 `HtmlUtils.htmlEscape` 또는 JSTL escape를 사용한다. +- 클라이언트 렌더링은 가능한 `textContent`를 사용한다. +- 세션 관련 변경은 세션 고정 방어와 쿠키 하드닝을 함께 확인한다. + +## 문서 위치 + +- 문서 인덱스: `docs/index.md` +- 최신 프로젝트 분석: `docs/analysis/2026-06-16-project-analysis.md` +- 보안 개선 체크리스트: `docs/security/security-remediation-checklist.md` diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..e4f6a1e --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,31 @@ +# ADR + +Architecture Decision Record를 보관한다. + +## 작성 템플릿 + +```md +# ADR-NNN: 제목 + +## 상태 + +Proposed | Accepted | Superseded + +## 배경 + +결정이 필요한 이유와 관련 근거를 적는다. + +## 결정 + +선택한 방향을 적는다. + +## 결과 + +장점, 단점, 후속 작업을 적는다. +``` + +## 후보 + +- ADR-001: WebGL asset 제공 origin 정책 +- ADR-002: 서비스 레이어 도입 범위 +- ADR-003: 좋아요/댓글 사용자 식별 정책 diff --git a/docs/analysis/2026-06-16-project-analysis.md b/docs/analysis/2026-06-16-project-analysis.md new file mode 100644 index 0000000..cb7a556 --- /dev/null +++ b/docs/analysis/2026-06-16-project-analysis.md @@ -0,0 +1,179 @@ +# 2026-06-16 프로젝트 전면 분석 + +## 범위 + +- 목적: 현재 프로젝트의 아키텍처, 보안, 품질, 도메인 상태를 읽기 전용으로 분석하고 후속 보안 개선 항목을 정리한다. +- 변경 원칙: 이 분석 세션은 문서와 프로젝트 지침만 추가/수정한다. `src/`와 `pom.xml`은 수정하지 않는다. +- 조사 방식: 파일 목록, 컨트롤러, 매퍼, JSP, 설정 파일을 `rg`와 라인 번호 기준으로 확인했다. + +## 요약 + +- 보안 기초는 비교적 견고하다. MyBatis 매퍼는 동적 `${}` 조립 없이 `#{}` 바인딩을 쓰고, 비밀번호는 PBKDF2-SHA256 210,000회 반복으로 저장한다. +- 주요 보안 갭은 `POST /login`, `POST /signup`에 CSRF 검증이 없는 점이다. 다른 주요 상태 변경 API는 `CsrfTokens.isValid`를 적용하고 있다. +- 파일 업로드와 WebGL asset 제공 경로는 normalize/startsWith 검사를 반복 적용하고, zip-slip 및 XSS 방어도 일관된 편이다. +- 구조적 부채는 서비스 레이어 부재, fat controller, 프로토타입 잔재(`abstracts`, `header.jspf`, `GameCatalog`), 실질 테스트 부족이다. +- 좋아요/댓글은 DB 매퍼와 삭제 연계는 있으나, 상세 화면은 `localStorage`만 사용해 서버 영속화가 미완성이다. + +## 1. 아키텍처 분석 + +### A1. 현재 구조는 Controller + Mapper 중심이다 + +- Spring MVC JSP 뷰 구조는 `spring.mvc.view.prefix`와 suffix 설정에 고정되어 있다. 근거: `src/main/resources/application.properties:5`, `src/main/resources/application.properties:6` +- 홈/로그인/회원가입/프로필 라우팅은 `WebMvcController`가 담당한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java:57`, `src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java:64`, `src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java:75`, `src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java:88` +- 회원가입, 로그인, 프로필 변경은 `UserController`가 요청 검증, DB 접근, 세션 저장, 응답 생성을 함께 수행한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:65`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:118`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:173`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:209`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:494` +- 게임 등록/수정/삭제도 컨트롤러가 검증, 권한 확인, 매퍼 호출을 직접 수행한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:41`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:158`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:222` +- 모집글 등록도 동일 패턴이다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java:49`, `src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java:73`, `src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java:116` + +영향: 단기 구현 속도는 빠르지만, 보안 검증과 도메인 규칙이 컨트롤러마다 흩어진다. CSRF 누락처럼 특정 엔드포인트만 빠지는 회귀가 발생하기 쉽다. + +### A2. 정적 리소스 제공은 프로필과 게임 asset이 분리되어 있다 + +- 프로필 이미지는 `UploadResourceConfig`에서 `/profile/**`로 노출한다. 근거: `src/main/java/com/pandoli365/bibimbap/config/UploadResourceConfig.java:19`, `src/main/java/com/pandoli365/bibimbap/config/UploadResourceConfig.java:22` +- WebGL 게임 asset은 별도 컨트롤러가 `/game/{gameUuid}/**`를 직접 처리한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java:31`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java:43`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java:50` +- WebGL 응답에는 `X-Content-Type-Options`와 CSP가 설정된다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java:59`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java:60`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java:65` + +영향: 업로드 asset을 일반 정적 리소스와 분리한 점은 좋다. 단, 사용자가 만든 WebGL을 같은 사이트 origin에서 iframe으로 실행하므로 장기적으로 별도 asset origin 정책을 결정해야 한다. + +### A3. 프로토타입 잔재가 남아 있다 + +- `GameController`는 DB에 게임이 없으면 `GameCatalog`로 fallback한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:103`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:111`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:116` +- `GameCatalog`는 빈 배열만 가진다. 근거: `src/main/java/com/pandoli365/bibimbap/game/GameCatalog.java:8`, `src/main/java/com/pandoli365/bibimbap/game/GameCatalog.java:12`, `src/main/java/com/pandoli365/bibimbap/game/GameCatalog.java:18` +- `header.jspf`는 Spring Security taglib와 `${_csrf}`를 전제로 하지만, 실제 화면은 `/WEB-INF/views/header.jsp`를 include한다. 근거: `src/main/webapp/WEB-INF/jsp/fragments/header.jspf:1`, `src/main/webapp/WEB-INF/jsp/fragments/header.jspf:2`, `src/main/webapp/WEB-INF/jsp/fragments/header.jspf:14`, `src/main/webapp/WEB-INF/views/login.jsp:203`, `src/main/webapp/WEB-INF/views/signup.jsp:201` +- `abstracts.Service`는 현재 컨트롤러 흐름과 별개인 수동 인증/요청 검증 추상화다. 근거: `src/main/java/com/pandoli365/bibimbap/abstracts/Service.java:5`, `src/main/java/com/pandoli365/bibimbap/abstracts/Service.java:7`, `src/main/java/com/pandoli365/bibimbap/abstracts/Service.java:11`, `src/main/java/com/pandoli365/bibimbap/abstracts/Service.java:12` + +영향: 죽은 코드가 보안 로직처럼 보이는 상태로 남아 있으면 새 구현자가 잘못 재사용할 수 있다. 삭제 전 참조 검색과 동작 확인이 필요하다. + +## 2. 보안 분석 + +### S1. SQL injection 표면은 현재 스캔 범위에서 발견되지 않았다 + +- `src/main/java/com/pandoli365/bibimbap/mapper`와 controller 패키지에서 MyBatis 동적 치환 `${}` 사용은 발견되지 않았다. +- 검색 쿼리도 `ILIKE CONCAT('%', #{query}, '%')`로 바인딩한다. 근거: `src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java:85`, `src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java:86`, `src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java:87` +- 주요 insert/update/select는 `#{}` 바인딩을 사용한다. 근거: `src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java:25`, `src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java:39`, `src/main/java/com/pandoli365/bibimbap/mapper/UserAuthIdentitiesMapper.java:47`, `src/main/java/com/pandoli365/bibimbap/mapper/UserAuthIdentitiesMapper.java:67`, `src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java:128`, `src/main/java/com/pandoli365/bibimbap/mapper/RecruitPostsMapper.java:91` + +판정: 현재 코드 스캔 기준 SQLi 직접 표면은 0이다. 동적 정렬/테이블명 기능을 추가할 때도 `${}`를 금지하는 규칙을 유지해야 한다. + +### S2. 비밀번호 저장 방식은 강한 편이다 + +- 최소 비밀번호 길이와 PBKDF2 파라미터가 상수로 정의되어 있다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:45`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:46`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:47`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:48` +- 신규 비밀번호는 salt 생성 후 `pbkdf2_sha256$iterations$salt$hash` 형식으로 저장된다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:542`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:543`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:546` +- 검증은 저장된 iteration/salt를 읽고 `MessageDigest.isEqual`로 비교한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:551`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:558`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:561`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:562` +- 알고리즘은 `PBKDF2WithHmacSHA256`이다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:568`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:571` + +판정: 현재 구현은 기본 수준 이상이다. 향후에는 로그인 실패 제한, 비밀번호 재해시 정책, 계정 잠금 정책을 별도 개선으로 다루면 된다. + +### S3. CSRF 방어는 전반적으로 있으나 login/signup에 구멍이 있다 + +- CSRF 토큰은 세션에 저장되고 32바이트 난수로 생성된다. 근거: `src/main/java/com/pandoli365/bibimbap/security/CsrfTokens.java:20`, `src/main/java/com/pandoli365/bibimbap/security/CsrfTokens.java:28`, `src/main/java/com/pandoli365/bibimbap/security/CsrfTokens.java:31` +- 검증은 `X-CSRF-Token` 헤더 또는 `_csrf` 파라미터를 허용한다. 근거: `src/main/java/com/pandoli365/bibimbap/security/CsrfTokens.java:35`, `src/main/java/com/pandoli365/bibimbap/security/CsrfTokens.java:44`, `src/main/java/com/pandoli365/bibimbap/security/CsrfTokens.java:46`, `src/main/java/com/pandoli365/bibimbap/security/CsrfTokens.java:48` +- JSP 공통 초기화는 meta token과 JS header helper를 제공한다. 근거: `src/main/webapp/WEB-INF/views/theme-init.jsp:5`, `src/main/webapp/WEB-INF/views/theme-init.jsp:7`, `src/main/webapp/WEB-INF/views/theme-init.jsp:19`, `src/main/webapp/WEB-INF/views/theme-init.jsp:27` +- 로그아웃, 프로필 변경, 게임 업로드, 게임 등록/수정/삭제, 모집글 등록은 CSRF 검증을 한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:164`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:179`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:215`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:59`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:106`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:170`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:53`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:171`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:227`, `src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java:65` +- `POST /signup`은 컨트롤러 진입 후 CSRF 검증 없이 입력 검증과 DB 조회를 시작한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:65`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:75`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:87` +- `POST /login`도 CSRF 검증 없이 인증 로직과 세션 갱신으로 이어진다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:118`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:126`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:151`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:152` +- 로그인/회원가입 fetch 요청은 현재 CSRF header helper를 쓰지 않는다. 근거: `src/main/webapp/WEB-INF/views/login.jsp:277`, `src/main/webapp/WEB-INF/views/login.jsp:279`, `src/main/webapp/WEB-INF/views/login.jsp:284`, `src/main/webapp/WEB-INF/views/signup.jsp:291`, `src/main/webapp/WEB-INF/views/signup.jsp:293`, `src/main/webapp/WEB-INF/views/signup.jsp:297` + +판정: MED. 공격자가 피해자의 브라우저에서 원치 않는 로그인 상태 전환 또는 회원가입 요청을 유도할 수 있다. 기존 CSRF 헬퍼가 있어 수정 범위는 작다. + +### S4. 업로드와 파일 경로 방어는 일관되어 있다 + +- 게임 파일 업로드는 CSRF와 로그인 세션을 먼저 확인한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:59`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:62` +- WebGL zip 업로드는 zip 여부, 로그인, target path boundary를 확인한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:106`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:113`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:121`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:127`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:128` +- zip entry 개수와 압축 해제 총량 제한이 있다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:40`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:41`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:261`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:297` +- zip-slip 방어는 `targetDir.resolve(entry.getName()).normalize()` 후 `startsWith(targetDir)`로 수행된다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:266`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:267`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:268` +- 썸네일/프로필 이미지는 크기와 확장자/Content-Type 검사를 한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:181`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:186`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:228`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:233` +- 프로필 저장 경로도 root boundary를 검사한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:246`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:248`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:254`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:256` +- 게임 asset 조회도 URI decode 후 normalize/startsWith를 수행한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java:91`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java:92`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java:101`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java:102` + +판정: 현재 방어는 좋다. 다만 운영 설정에서 multipart 상한이 1GB로 높아 실제 서버 메모리/디스크 정책과 맞는지 재검토해야 한다. 근거: `src/main/resources/application.properties:15`, `src/main/resources/application.properties:16`, `src/main/resources/application.properties:17`, `src/main/resources/application.properties:18` + +### S5. XSS 방어는 서버 렌더와 클라이언트 렌더 모두 대체로 안전하다 + +- 게임 상세 주요 출력값은 `HtmlUtils.htmlEscape`로 이스케이프한다. 근거: `src/main/webapp/WEB-INF/views/game-detail.jsp:6`, `src/main/webapp/WEB-INF/views/game-detail.jsp:7`, `src/main/webapp/WEB-INF/views/game-detail.jsp:10`, `src/main/webapp/WEB-INF/views/game-detail.jsp:21` +- 홈 검색어와 게임 카드 출력도 escape를 적용한다. 근거: `src/main/webapp/WEB-INF/views/index.jsp:21`, `src/main/webapp/WEB-INF/views/index.jsp:466`, `src/main/webapp/WEB-INF/views/index.jsp:506`, `src/main/webapp/WEB-INF/views/index.jsp:507`, `src/main/webapp/WEB-INF/views/index.jsp:514` +- 댓글 localStorage 렌더링은 `textContent`를 사용한다. 근거: `src/main/webapp/WEB-INF/views/game-detail.jsp:951`, `src/main/webapp/WEB-INF/views/game-detail.jsp:958`, `src/main/webapp/WEB-INF/views/game-detail.jsp:969` +- 프로필 아바타 URL은 출력 전에 escape한다. 근거: `src/main/webapp/WEB-INF/views/header.jsp:8`, `src/main/webapp/WEB-INF/views/header.jsp:11`, `src/main/webapp/WEB-INF/views/header.jsp:219` +- 외부 Git URL은 `http://` 또는 `https://`만 허용한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:282`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:287`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:288` + +판정: 현재 XSS 방어는 일관된 편이다. 서버 댓글 영속화를 붙일 때도 JSP 직접 출력 대신 escape/textContent 원칙을 유지해야 한다. + +### S6. 세션 보안은 기본은 있으나 쿠키 하드닝 설정이 없다 + +- 로그인 성공 시 `request.changeSessionId()`로 세션 고정 공격을 줄인다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:151`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:152` +- 로그인 유지 옵션은 세션 만료 시간을 조정한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:49`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:50`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:317`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:318` +- `application.properties`에는 session cookie `HttpOnly`, `Secure`, `SameSite` 설정이 없다. 근거: `src/main/resources/application.properties:1`, `src/main/resources/application.properties:27` + +판정: B4에서 운영 프로필 기준 쿠키 하드닝을 추가한다. + +### S7. 의존성/로깅 운영 리스크가 있다 + +- Spring Boot 버전이 SNAPSHOT이다. 근거: `pom.xml:29` +- snapshot repository와 pluginRepository가 활성화되어 있다. 근거: `pom.xml:200`, `pom.xml:202`, `pom.xml:210`, `pom.xml:212` +- MyBatis TRACE 로그가 켜져 있다. 근거: `src/main/resources/application.properties:25`, `src/main/resources/application.properties:26`, `src/main/resources/application.properties:27` + +판정: 배포 안정성과 보안 재현성을 위해 release 버전 고정, CVE 스캔, 운영 로깅 레벨 분리가 필요하다. + +## 3. 품질 분석 + +### Q1. 테스트가 실질 보안/도메인 동작을 검증하지 않는다 + +- 기본 테스트는 context load뿐이다. 근거: `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java:6`, `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java:9`, `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java:10` +- DB 업데이트 쿼리 생성 테스트는 surefire에서 제외되어 일반 테스트로 돌지 않는다. 근거: `pom.xml:127`, `pom.xml:130`, `pom.xml:131` +- CSRF, 로그인, 업로드 zip-slip, XSS 렌더링 회귀 테스트가 없다. 근거: 현재 `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java:9` 외 애플리케이션 동작 테스트가 확인되지 않는다. + +영향: 보안 개선 PR마다 최소 MockMvc 테스트를 붙여 회귀를 잡아야 한다. + +### Q2. 컨트롤러에 중복 helper가 많다 + +- 세션 사용자 ID 추출이 여러 컨트롤러에 반복된다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:325`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:222`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:291`, `src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java:155` +- trim helper도 반복된다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:317`, `src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java:173` + +영향: 정책 변경 시 수정 누락 위험이 높다. 서비스/유틸 레이어 도입은 보안 변경 뒤 별도 PR로 다루는 편이 안전하다. + +### Q3. 설정 상한과 실제 검증 상한이 어긋난다 + +- Spring multipart 상한은 1GB다. 근거: `src/main/resources/application.properties:15`, `src/main/resources/application.properties:16` +- WebGL zip 압축 해제 총량 제한은 512MB다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:40`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java:297` + +영향: 애플리케이션 레벨에서 거절하기 전 서버가 큰 요청을 받아야 한다. 운영 인프라 제한과 함께 조정해야 한다. + +## 4. 도메인 분석 + +### D1. 회원가입 문서는 다중 provider를 설명하지만 구현은 email provider 중심이다 + +- 문서는 `guest`, `google`, `email`, `kakao`, `naver`, `github`, `apple`를 설명한다. 근거: `docs/user-signup-schema.md:32` +- 현재 컨트롤러는 `PROVIDER_EMAIL` 상수로 email provider를 사용한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:42`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:87`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:100` +- 회원가입 JSP에는 `provider` hidden input이 있지만 컨트롤러는 해당 파라미터를 받지 않는다. 근거: `src/main/webapp/WEB-INF/views/signup.jsp:216`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:67`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:72` + +영향: 문서와 구현 간 목표 차이가 있다. 소셜/게스트 가입을 실제 기능으로 만들지, 문서를 email-only로 조정할지 결정해야 한다. + +### D2. 게임 등록/수정/삭제는 서버 영속화되어 있다 + +- 게임 생성은 `gamesMapper.addGame`으로 저장한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:80`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:90`, `src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java:117` +- 게임 수정은 작성자 확인 후 `gamesMapper.updateGame`을 호출한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:179`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:183`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:212` +- 게임 삭제는 댓글 soft delete와 좋아요 삭제를 함께 수행한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:243`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:244`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:245` + +영향: 게임 CRUD는 서버 중심으로 동작한다. 좋아요/댓글만 연결되지 않은 상태다. + +### D3. 좋아요/댓글은 DB 모델은 있으나 상세 화면은 localStorage 전용이다 + +- 좋아요 매퍼는 존재한다. 근거: `src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java:10`, `src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java:24`, `src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java:36` +- 댓글 매퍼는 존재한다. 근거: `src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java:10`, `src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java:27`, `src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java:41` +- 상세 화면 좋아요는 `localStorage` map만 갱신한다. 근거: `src/main/webapp/WEB-INF/views/game-detail.jsp:811`, `src/main/webapp/WEB-INF/views/game-detail.jsp:812`, `src/main/webapp/WEB-INF/views/game-detail.jsp:817`, `src/main/webapp/WEB-INF/views/game-detail.jsp:830`, `src/main/webapp/WEB-INF/views/game-detail.jsp:904`, `src/main/webapp/WEB-INF/views/game-detail.jsp:906` +- 상세 화면 댓글도 `localStorage`에 저장하고 DOM으로 렌더링한다. 근거: `src/main/webapp/WEB-INF/views/game-detail.jsp:813`, `src/main/webapp/WEB-INF/views/game-detail.jsp:913`, `src/main/webapp/WEB-INF/views/game-detail.jsp:928`, `src/main/webapp/WEB-INF/views/game-detail.jsp:994`, `src/main/webapp/WEB-INF/views/game-detail.jsp:1002` + +영향: 사용자는 브라우저를 바꾸면 좋아요/댓글을 잃는다. 서버 연결 시 익명 허용 여부, 남용 방지, 삭제 권한, CSRF 정책을 먼저 확정해야 한다. + +### D4. 모집글은 서버 영속화되어 있다 + +- 목록, 작성, 상세 라우트가 존재한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java:35`, `src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java:49`, `src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java:145` +- 작성 시 enum-like set과 길이 검증을 수행한다. 근거: `src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java:25`, `src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java:26`, `src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java:27`, `src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java:80`, `src/main/java/com/pandoli365/bibimbap/controller/RecruitController.java:112` +- 매퍼는 visible/not-deleted 조건으로 조회한다. 근거: `src/main/java/com/pandoli365/bibimbap/mapper/RecruitPostsMapper.java:67`, `src/main/java/com/pandoli365/bibimbap/mapper/RecruitPostsMapper.java:68`, `src/main/java/com/pandoli365/bibimbap/mapper/RecruitPostsMapper.java:74` + +영향: 모집글 쪽은 최소 CRUD 흐름이 게임 CRUD와 비슷한 수준으로 정리되어 있다. + +## 후속 작업 후보 + +- B1: `login`/`signup` CSRF 검증 추가. 자세한 실행 항목은 [보안 개선 체크리스트](../security/security-remediation-checklist.md#b1-loginsignup-csrf-검증-추가-med)를 따른다. +- B2: 프로토타입 dead code 제거. 자세한 실행 항목은 [보안 개선 체크리스트](../security/security-remediation-checklist.md#b2-프로토타입-dead-code-제거)를 따른다. +- B3: 좋아요/댓글 서버 영속화 연결. 자세한 실행 항목은 [보안 개선 체크리스트](../security/security-remediation-checklist.md#b3-좋아요댓글-서버-영속화-연결)를 따른다. +- B4: Spring Boot SNAPSHOT 고정, 세션 쿠키 하드닝, 의존성 CVE 스캔. 자세한 실행 항목은 [보안 개선 체크리스트](../security/security-remediation-checklist.md#b4-의존성세션운영-하드닝)를 따른다. diff --git a/docs/analysis/README.md b/docs/analysis/README.md new file mode 100644 index 0000000..a131bd5 --- /dev/null +++ b/docs/analysis/README.md @@ -0,0 +1,14 @@ +# Analysis + +프로젝트 전면 분석, 감사 기록, 리스크 인벤토리를 보관한다. + +## 문서 + +- [2026-06-16 프로젝트 전면 분석](2026-06-16-project-analysis.md) + +## 작성 규칙 + +- 분석 범위와 변경 범위를 먼저 적는다. +- 발견 사항은 아키텍처, 보안, 품질, 도메인 축으로 분류한다. +- 모든 발견은 `file:line` 근거를 포함한다. +- 후속 작업은 보안/기능/리팩터링 PR로 분리할 수 있게 체크리스트와 연결한다. diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..2cb265d --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,21 @@ +# Architecture + +현재 구조와 향후 아키텍처 결정 기록으로 이어지는 문서를 보관한다. + +## 현재 구조 요약 + +- Spring Boot WAR 패키징, JSP ViewResolver, MyBatis mapper 중심 구조. +- Controller가 요청 검증, 세션 확인, 도메인 처리, mapper 호출을 직접 수행한다. +- 프로필 이미지는 `/profile/**` resource handler로 제공하고, WebGL 게임 asset은 `GameAssetController`가 직접 제공한다. + +## 관련 문서 + +- [2026-06-16 프로젝트 전면 분석](../analysis/2026-06-16-project-analysis.md) +- [보안 개선 체크리스트](../security/security-remediation-checklist.md) + +## 후속 ADR 후보 + +- 서비스 레이어 도입 기준 +- WebGL asset 별도 origin 분리 여부 +- 좋아요/댓글 익명 허용 정책 +- 회원가입 provider 범위 diff --git a/docs/db-update-query-generator.md b/docs/db-update-query-generator.md index 4d75fb5..de52640 100644 --- a/docs/db-update-query-generator.md +++ b/docs/db-update-query-generator.md @@ -2,6 +2,13 @@ `DbUpdateQueryGeneratorTest`는 `dev` 스키마와 `live` 스키마의 테이블 구조를 비교해서, `live`에 필요한 DDL 쿼리를 생성하는 테스트 유틸이다. +관련 문서: + +- [문서 인덱스](index.md) +- [2026-06-16 프로젝트 전면 분석](analysis/2026-06-16-project-analysis.md) +- [보안 개선 체크리스트](security/security-remediation-checklist.md) +- [회원가입 정보 구조](user-signup-schema.md) + 현재 설정은 코드에 하드코딩되어 있다. ```java diff --git a/docs/development/README.md b/docs/development/README.md new file mode 100644 index 0000000..01c1f1f --- /dev/null +++ b/docs/development/README.md @@ -0,0 +1,18 @@ +# Development + +개발 워크플로, 테스트, 운영성 메모를 보관한다. + +## 기본 워크플로 + +1. 변경 전 `git status --short`로 사용자 변경을 확인한다. +2. 분석 작업은 `rg`와 라인 번호 근거를 남긴다. +3. 보안 변경은 최소 회귀 테스트를 추가한다. +4. 문서-only 작업은 `src/`와 `pom.xml`을 수정하지 않는다. +5. 변경 후 `git diff --name-only`로 범위를 확인한다. + +## 관련 문서 + +- [DB 업데이트 쿼리 생성기](../db-update-query-generator.md) +- [회원가입 정보 구조](../user-signup-schema.md) +- [2026-06-16 프로젝트 전면 분석](../analysis/2026-06-16-project-analysis.md) +- [보안 개선 체크리스트](../security/security-remediation-checklist.md) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..0abd889 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,41 @@ +# bibimbap Documentation + +이 디렉터리는 프로젝트 분석, 보안 개선 체크리스트, 아키텍처 메모, 개발 운영 문서를 모아 둔다. + +## 빠른 링크 + +- [2026-06-16 프로젝트 전면 분석](analysis/2026-06-16-project-analysis.md) +- [보안 개선 체크리스트](security/security-remediation-checklist.md) +- [DB 업데이트 쿼리 생성기](db-update-query-generator.md) +- [회원가입 정보 구조](user-signup-schema.md) + +## 문서 트리 + +```text +docs/ + index.md + adr/ + README.md + analysis/ + README.md + 2026-06-16-project-analysis.md + architecture/ + README.md + development/ + README.md + security/ + README.md + security-remediation-checklist.md + db-update-query-generator.md + user-signup-schema.md + security-hardening-ddl.sql + recruit-posts-ddl.sql + profile-management-prompt.md +``` + +## 운영 원칙 + +- 프로젝트 분석 문서는 발견마다 `file:line` 근거를 남긴다. +- 코드 변경 없이 분석만 하는 작업은 `src/`와 `pom.xml`을 수정하지 않는다. +- 보안 개선은 체크리스트의 우선순위와 완료 조건을 기준으로 별도 PR로 분리한다. +- 스키마, 운영 쿼리, 배포 절차를 바꾸는 문서는 관련 보안/아키텍처 문서와 교차 링크한다. diff --git a/docs/security/README.md b/docs/security/README.md new file mode 100644 index 0000000..e8481ba --- /dev/null +++ b/docs/security/README.md @@ -0,0 +1,17 @@ +# Security + +보안 분석 결과와 개선 체크리스트를 보관한다. + +## 문서 + +- [보안 개선 체크리스트](security-remediation-checklist.md) +- [2026-06-16 프로젝트 전면 분석](../analysis/2026-06-16-project-analysis.md) +- [Security hardening DDL](../security-hardening-ddl.sql) + +## 원칙 + +- 상태 변경 요청은 CSRF 검증을 기본값으로 둔다. +- DB 접근은 MyBatis `#{}` 바인딩을 사용하고 `${}` 동적 치환은 금지한다. +- 업로드 파일은 크기, 타입, 경로 boundary를 모두 검증한다. +- 사용자 입력 출력은 JSP에서 escape하거나 클라이언트에서 `textContent`로 렌더링한다. +- 보안 변경은 최소 하나 이상의 회귀 테스트를 함께 추가한다. diff --git a/docs/security/security-remediation-checklist.md b/docs/security/security-remediation-checklist.md new file mode 100644 index 0000000..30e23b6 --- /dev/null +++ b/docs/security/security-remediation-checklist.md @@ -0,0 +1,154 @@ +# 보안 개선 체크리스트 + +기준 분석: [2026-06-16 프로젝트 전면 분석](../analysis/2026-06-16-project-analysis.md) + +## 상태 표시 + +- `[ ]` 미착수 +- `[~]` 진행 중 +- `[x]` 완료 +- `[hold]` 의도 확인 또는 별도 결정 필요 + +## 우선순위 + +| ID | 우선순위 | 항목 | 보안 영향 | +| --- | --- | --- | --- | +| B1 | P1 | login/signup CSRF 검증 추가 | MED, 완료 | +| B2 | P2 | 프로토타입 dead code 제거 | LOW-MED | +| B3 | P2 | 좋아요/댓글 서버 영속화 연결 | MED, 기능 무결성 | +| B4 | P2 | 의존성/세션/운영 하드닝 | MED | + +## B1. login/signup CSRF 검증 추가 (MED) + +근거: + +- `POST /signup`은 CSRF 검증 없이 입력 검증과 DB 조회를 시작한다. `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:65`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:75`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:87` +- `POST /login`은 CSRF 검증 없이 인증과 세션 갱신을 수행한다. `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:118`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:151`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:152` +- 로그인/회원가입 fetch 요청은 `window.BibimbapCsrf.headers`를 쓰지 않는다. `src/main/webapp/WEB-INF/views/login.jsp:277`, `src/main/webapp/WEB-INF/views/login.jsp:279`, `src/main/webapp/WEB-INF/views/signup.jsp:291`, `src/main/webapp/WEB-INF/views/signup.jsp:293` +- 공통 CSRF helper는 이미 있다. `src/main/webapp/WEB-INF/views/theme-init.jsp:19`, `src/main/webapp/WEB-INF/views/theme-init.jsp:27` + +구현 결과: + +- `UserController.signup`은 입력 검증/DB 조회 전에 CSRF를 검사한다. `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:75`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:76` +- `UserController.login`은 인증/세션 갱신 전에 CSRF를 검사한다. `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:130`, `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:131` +- 로그인/회원가입 JSP는 hidden `_csrf` input과 fetch `X-CSRF-Token` header를 모두 제공한다. `src/main/webapp/WEB-INF/views/login.jsp:221`, `src/main/webapp/WEB-INF/views/login.jsp:283`, `src/main/webapp/WEB-INF/views/signup.jsp:219`, `src/main/webapp/WEB-INF/views/signup.jsp:297` +- 회귀 테스트는 토큰 없는 요청 거부, 정상 토큰 signup 성공, 정상 토큰 login 성공, 정상 토큰 invalid login 흐름을 검증한다. `src/test/java/com/pandoli365/bibimbap/controller/api/UserControllerCsrfTest.java:35`, `src/test/java/com/pandoli365/bibimbap/controller/api/UserControllerCsrfTest.java:54`, `src/test/java/com/pandoli365/bibimbap/controller/api/UserControllerCsrfTest.java:71`, `src/test/java/com/pandoli365/bibimbap/controller/api/UserControllerCsrfTest.java:101`, `src/test/java/com/pandoli365/bibimbap/controller/api/UserControllerCsrfTest.java:136` + +체크리스트: + +- [x] `UserController.signup` 시작부에서 `CsrfTokens.isValid(request)`를 먼저 검사한다. +- [x] `UserController.login` 시작부에서 `CsrfTokens.isValid(request)`를 먼저 검사한다. +- [x] JSON 요청은 `403`과 동일한 `AuthResult` 형태로 응답한다. +- [x] 일반 form fallback은 `/signup?error=csrf` 또는 `/login?error=csrf` 리다이렉트를 사용한다. +- [x] `login.jsp` fetch headers에 `window.BibimbapCsrf.headers(...)`를 적용한다. +- [x] `signup.jsp` fetch headers에 `window.BibimbapCsrf.headers(...)`를 적용한다. +- [x] JS 비활성 또는 일반 form submit을 고려해 hidden `_csrf` input을 추가한다. +- [x] `POST /login` 토큰 없음: 403 테스트를 추가한다. +- [x] `POST /signup` 토큰 없음: 403 테스트를 추가한다. +- [x] 정상 토큰이 있는 login/signup 성공 테스트를 추가한다. +- [x] 로그인 성공 시 `request.changeSessionId()` 동작을 유지한다. +- [x] 회원가입 중복 이메일 응답과 기존 error code 흐름이 깨지지 않도록 기존 분기 뒤에 CSRF만 선행 배치한다. + +완료 조건: + +- [x] 토큰 없는 login/signup POST가 실패한다. +- [x] 토큰 있는 login/signup POST가 기존과 동일하게 성공한다. +- [x] 기존 CSRF 적용 엔드포인트의 응답 형태가 바뀌지 않는다. +- [x] 보안 테스트가 로컬 `mvn test`에서 실행된다. + +## B2. 프로토타입 dead code 제거 + +근거: + +- `abstracts.Service`는 현재 컨트롤러 흐름과 별개로 수동 인증/요청 검증을 제공한다. `src/main/java/com/pandoli365/bibimbap/abstracts/Service.java:5`, `src/main/java/com/pandoli365/bibimbap/abstracts/Service.java:7`, `src/main/java/com/pandoli365/bibimbap/abstracts/Service.java:11` +- `header.jspf`는 Spring Security taglib와 `${_csrf}`를 전제로 한다. `src/main/webapp/WEB-INF/jsp/fragments/header.jspf:1`, `src/main/webapp/WEB-INF/jsp/fragments/header.jspf:2`, `src/main/webapp/WEB-INF/jsp/fragments/header.jspf:14` +- 실제 화면은 `/WEB-INF/views/header.jsp`를 include한다. `src/main/webapp/WEB-INF/views/login.jsp:203`, `src/main/webapp/WEB-INF/views/signup.jsp:201` +- `GameCatalog`는 빈 배열 기반 fallback이다. `src/main/java/com/pandoli365/bibimbap/game/GameCatalog.java:8`, `src/main/java/com/pandoli365/bibimbap/game/GameCatalog.java:18` +- `GameController`는 DB 게임이 없으면 `GameCatalog` fallback을 시도한다. `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:111`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:116` + +체크리스트: + +- [ ] `rg "abstracts|GameCatalog|header.jspf"`로 실제 참조를 재확인한다. +- [ ] `abstracts` 패키지를 삭제해도 컴파일이 깨지지 않는지 확인한다. +- [ ] `header.jspf` 삭제 전 JSP include 경로가 전부 `/WEB-INF/views/header.jsp`인지 확인한다. +- [ ] `GameCatalog` fallback 제거 시 없는 게임 ID의 기대 동작을 `redirect:/` 또는 404로 결정한다. +- [ ] 삭제 PR에는 기능 변경이 없도록 테스트와 수동 확인 범위를 좁힌다. +- [ ] 문서에서 제거된 프로토타입 흐름을 최신 구조로 갱신한다. + +완료 조건: + +- [ ] dead code 파일이 제거되거나 “보존 이유”가 문서화된다. +- [ ] `mvn test` 또는 최소 컴파일 검증이 통과한다. +- [ ] 없는 게임 상세 접근의 동작이 명확하다. + +## B3. 좋아요/댓글 서버 영속화 연결 + +근거: + +- 좋아요 매퍼는 있다. `src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java:10`, `src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java:24` +- 댓글 매퍼는 있다. `src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java:10`, `src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java:27` +- 게임 삭제 시 댓글/좋아요 데이터 정리 로직도 있다. `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:243`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:244` +- 현재 UI는 좋아요와 댓글을 `localStorage`에만 저장한다. `src/main/webapp/WEB-INF/views/game-detail.jsp:812`, `src/main/webapp/WEB-INF/views/game-detail.jsp:830`, `src/main/webapp/WEB-INF/views/game-detail.jsp:913`, `src/main/webapp/WEB-INF/views/game-detail.jsp:928` + +의도 확인: + +- [hold] 좋아요를 로그인 사용자만 허용할지, 익명 사용자 키 기반으로 허용할지 결정한다. +- [hold] 댓글을 로그인 사용자만 허용할지, 익명 닉네임 댓글을 허용할지 결정한다. +- [hold] 기존 localStorage 댓글/좋아요를 서버로 마이그레이션할지, 신규 서버 데이터로만 전환할지 결정한다. + +체크리스트: + +- [ ] `POST /game/{id}/like` 또는 `/api/games/{id}/like` 엔드포인트를 설계한다. +- [ ] 좋아요 추가/취소는 CSRF 검증을 적용한다. +- [ ] `game_likes` 중복 방지 키를 DB 또는 트랜잭션에서 보장한다. +- [ ] `games.like_count` 증감은 race condition 없이 처리한다. +- [ ] `GET /game/{id}/comments` 또는 상세 모델 주입 방식을 결정한다. +- [ ] `POST /game/{id}/comments`는 CSRF, 길이 제한, 작성자 정책을 적용한다. +- [ ] 댓글 삭제는 작성자 또는 관리자 권한을 확인한다. +- [ ] 서버에서 내려온 댓글도 JSP escape 또는 DOM `textContent`로 렌더링한다. +- [ ] 남용 방지를 위해 rate limit, 로그인 제한, 운영 신고/삭제 정책 중 최소 한 가지를 결정한다. +- [ ] localStorage UI는 서버 응답 기준으로 교체한다. + +완료 조건: + +- [ ] 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다. +- [ ] 토큰 없는 좋아요/댓글 변경 요청이 실패한다. +- [ ] XSS payload 댓글이 스크립트로 실행되지 않는다. +- [ ] 게임 삭제 시 관련 댓글/좋아요 정리가 유지된다. + +## B4. 의존성/세션/운영 하드닝 + +근거: + +- Spring Boot가 SNAPSHOT이다. `pom.xml:29` +- snapshot repository와 pluginRepository가 활성화되어 있다. `pom.xml:200`, `pom.xml:210` +- 세션 쿠키 하드닝 설정이 없다. `src/main/resources/application.properties:1`, `src/main/resources/application.properties:27` +- MyBatis TRACE 로그가 켜져 있다. `src/main/resources/application.properties:25`, `src/main/resources/application.properties:26`, `src/main/resources/application.properties:27` +- multipart 상한이 1GB다. `src/main/resources/application.properties:15`, `src/main/resources/application.properties:16`, `src/main/resources/application.properties:17`, `src/main/resources/application.properties:18` + +체크리스트: + +- [ ] Spring Boot를 안정 release 버전으로 고정한다. +- [ ] snapshot repository와 pluginRepository 필요성을 제거하거나 문서화한다. +- [ ] `server.servlet.session.cookie.http-only=true`를 설정한다. +- [ ] live 프로필에서 `server.servlet.session.cookie.secure=true`를 설정한다. +- [ ] `server.servlet.session.cookie.same-site=lax` 또는 stricter 정책을 결정한다. +- [ ] 운영 로그에서 MyBatis TRACE를 낮추고 민감 파라미터 노출 가능성을 확인한다. +- [ ] multipart 상한을 실제 운영 허용치와 맞춘다. +- [ ] 의존성 CVE 스캔 도구를 정한다. 후보: Dependabot, OWASP Dependency-Check, Maven Versions Plugin. +- [ ] CVE 스캔 결과와 예외 처리 기준을 `docs/security/`에 기록한다. + +완료 조건: + +- [ ] 빌드가 snapshot repository 없이 재현 가능하다. +- [ ] 운영 쿠키에 HttpOnly/Secure/SameSite 정책이 반영된다. +- [ ] CVE 스캔 결과가 문서화된다. +- [ ] 운영 로그 레벨이 민감 데이터 노출을 최소화한다. + +## 공통 테스트 매트릭스 + +- [ ] CSRF: 모든 상태 변경 엔드포인트가 토큰 없는 요청을 거부한다. +- [ ] SQLi: 검색어와 인증 입력에 특수 문자를 넣어도 쿼리 구조가 변하지 않는다. +- [ ] 업로드: `../`, 절대 경로, nested zip, 과대 zip을 거부한다. +- [ ] XSS: 게임 이름, 제작자 한마디, 댓글, 검색어가 HTML로 실행되지 않는다. +- [ ] 권한: 게임 수정/삭제, 댓글 삭제, 프로필 변경은 소유자 또는 로그인 조건을 확인한다. diff --git a/docs/user-signup-schema.md b/docs/user-signup-schema.md index 5040fbb..729e983 100644 --- a/docs/user-signup-schema.md +++ b/docs/user-signup-schema.md @@ -2,6 +2,13 @@ 이 문서는 `users`와 `user_auth_identities` 테이블 구조를 기준으로, 회원가입 시 필요한 정보와 저장 방식을 정리한다. +관련 문서: + +- [문서 인덱스](index.md) +- [2026-06-16 프로젝트 전면 분석](analysis/2026-06-16-project-analysis.md) +- [보안 개선 체크리스트](security/security-remediation-checklist.md) +- [DB 업데이트 쿼리 생성기](db-update-query-generator.md) + ## 기본 개념 `users`는 서비스 내부의 사용자 계정이다. 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 f4d95dc..5d226f3 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java @@ -73,6 +73,10 @@ public class UserController { HttpServletRequest request ) { boolean json = wantsJson(request); + if (!CsrfTokens.isValid(request)) { + return authError(json, request, "/signup", "csrf", HttpStatus.FORBIDDEN, "요청 보안 토큰이 유효하지 않습니다."); + } + String normalizedEmail = normalizeEmail(email); String normalizedDisplayName = normalizeDisplayName(displayName, null); @@ -124,6 +128,10 @@ public class UserController { HttpServletRequest request ) { boolean json = wantsJson(request); + if (!CsrfTokens.isValid(request)) { + return authError(json, request, "/login", "csrf", HttpStatus.FORBIDDEN, "요청 보안 토큰이 유효하지 않습니다."); + } + String normalizedEmail = normalizeEmail(email); if (normalizedEmail == null || password == null || password.isBlank()) { return authError(json, request, "/login", "invalid", HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호를 확인해 주세요."); diff --git a/src/main/webapp/WEB-INF/views/login.jsp b/src/main/webapp/WEB-INF/views/login.jsp index 6f15c13..fe7ae12 100644 --- a/src/main/webapp/WEB-INF/views/login.jsp +++ b/src/main/webapp/WEB-INF/views/login.jsp @@ -1,4 +1,6 @@ <%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %> +<%@ page import="com.pandoli365.bibimbap.security.CsrfTokens" %> +<%@ page import="org.springframework.web.util.HtmlUtils" %> @@ -203,6 +205,7 @@ <% String ctx = request.getContextPath(); + String csrfToken = HtmlUtils.htmlEscape(CsrfTokens.getOrCreate(request.getSession())); %>
@@ -215,6 +218,7 @@
+
@@ -276,7 +280,11 @@ fetch(form.action, { method: 'POST', - headers: { + headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({ + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }) : { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' diff --git a/src/main/webapp/WEB-INF/views/signup.jsp b/src/main/webapp/WEB-INF/views/signup.jsp index d579e31..ec096cc 100644 --- a/src/main/webapp/WEB-INF/views/signup.jsp +++ b/src/main/webapp/WEB-INF/views/signup.jsp @@ -1,4 +1,6 @@ <%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %> +<%@ page import="com.pandoli365.bibimbap.security.CsrfTokens" %> +<%@ page import="org.springframework.web.util.HtmlUtils" %> @@ -201,6 +203,7 @@ <% String ctx = request.getContextPath(); + String csrfToken = HtmlUtils.htmlEscape(CsrfTokens.getOrCreate(request.getSession())); %>
@@ -213,6 +216,7 @@
+
@@ -290,7 +294,10 @@ fetch(form.action, { method: 'POST', - headers: { + headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({ + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + }) : { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, diff --git a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java index d6e198d..f090977 100644 --- a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java +++ b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java @@ -1,11 +1,33 @@ package com.pandoli365.bibimbap; +import com.pandoli365.bibimbap.mapper.GamesMapper; +import com.pandoli365.bibimbap.mapper.RecruitPostsMapper; +import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper; +import com.pandoli365.bibimbap.mapper.UsersMapper; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; -@SpringBootTest +@SpringBootTest(properties = { + "spring.profiles.active=dev", + "spring.autoconfigure.exclude=" + + "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration," + + "org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration" +}) class BibimbapApplicationTests { + @MockBean + private GamesMapper gamesMapper; + + @MockBean + private RecruitPostsMapper recruitPostsMapper; + + @MockBean + private UserAuthIdentitiesMapper userAuthIdentitiesMapper; + + @MockBean + private UsersMapper usersMapper; + @Test void contextLoads() { } diff --git a/src/test/java/com/pandoli365/bibimbap/controller/api/UserControllerCsrfTest.java b/src/test/java/com/pandoli365/bibimbap/controller/api/UserControllerCsrfTest.java new file mode 100644 index 0000000..a13c63c --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/controller/api/UserControllerCsrfTest.java @@ -0,0 +1,186 @@ +package com.pandoli365.bibimbap.controller.api; + +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserControllerCsrfTest { + + @Mock + private UsersMapper usersMapper; + + @Mock + private UserAuthIdentitiesMapper userAuthIdentitiesMapper; + + @Test + void signupRejectsMissingCsrfBeforeMapperAccess() { + UserController controller = controller(); + MockHttpServletRequest request = jsonPost("/signup"); + + ResponseEntity response = controller.signup( + "테스터", + "tester@example.com", + "password123", + "password123", + "true", + request + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).isInstanceOf(UserController.AuthResult.class); + verifyNoInteractions(usersMapper, userAuthIdentitiesMapper); + } + + @Test + void loginRejectsMissingCsrfBeforeMapperAccess() { + UserController controller = controller(); + MockHttpServletRequest request = jsonPost("/login"); + + ResponseEntity response = controller.login( + "tester@example.com", + "password123", + null, + request + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).isInstanceOf(UserController.AuthResult.class); + verifyNoInteractions(usersMapper, userAuthIdentitiesMapper); + } + + @Test + void signupAcceptsValidCsrfParameter() { + UserController controller = controller(); + MockHttpServletRequest request = jsonPost("/signup"); + addCsrfParameter(request); + when(userAuthIdentitiesMapper.getUserAuthIdentityByProvider("email", "tester@example.com")) + .thenReturn(null); + when(usersMapper.addUser(any(UserData.class))).thenAnswer(invocation -> { + UserData user = invocation.getArgument(0); + user.setId(10L); + return 1; + }); + when(userAuthIdentitiesMapper.addUserAuthIdentity(any(UserAuthIdentityData.class))) + .thenReturn(1); + + ResponseEntity response = controller.signup( + "테스터", + "Tester@Example.com", + "password123", + "password123", + "true", + request + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(userAuthIdentitiesMapper).getUserAuthIdentityByProvider("email", "tester@example.com"); + verify(usersMapper).addUser(any(UserData.class)); + verify(userAuthIdentitiesMapper).addUserAuthIdentity(any(UserAuthIdentityData.class)); + } + + @Test + void loginAcceptsValidCsrfHeaderAndAuthenticates() throws Exception { + UserController controller = controller(); + MockHttpServletRequest request = jsonPost("/login"); + addCsrfHeader(request); + UserAuthIdentityData identity = new UserAuthIdentityData(); + identity.setId(20L); + identity.setUserId(10L); + identity.setProvider("email"); + identity.setProviderUserId("tester@example.com"); + identity.setEmail("tester@example.com"); + identity.setPasswordHash(passwordHash(controller, "password123")); + UserData user = new UserData(); + user.setId(10L); + user.setDisplayName("테스터"); + user.setCanonicalEmail("tester@example.com"); + user.setRole("USER"); + user.setStatus("ACTIVE"); + when(userAuthIdentitiesMapper.getUserAuthIdentityByProvider("email", "tester@example.com")) + .thenReturn(identity); + when(usersMapper.getUser(10L)).thenReturn(user); + + ResponseEntity response = controller.login( + "Tester@Example.com", + "password123", + null, + request + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(request.getSession().getAttribute("userId")).isEqualTo(10L); + verify(usersMapper).updateUser(user); + verify(userAuthIdentitiesMapper).updateUserAuthIdentity(identity); + } + + @Test + void loginAcceptsValidCsrfHeaderAndReachesCredentialLookup() { + UserController controller = controller(); + MockHttpServletRequest request = jsonPost("/login"); + addCsrfHeader(request); + when(userAuthIdentitiesMapper.getUserAuthIdentityByProvider("email", "tester@example.com")) + .thenReturn(null); + + ResponseEntity response = controller.login( + "Tester@Example.com", + "password123", + null, + request + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verify(userAuthIdentitiesMapper).getUserAuthIdentityByProvider("email", "tester@example.com"); + verifyNoInteractions(usersMapper); + } + + private UserController controller() { + return new UserController(usersMapper, userAuthIdentitiesMapper); + } + + private MockHttpServletRequest jsonPost(String path) { + MockHttpServletRequest request = new MockHttpServletRequest("POST", path); + request.addHeader(HttpHeaders.ACCEPT, "application/json"); + request.addHeader("X-Requested-With", "XMLHttpRequest"); + return request; + } + + private void addCsrfHeader(MockHttpServletRequest request) { + MockHttpSession session = new MockHttpSession(); + String token = CsrfTokens.getOrCreate(session); + request.setSession(session); + request.addHeader(CsrfTokens.HEADER_NAME, token); + } + + private void addCsrfParameter(MockHttpServletRequest request) { + MockHttpSession session = new MockHttpSession(); + String token = CsrfTokens.getOrCreate(session); + request.setSession(session); + request.addParameter("_csrf", token); + } + + private String passwordHash(UserController controller, String password) throws Exception { + Method method = UserController.class.getDeclaredMethod("hashPassword", String.class); + method.setAccessible(true); + return (String) method.invoke(controller, password); + } +}