Uh oh!
There was an error while loading. Please reload this page.
FIX : 권한 상승 경로 차단과 커뮤니티 N+1 개선 - #80
Merged
Merged
Conversation
권한 상승 (두 경로)
- POST /auth/register 는 permitAll 인데 요청 본문의 role 을 그대로 엔티티에 넣었다.
로그인 없이 {"role":"ADMIN"} 으로 가입하면 그 자리에서 관리자가 됐다.
가입 시 role/provider/providerId/emailVerified 를 서버가 정하도록 바꾸고,
provider 를 붙여 비밀번호 검사를 건너뛰던 경로도 함께 막았다.
- PUT /users/{id}/role 은 클래스 제약이 isAuthenticated() 뿐이라 아무 회원이나
남의 역할을 바꿀 수 있었다. 사용자 관리 기능 10종을 /api/admin/users 로 옮겼다.
UserService 의 updateUserRole/activateUser/reactivateUser 에도 @PreAuthorize 를
직접 걸어 호출 경로가 바뀌어도 막히게 했다.
소유권 검증
- /users/{userId}/... 계열이 남의 식별자를 그대로 받았다(IDOR).
CurrentUserFacade.requireSelf 로 본인 확인 후 본인 엔티티로만 동작한다.
존재 여부가 새지 않도록 404 가 아니라 403 으로 응답한다.
- 타인 프로필 조회(GET /users/{userId})와, 경로 변수를 무시하고 현재 사용자를
수정하던 PUT /users/{userId} 는 삭제했다. /users/me/* 별칭을 추가한다.
인증
- /auth/refresh 가 validateToken 을 써서 Access Token 도 통과했다.
기본 설정(store=none)에서는 등록 확인도 항상 참이라 1시간짜리 토큰을
30일짜리로 바꿀 수 있었다. validateRefreshToken 으로 교정하고
운영 프로파일의 리프레시 저장소 기본값을 redis 로 둔다.
- 이메일 인증 화이트리스트가 존재하지 않는 /users/send-code 를 가리켜,
실제 경로인 /auth/* 는 인증을 요구했다. 즉 가입하려면 먼저 로그인해야 했다.
- JWT 필터가 토큰이 없을 때도 SecurityContext 를 지웠다. 자기가 세우지 않은
컨텍스트를 지우는 필터라 앞단 인증을 조용히 무력화한다.
이메일 인증번호
- Math.random() 을 SecureRandom 으로 교체.
- 시도 횟수 제한이 없어 6자리 코드를 5분 안에 전수 조회할 수 있었다. 코드당 5회로 제한.
- 재발송 쿨다운 60초 추가. 상수 시간 비교 적용.
- 인증 링크의 하드코딩 운영 IP 와 존재하지 않는 경로를 설정값 + /auth/verify 로 교정.
Rate limit
- RateLimitInterceptor 의 fail-open 이 increment 만 감쌌다. TTL 조회 실패가
그대로 500 이 됐고, DataAccessException 외의 실패도 마찬가지였다.
- 민감 엔드포인트 목록이 이 앱에 없는 /api/v1/* 를 보고 있어 한 번도 적용된 적이 없었다.
- 챗봇 /chat 은 요청 한 건이 유료 LLM 호출인데 엔드포인트 상한이 없었다.
가입·인증코드 발송/검증과 함께 @ratelimit 을 건다.
- AOP 키를 IP 대신 인증 principal 우선으로 바꿔 NAT 공유와 IP 우회를 함께 해결.
커뮤니티
- 신고 누적 자동 숨김이 목록에서 동작하지 않았다. getAllPosts 가 findAll 을 써서
필터가 없었고 상세도 ID 만 알면 열렸다. findAllActive/findActiveById 로 교체.
- N+1 제거: 작성자는 @EntityGraph(ToOne), 태그는 @batchsize(컬렉션이라 페이징
join fetch 는 메모리 페이징을 유발), 댓글 답글은 재귀 조회 대신 1회 조회 후
메모리에서 트리 조립. 목록 20건 기준 41 → 3 쿼리.
기타
- KakaoUtil 의 미사용 필드 제거(기본값에 운영 IP 가 박혀 있었다).
- 문서 생성기 Base URL 을 대상 서버에서 읽도록 변경.
- 이관으로 참조가 끊긴 응답 DTO 5개 삭제.
테스트
- 80건 추가. 라인 커버리지 16% → 34.9%, ratchet 0.15 → 0.30.
- 보안 핵심 클래스 4종을 60% 고기준 목록에 추가.
- 접근제어 계약 테스트에 권한 상승·토큰 종류·가입 흐름 회귀 케이스를 넣었다.
372 tests, 0 failures.
This was referenced Aug 22, 2026
리뷰 중 확인한 두 가지를 고친다. 1) 운영 프로파일의 EMAIL_VERIFICATION_BASE_URL 기본값이 빈 문자열이었다. 환경변수를 넣지 않으면 인증 링크가 "/auth/verify?token=..." 라는 상대 경로로 나가 메일에서 클릭할 수 없다. 사용자는 이유도 모른 채 인증에 실패한다. 기본값을 없애 기동 시점에 실패하게 한다. DB_PASSWORD, JWT_SECRET 과 같은 취급이다. 2) app.rate-limit.trust-forwarded-headers 가 XFF 신뢰를 혼자 결정한다고 적어 뒀는데 사실이 아니었다. server.forward-headers-strategy: framework 가 등록하는 ForwardedHeaderFilter 가 요청을 감싸 getRemoteAddr() 자체를 헤더의 클라이언트 주소로 바꿔친다. 따라서 trust=false 의 폴백도 이미 헤더에서 온 값을 돌려준다. "trust=false 니까 헤더 위조에 안전하다" 고 오판하기 쉬운 지점이라 실제 동작을 ForwardedClientIpTest 에 고정하고 주석을 정정했다. 376 tests, 0 failures.
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🏫 관련 이슈
상위 트래킹: #81
이 PR 로 해소된다.
role을 신뢰해 누구나 관리자가 된다/users의 사용자 관리 기능으로 권한 상승과 개인정보 열람이 가능하다/auth/refresh가 통과한다Closes#81
이 PR 로 닫히지 않는 후속 이슈
/users구 경로 제거)UserService테스트 커버리지가 0.7% 다🏫 PR 타입
🏫 반영 브랜치
fix/authorization-and-n-plus-one->main🏫 변경 사항
1. 권한 상승 경로 2개 차단 (Critical)
(1) 가입 시점 — 인증조차 필요 없음
POST /auth/register는permitAll인데UserService.createUser가 요청 본문의role을그대로 엔티티에 넣고 있었습니다. 즉 로그인 없이
{"role":"ADMIN"}으로 가입하면 그 자리에서 관리자가 됩니다.곁가지로
provider를 붙이면 비밀번호 검사를 건너뛰고emailVerified=true인 계정을임의 이메일로 미리 만들 수 있었습니다.
가입 시
role/provider/providerId/emailVerified를 서버가 정하도록 바꿨습니다.createUser의 호출처는 이 공개 엔드포인트 하나뿐이고, 카카오 가입은AuthServiceImpl.createNewUser라는 별도 경로라 영향이 없습니다.UserRole.valueOf(null)로 500 이 나던 것도 함께 해결됩니다.(2) 가입 이후 — 로그인만 하면 됨
UserController의 클래스 제약이@PreAuthorize("isAuthenticated()")하나였는데그 아래에 관리 기능이 섞여 있었습니다.
PUT /users/{id}/roleDELETE /users/{id},/{id}/deactivateGET /users/{id}/users/search,/active,/verified,/by-region/{region}등사용자 관리 기능 10종을
/api/admin/users로 이관했습니다.URL 규칙 하나에 의존하지 않도록
UserService의updateUserRole/activateUser/reactivateUser에도@PreAuthorize("hasRole('ADMIN')")를 직접 걸었습니다.(이 저장소는
/health/**가/hospitals/**를 삼켜 공개 API 가 통째로 막힌 전례가 있습니다.)경로 변수가 남은 구 경로는
CurrentUserFacade.requireSelf로 본인 확인 후 본인 엔티티로만 동작합니다.존재 여부가 새지 않도록 404 가 아니라 403 으로 응답합니다.
2. 인증 흐름
/auth/refresh토큰 종류 혼동 —validateToken은 서명·만료만 봐서 Access Token 도 통과합니다.기본 설정(
store=none)에서는 뒤따르는 등록 확인도 항상true라, 탈취한 1시간짜리 토큰을30일짜리 Refresh Token 으로 교환할 수 있었습니다. →
validateRefreshTokenredis로.none이면 로그아웃이 무작동이라 탈취 토큰을 회수할 수 없습니다./users/send-code를가리키고, 실제 경로인
/auth/*는anyRequest().authenticated()에 걸려 있었습니다.즉 "가입하려면 먼저 로그인해야" 했고, 메일의 인증 링크도 401 이었습니다.
필터라 앞단에서 인증을 넣는 경로를 조용히 무력화합니다.
3. 이메일 인증번호
Math.random()으로 인증번호 생성 (예측 가능)SecureRandomequals비교/users/verify경로/auth/verify4. Rate limit
RateLimitInterceptor의 fail-open 이increment만 감싸고 있어 TTL 조회 실패가 그대로 500.DataAccessException외의 실패도 마찬가지였습니다. → 카운터 관련 런타임 실패 전체를 fail-open/api/v1/contact,/api/v1/auth/signup을 보고 있어한 번도 적용된 적이 없었습니다. → 실제
/auth/*경로로 교체/chat은 요청 한 건이 유료 LLM 호출인데 엔드포인트 상한이 없었습니다(전역 300/분만 적용).→ 20/분. 가입 5/시간, 인증코드 발송 5/10분, 검증 10/10분 추가
5. 커뮤니티 — 자동 숨김 정상화 + N+1 제거
ModerationService가 신고 누적 시isActive=false로 감추는데,getAllPosts가findAll을 써서필터가 없었고 상세도 ID 만 알면 열렸습니다. 숨김 기능이 사실상 동작하지 않았습니다.
(인기·최신·검색 쿼리는 처음부터
isActive를 걸고 있어서 더 눈에 띄지 않았습니다.)N+1 은 세 군데였습니다.
post.getAuthor().getId()— 필드 접근 엔티티의 프록시는getId()만으로 초기화됨@EntityGraph(author)(ToOne 이라 페이징과 안전)post.getTags()게시글마다 1회@BatchSize(100). 컬렉션을 페이징 쿼리에 join fetch 하면 Hibernate 가 전체를 읽어 메모리 페이징(HHH000104) 하므로 그 방향은 피함comment.getReplies()재귀 호출 — 댓글 트리 노드 수만큼목록 20건 기준 41 → 3 쿼리.
6. 기타
KakaoUtil의serverAddress/serverPort삭제 — 선언만 있고 미사용이었고,기본값에 운영 IP 가 박혀 있었으며
server.address는 원래 바인딩 주소를 뜻하는 Boot 표준 속성이라 용도도 맞지 않았습니다.servers[0].url에서 읽도록 변경 (하드코딩 IP 제거)🏫 테스트 결과
0.15→0.30CurrentUserFacade,JwtAuthenticationFilter,RateLimitInterceptor,EmailVerificationService)을 60% 고기준 목록에 추가AccessControlContractTest를 확장해 권한 상승·토큰 종류·가입 흐름을 실제 HTTP 응답 코드로 고정UserServiceSignUpTestCurrentUserFacadeTestJwtAuthenticationFilterTestRateLimitInterceptorTestEmailVerificationServiceTestCommunityMapperCommentTreeTestCommunityServiceHiddenPostTest🏫 To Reviewer
봐주셨으면 하는 지점입니다.
/users구 경로 유지 여부 —PUT /users/{userId}/location등은 프런트가 실제로 호출 중이라경로를 유지하고 본인 확인만 추가했습니다.
/users/me/*별칭도 함께 넣었으니,프런트 전환이 끝나면 구 경로를 제거하면 됩니다.
UserDto를 가입 요청 본문으로 그대로 받는 구조 — 값이 무시된다는 사실은UserService.createUser가 보장하지만 Swagger 에는role등이 여전히 노출됩니다.전용
SignUpRequestDTO 분리는 프런트 계약에 영향이 있어 이번 PR 에는 넣지 않았습니다.RATE_LIMIT_TRUST_XFF를 prod 에서true로 바꿨습니다.Nginx 를 거치지 않고 앱 포트(8082)가 직접 노출되면 헤더 위조가 가능하므로,
보안그룹에서 프록시만 접근 가능한 상태인지 확인이 필요합니다.
jwt.refresh-token.storeprod 기본값을redis로 바꿨습니다. 운영 Redis 가용성 전제입니다.(로컬/CI 는
none유지라 Redis 없이 기동됩니다.)🏫 참고
프런트의
postSignup(POST /users) 은 백엔드에 매핑이 없지만, UI 에서 호출되지 않는 죽은 코드이고스키마가
role: ['ADMIN', ...]을 클라이언트에게 고르게 하고 있어 백엔드에 추가하지 않았습니다.추가했다면 위 (1) 번과 같은 구멍을 다시 여는 셈입니다. 프런트에서 정리하는 편이 맞다고 봅니다.