# 보안 개선 체크리스트

기준 분석: [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] 좋아요를 로그인 사용자만 허용할지, 익명 사용자 키 기반으로 허용할지 결정한다. ← **좋아요는 범위 밖, 미결 유지.**
- [x] 댓글을 로그인 사용자만 허용할지, 익명 닉네임 댓글을 허용할지 결정한다. → **로그인 사용자만(서버 영속화, session userId 귀속). 기존 닉네임 레코드는 user_id=NULL 보존(비파괴, QG-2).** W3-2 세션(20260618-104034) 확정.
- [x] 기존 localStorage 댓글을 서버로 마이그레이션할지, 신규 서버 데이터로만 전환할지 결정한다. → **비마이그레이션(신규 서버 데이터로만 전환).** 기존 localStorage 댓글 소멸. W3-2 세션 확정.
- [hold] 기존 localStorage 좋아요 처리 방침 — **좋아요는 범위 밖, 미결 유지.**

체크리스트:

> **좋아요 항목(101~104)은 범위 밖 — 미충족 유지. 댓글 항목만 W3-2에서 충족.**

- [ ] `POST /game/{id}/like` 또는 `/api/games/{id}/like` 엔드포인트를 설계한다. ← **좋아요: 미착수(범위 밖)**
- [ ] 좋아요 추가/취소는 CSRF 검증을 적용한다. ← **좋아요: 미착수(범위 밖)**
- [ ] `game_likes` 중복 방지 키를 DB 또는 트랜잭션에서 보장한다. ← **좋아요: 미착수(범위 밖)**
- [ ] `games.like_count` 증감은 race condition 없이 처리한다. ← **좋아요: 미착수(범위 밖)**
- [x] `GET /game/{id}/comments` 또는 상세 모델 주입 방식을 결정한다. → **초기 모델 주입 가능 + 별도 GET C1(`GET /game/{id}/comments`) fetch 방식 채택.** GameCommentController C1 구현 완료(20260618-104034).
- [x] `POST /game/{id}/comments`는 CSRF, 길이 제한, 작성자 정책을 적용한다. → **C2 `POST /game/{id}/comments`: CsrfTokens.isValid(403), content 200자(400), 로그인(401) 적용.** L1 12건 PASS(20260618-104034).
- [x] 댓글 삭제는 작성자 또는 관리자 권한을 확인한다. → **C4: 작성자 본인(sessionUserId) OR ROLE_ADMIN. 비작성자 403.** L1 PASS(20260618-104034).
- [x] 서버에서 내려온 댓글도 JSP escape 또는 DOM `textContent`로 렌더링한다. → **game-detail.jsp 댓글 렌더링 전면 textContent 교체.** L3 브라우저 스모크(20260622-170857) 확인: `<img src=x onerror=alert(1)>`/`<script>` 저장 후 재렌더 시 isOnlyTextNode=true, img/script 엘리먼트 미생성, alert 0회 발화.
- [ ] 남용 방지를 위해 rate limit, 로그인 제한, 운영 신고/삭제 정책 중 최소 한 가지를 결정한다. ← **로그인 제한 적용됨(댓글 쓰기 로그인 필수). rate limit/신고 정책은 이월.**
- [x] localStorage UI는 서버 응답 기준으로 교체한다. → **game-detail.jsp 댓글 localStorage → fetch API 서버 응답 기준으로 전환.** L1 PASS(20260618-104034).

완료 조건:

- [x] 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다. → **댓글: L3 스모크(20260622-170857) 확인 — 댓글/리뷰 작성 후 새로고침 유지 + 쿠키 없는(=다른 브라우저/시크릿 동치) GET `/game/3/comments`·`/reviews` 가 서버 데이터를 반환(localStorage 비의존 증명).** 좋아요: localStorage 유지(범위 밖, 미충족).
- [x] 토큰 없는 댓글 변경 요청이 실패한다. → **CsrfTokens.isValid 6개 게이트 PASS(AGG-3).** 좋아요 CSRF는 범위 밖.
- [x] XSS payload 댓글이 스크립트로 실행되지 않는다. → **L3 브라우저 스모크(20260622-170857) 확인: 댓글 `<img src=x onerror=alert(1)>`·`<script>alert(2)</script>` + 리뷰 본문 모두 DB raw 저장, 클라 textContent 렌더(game-detail.jsp:1840 댓글/:2136 리뷰) → 텍스트 노드로만 표시(img/script 미생성), alert/confirm/prompt 0회 발화.**
- [x] 게임 삭제 시 관련 댓글/좋아요 정리가 유지된다. → **GameController.deleteGame에 softDeleteGameReviews 추가.** 댓글 soft-delete도 기존 로직 확인. L1 PASS.

> 구현 이력 상세: [changes/2026-06-18-w3-2-comments-reviews.md](../changes/2026-06-18-w3-2-comments-reviews.md)

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