fix: harden auth csrf handling

This commit is contained in:
김판돌 2026-06-16 15:38:44 +09:00
parent 40bde12c6d
commit f6f691ca73
18 changed files with 821 additions and 3 deletions

47
.atp/work-session Normal file
View File

@ -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.

18
.serena/project.yml Normal file
View File

@ -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.

33
CLAUDE.md Normal file
View File

@ -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`

31
docs/adr/README.md Normal file
View File

@ -0,0 +1,31 @@
# ADR
Architecture Decision Record를 보관한다.
## 작성 템플릿
```md
# ADR-NNN: 제목
## 상태
Proposed | Accepted | Superseded
## 배경
결정이 필요한 이유와 관련 근거를 적는다.
## 결정
선택한 방향을 적는다.
## 결과
장점, 단점, 후속 작업을 적는다.
```
## 후보
- ADR-001: WebGL asset 제공 origin 정책
- ADR-002: 서비스 레이어 도입 범위
- ADR-003: 좋아요/댓글 사용자 식별 정책

View File

@ -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-의존성세션운영-하드닝)를 따른다.

14
docs/analysis/README.md Normal file
View File

@ -0,0 +1,14 @@
# Analysis
프로젝트 전면 분석, 감사 기록, 리스크 인벤토리를 보관한다.
## 문서
- [2026-06-16 프로젝트 전면 분석](2026-06-16-project-analysis.md)
## 작성 규칙
- 분석 범위와 변경 범위를 먼저 적는다.
- 발견 사항은 아키텍처, 보안, 품질, 도메인 축으로 분류한다.
- 모든 발견은 `file:line` 근거를 포함한다.
- 후속 작업은 보안/기능/리팩터링 PR로 분리할 수 있게 체크리스트와 연결한다.

View File

@ -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 범위

View File

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

View File

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

41
docs/index.md Normal file
View File

@ -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로 분리한다.
- 스키마, 운영 쿼리, 배포 절차를 바꾸는 문서는 관련 보안/아키텍처 문서와 교차 링크한다.

17
docs/security/README.md Normal file
View File

@ -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`로 렌더링한다.
- 보안 변경은 최소 하나 이상의 회귀 테스트를 함께 추가한다.

View File

@ -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로 실행되지 않는다.
- [ ] 권한: 게임 수정/삭제, 댓글 삭제, 프로필 변경은 소유자 또는 로그인 조건을 확인한다.

View File

@ -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`는 서비스 내부의 사용자 계정이다.

View File

@ -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, "이메일 또는 비밀번호를 확인해 주세요.");

View File

@ -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" %>
<!DOCTYPE html>
<html lang="ko">
<head>
@ -203,6 +205,7 @@
<jsp:include page="/WEB-INF/views/header.jsp"/>
<%
String ctx = request.getContextPath();
String csrfToken = HtmlUtils.htmlEscape(CsrfTokens.getOrCreate(request.getSession()));
%>
<main class="auth-main">
<section class="auth-panel" aria-labelledby="login-title">
@ -215,6 +218,7 @@
</div>
<form class="auth-form" action="<%= ctx %>/login" method="post" id="login-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
<div class="auth-field">
<label class="auth-field__label" for="login-email">이메일</label>
<input class="auth-field__input" type="email" id="login-email" name="email" placeholder="name@example.com" autocomplete="username" required />
@ -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'

View File

@ -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" %>
<!DOCTYPE html>
<html lang="ko">
<head>
@ -201,6 +203,7 @@
<jsp:include page="/WEB-INF/views/header.jsp"/>
<%
String ctx = request.getContextPath();
String csrfToken = HtmlUtils.htmlEscape(CsrfTokens.getOrCreate(request.getSession()));
%>
<main class="auth-main">
<section class="auth-panel" aria-labelledby="signup-title">
@ -213,6 +216,7 @@
</div>
<form class="auth-form" action="<%= ctx %>/signup" method="post" id="signup-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
<input type="hidden" name="provider" value="email" />
<input type="hidden" name="providerUserId" id="signup-provider-user-id" />
<div class="auth-field">
@ -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'
},

View File

@ -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() {
}

View File

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