bibimbap/docs/security/security-remediation-checkl...

19 KiB

보안 개선 체크리스트

기준 분석: 2026-06-16 프로젝트 전면 분석

상태 표시

  • [ ] 미착수
  • [~] 진행 중
  • [x] 완료
  • [hold] 의도 확인 또는 별도 결정 필요

우선순위

ID 우선순위 항목 보안 영향
B1 P1 login/signup CSRF 검증 추가 MED, 완료
B2 P2 프로토타입 dead code 제거 LOW-MED
B3 P2 좋아요/댓글 서버 영속화 연결 MED, 기능 무결성. 댓글 완료(W3-2) + 좋아요 완료(20260629-151216, 실환경 스모크 PASS + UNIQUE 제약 dev DB 적용 20260629-171042)
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

체크리스트:

  • UserController.signup 시작부에서 CsrfTokens.isValid(request)를 먼저 검사한다.
  • UserController.login 시작부에서 CsrfTokens.isValid(request)를 먼저 검사한다.
  • JSON 요청은 403과 동일한 AuthResult 형태로 응답한다.
  • 일반 form fallback은 /signup?error=csrf 또는 /login?error=csrf 리다이렉트를 사용한다.
  • login.jsp fetch headers에 window.BibimbapCsrf.headers(...)를 적용한다.
  • signup.jsp fetch headers에 window.BibimbapCsrf.headers(...)를 적용한다.
  • JS 비활성 또는 일반 form submit을 고려해 hidden _csrf input을 추가한다.
  • POST /login 토큰 없음: 403 테스트를 추가한다.
  • POST /signup 토큰 없음: 403 테스트를 추가한다.
  • 정상 토큰이 있는 login/signup 성공 테스트를 추가한다.
  • 로그인 성공 시 request.changeSessionId() 동작을 유지한다.
  • 회원가입 중복 이메일 응답과 기존 error code 흐름이 깨지지 않도록 기존 분기 뒤에 CSRF만 선행 배치한다.

완료 조건:

  • 토큰 없는 login/signup POST가 실패한다.
  • 토큰 있는 login/signup POST가 기존과 동일하게 성공한다.
  • 기존 CSRF 적용 엔드포인트의 응답 형태가 바뀌지 않는다.
  • 보안 테스트가 로컬 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

구현 결과 (2026-06-29, sid 20260629-175705):

  • abstracts/ 패키지 4파일(Service, Request, Result, ErrorResult) 전체 삭제. 참조 0 확인 후 제거.
  • src/main/webapp/WEB-INF/jsp/fragments/header.jspf 삭제. include 참조 0 확인 후 제거.
  • src/main/java/com/pandoli365/bibimbap/game/GameCatalog.java 삭제.
  • GameController.gameDetail() 의 GameCatalog fallback(redirect:/+정적뷰) 제거 → DB 미존재 게임 ID = HTTP 404 (ResponseStatusException(NOT_FOUND)). 사용자 확정 결정(D-B2-404).
  • ApiExceptionControllerAdviceResponseStatusException 핸들러 추가 — 기존 Exception catch-all 이 500으로 덮던 문제 수정, 404 status 보존.
  • 회귀 가드 GameLikeControllerTest.gameDetailThrowsNotFoundWhenGameMissing 추가.
  • mvn test 361 PASS. 런타임 스모크: GET /game/99999 = 404, GET /game/3 = 200 확인.

체크리스트:

  • rg “abstracts|GameCatalog|header.jspf”로 실제 참조를 재확인한다.
  • abstracts 패키지를 삭제해도 컴파일이 깨지지 않는지 확인한다.
  • header.jspf 삭제 전 JSP include 경로가 전부 /WEB-INF/views/header.jsp인지 확인한다.
  • GameCatalog fallback 제거 시 없는 게임 ID의 기대 동작을 redirect:/ 또는 404로 결정한다. → HTTP 404 확정.
  • 삭제 PR에는 기능 변경이 없도록 테스트와 수동 확인 범위를 좁힌다.
  • 문서에서 제거된 프로토타입 흐름을 최신 구조로 갱신한다. → analysis 문서 stale 항목 갱신 완료.

완료 조건:

  • dead code 파일이 제거되거나 “보존 이유”가 문서화된다. → abstracts 4파일·header.jspf·GameCatalog.java 삭제.
  • mvn test 또는 최소 컴파일 검증이 통과한다. → 361 PASS.
  • 없는 게임 상세 접근의 동작이 명확하다. → HTTP 404 (ResponseStatusException(NOT_FOUND)).

구현 이력: changes/2026-06-29-b2-b4-fe-hardening.md

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

의도 확인 (댓글 결정 완료 / 좋아요 결정 완료):

  • 좋아요를 로그인 사용자만 허용할지, 익명 사용자 키 기반으로 허용할지 결정한다. → 로그인 사용자만(sessionUserId). game_likes.user_key = String.valueOf(userId). 미로그인 401 로그인 유도. 사용자당 게임당 1회(멱등 토글). 세션 20260629-151216 D2 확정.
  • 댓글을 로그인 사용자만 허용할지, 익명 닉네임 댓글을 허용할지 결정한다. → 로그인 사용자만(서버 영속화, session userId 귀속). 기존 닉네임 레코드는 user_id=NULL 보존(비파괴, QG-2). W3-2 세션(20260618-104034) 확정.
  • 기존 localStorage 댓글을 서버로 마이그레이션할지, 신규 서버 데이터로만 전환할지 결정한다. → 비마이그레이션(신규 서버 데이터로만 전환). 기존 localStorage 댓글 소멸. W3-2 세션 확정.
  • 기존 localStorage 좋아요 처리 방침 → 비마이그레이션. JSP 가 더 이상 localStorage('bibimbap-game-liked') 를 읽지 않으므로 잔존 키는 무해(정리 불필요). 세션 20260629-151216.

체크리스트:

좋아요 항목은 세션 20260629-151216 에서 충족 + 실환경 스모크 PASS(20260629-171042, docker compose dev — 로그인 좋아요 → explore 카운트 반영 사용자 확인). 단 game_likes UNIQUE 제약은 마이그레이션 운영 적용 대기(아래).

  • POST /game/{id}/like 또는 /api/games/{id}/like 엔드포인트를 설계한다. → POST /game/{id}/like 토글(@Transactional). 게이트 CSRF(403)→로그인(401)→존재(404).
  • 좋아요 추가/취소는 CSRF 검증을 적용한다. → CsrfTokens.isValid(request) 선행(403). 회귀 테스트 AC-4 PASS.
  • game_likes 중복 방지 키를 DB 또는 트랜잭션에서 보장한다. → 애플리케이션 레벨 select-then-act + 단일 트랜잭션 1차 방어 + DB UNIQUE(game_id,user_key) 제약 2차 방어. db/schema.sql 반영 + db/migrations/20260629-game-likes-unique.sql. dev DB 적용 완료(2026-06-29, 중복 0 점검 후 ALTER, pg_constraint 검증). 별도 prod DB 배포 시 동일 마이그레이션 적용.
  • games.like_count 증감은 race condition 없이 처리한다. → incrementLikeCount(SET like_count = like_count + 1)/decrementLikeCount(GREATEST(like_count - 1, 0)) DB 원자 UPDATE + row 변경과 동일 @Transactional 단위. 클라이언트 ±1 추정 제거(토글 직후 컬럼 재조회 응답).
  • GET /game/{id}/comments 또는 상세 모델 주입 방식을 결정한다. → 초기 모델 주입 가능 + 별도 GET C1(GET /game/{id}/comments) fetch 방식 채택. GameCommentController C1 구현 완료(20260618-104034).
  • POST /game/{id}/comments는 CSRF, 길이 제한, 작성자 정책을 적용한다. → C2 POST /game/{id}/comments: CsrfTokens.isValid(403), content 200자(400), 로그인(401) 적용. L1 12건 PASS(20260618-104034).
  • 댓글 삭제는 작성자 또는 관리자 권한을 확인한다. → C4: 작성자 본인(sessionUserId) OR ROLE_ADMIN. 비작성자 403. L1 PASS(20260618-104034).
  • 서버에서 내려온 댓글도 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/신고 정책은 이월.
  • localStorage UI는 서버 응답 기준으로 교체한다. → game-detail.jsp 댓글 localStorage → fetch API 서버 응답 기준으로 전환. L1 PASS(20260618-104034).

완료 조건:

  • 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다. → 댓글: L3 스모크(20260622-170857) 확인. 좋아요: 서버 영속화 완료(20260629-151216) — game_likes row + games.like_count 컬럼 저장. 상세 초기 liked 는 서버 모델 주입(localStorage 비의존). 실환경 스모크(AC-1/AC-7)는 needs_user_verification(maintenance/post-deploy-verification-checklist.md).
  • 토큰 없는 댓글 변경 요청이 실패한다. → CsrfTokens.isValid 6개 게이트 PASS(AGG-3). 좋아요: POST /game/{id}/like CSRF 게이트(403) 회귀 테스트 AC-4 PASS(20260629-151216).
  • 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회 발화.
  • 게임 삭제 시 관련 댓글/좋아요 정리가 유지된다. → GameController.deleteGame에 softDeleteGameReviews 추가. 댓글 soft-delete도 기존 로직 확인. L1 PASS.

구현 이력 상세: 댓글 — changes/2026-06-18-w3-2-comments-reviews.md / 좋아요 — changes/2026-06-29-game-like-server-persistence.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

구현 결과 (2026-06-29, sid 20260629-175705):

  • Spring Boot: 3.5.14-SNAPSHOT3.5.16 (GA) 고정. pom.xml spring-boot.version 갱신.
  • Snapshot repo 제거: pom.xml 의 spring-snapshots repository·pluginRepository 블록 삭제. snapshot 없이 mvn -B dependency:resolve PASS 확인.
  • 쿠키 하드닝: application.propertiesserver.servlet.session.cookie.http-only=true, server.servlet.session.cookie.same-site=lax 추가. application-live.properties(신규) 에 server.servlet.session.cookie.secure=true. 런타임 Set-Cookie 확인: JSESSIONID; HttpOnly; SameSite=Lax.
  • MyBatis 로그: application.properties ibatis TRACE → WARN. application-dev.properties(신규) 에 TRACE 복원(로컬 개발 전용).
  • Profile properties: Spring 표준 application-dev.properties·application-live.properties 신규 생성. 기존 db.properties(DB 전용) 공존. 사용자 합의(D-B4-cfgloc).
  • OWASP Dependency-Check: pom.xmldependency-check-maven 12.2.2 plugin 추가. executions 없음(빌드 phase 비bind). 수동 실행: mvn dependency-check:check. autoUpdate=false, failBuildOnCVSS=7, skipProvidedScope=true. 상세: security/owasp-dependency-check-guide.md.
  • multipart 1GB 유지: 사용자 확정(D-B4-multipart). 리스크 문서화: security/multipart-size-risk.md.

체크리스트:

  • Spring Boot를 안정 release 버전으로 고정한다. → 3.5.16 GA.
  • snapshot repository와 pluginRepository 필요성을 제거하거나 문서화한다. → spring-snapshots 블록 삭제.
  • server.servlet.session.cookie.http-only=true를 설정한다.
  • live 프로필에서 server.servlet.session.cookie.secure=true를 설정한다. → application-live.properties.
  • server.servlet.session.cookie.same-site=lax 또는 stricter 정책을 결정한다. → lax 설정.
  • 운영 로그에서 MyBatis TRACE를 낮추고 민감 파라미터 노출 가능성을 확인한다. → base WARN, dev 전용 TRACE.
  • multipart 상한을 실제 운영 허용치와 맞춘다. → 1GB 유지(사용자 확정), 리스크 문서화 완료.
  • 의존성 CVE 스캔 도구를 정한다. → OWASP Dependency-Check maven plugin.
  • CVE 스캔 결과와 예외 처리 기준을 docs/security/에 기록한다. → security/owasp-dependency-check-guide.md.

완료 조건:

  • 빌드가 snapshot repository 없이 재현 가능하다. → mvn -B dependency:resolve PASS.
  • 운영 쿠키에 HttpOnly/Secure/SameSite 정책이 반영된다. → 런타임 Set-Cookie 확인 완료.
  • CVE 스캔 결과가 문서화된다. → OWASP DC plugin 설정 + guide 문서화.
  • 운영 로그 레벨이 민감 데이터 노출을 최소화한다. → base WARN.

구현 이력: changes/2026-06-29-b2-b4-fe-hardening.md

공통 테스트 매트릭스

  • CSRF: 모든 상태 변경 엔드포인트가 토큰 없는 요청을 거부한다.
  • SQLi: 검색어와 인증 입력에 특수 문자를 넣어도 쿼리 구조가 변하지 않는다.
  • 업로드: ../, 절대 경로, nested zip, 과대 zip을 거부한다.
  • XSS: 게임 이름, 제작자 한마디, 댓글, 검색어가 HTML로 실행되지 않는다.
  • 권한: 게임 수정/삭제, 댓글 삭제, 프로필 변경은 소유자 또는 로그인 조건을 확인한다.

B5. recruit-form.jsp CSRF 폴백 갭 (P0-보안)

근거:

  • BibimbapCsrf 미존재(비로그인 등) 시 fetch 폴백 브랜치에서 CSRF 토큰이 헤더에 포함되지 않는다. src/main/webapp/WEB-INF/views/recruit-form.jsp:386-394
  • hidden _csrf input 부재 — form submit fallback도 보호 없음.

체크리스트:

  • recruit-form.jsp<input type="hidden" name="_csrf" value="..."> 추가한다.
  • fetch 폴백 브랜치 headers에 'X-CSRF-Token': csrfToken 추가한다.
  • 수정 후 CSRF 없는 POST가 거부되는 회귀 테스트를 추가한다.

완료 조건:

  • BibimbapCsrf 미존재 시에도 recruit-form POST에 CSRF 토큰이 전송된다.
  • 토큰 없는 요청이 403으로 거부된다.

발견: 세션 20260630-113258 JSP 프론트엔드 컴포넌트 검토.