Skip to content

FIX : 권한 상승 경로 차단과 커뮤니티 N+1 개선 - #80

Merged
RosieOh merged 2 commits into
mainfrom
fix/authorization-and-n-plus-one
Aug 22, 2026
Merged

FIX : 권한 상승 경로 차단과 커뮤니티 N+1 개선#80
RosieOh merged 2 commits into
mainfrom
fix/authorization-and-n-plus-one

Conversation

@RosieOh

@RosieOhRosieOh commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

🏫 관련 이슈

상위 트래킹: #81

이 PR 로 해소된다.

이슈내용우선순위
Closes#82회원가입 요청의 role 을 신뢰해 누구나 관리자가 된다P0
Closes#83/users 의 사용자 관리 기능으로 권한 상승과 개인정보 열람이 가능하다P0
Closes#84Access Token 으로 /auth/refresh 가 통과한다P1
Closes#85이메일 인증 엔드포인트가 경로 불일치로 401 이다P1
Closes#86이메일 인증번호가 예측 가능하고 시도 횟수 제한이 없다P1
Closes#87Redis 장애 시 rate limit 이 fail-open 하지 못한다P1
Closes#88신고 누적 자동 숨김이 목록·상세에서 동작하지 않는다P1
Closes#89게시글 목록과 댓글 트리에서 N+1 이 발생한다P2

Closes#81

이 PR 로 닫히지 않는 후속 이슈


🏫 PR 타입

  • 기능 추가
  • 기능 수정
  • 기능 삭제
  • 버그 수정
  • 의존성, 환경 변수, 빌드 관련 코드 업데이트

🏫 반영 브랜치

fix/authorization-and-n-plus-one -> main

🏫 변경 사항

1. 권한 상승 경로 2개 차단 (Critical)

(1) 가입 시점 — 인증조차 필요 없음

POST /auth/registerpermitAll 인데 UserService.createUser 가 요청 본문의 role
그대로 엔티티에 넣고 있었습니다. 즉 로그인 없이 {"role":"ADMIN"} 으로 가입하면 그 자리에서 관리자가 됩니다.

curl -X POST http://localhost:8082/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"x@x.com","password":"pw123456","name":"공격자","role":"ADMIN"}'# → ADMIN 계정 발급, /api/admin/** 전체 열림

곁가지로 provider 를 붙이면 비밀번호 검사를 건너뛰고 emailVerified=true 인 계정을
임의 이메일로 미리 만들 수 있었습니다.

가입 시 role / provider / providerId / emailVerified 를 서버가 정하도록 바꿨습니다.
createUser 의 호출처는 이 공개 엔드포인트 하나뿐이고, 카카오 가입은
AuthServiceImpl.createNewUser 라는 별도 경로라 영향이 없습니다.
UserRole.valueOf(null) 로 500 이 나던 것도 함께 해결됩니다.

(2) 가입 이후 — 로그인만 하면 됨

UserController 의 클래스 제약이 @PreAuthorize("isAuthenticated()") 하나였는데
그 아래에 관리 기능이 섞여 있었습니다.

문제 경로영향
PUT /users/{id}/role아무 회원이나 자신/타인을 ADMIN 으로 승격
DELETE /users/{id}, /{id}/deactivate타인 계정 삭제·정지
GET /users/{id}타인 PII 조회 (IDOR)
/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 으로 교환할 수 있었습니다. → validateRefreshToken
  • 운영 리프레시 저장소 기본값을 redis 로. none 이면 로그아웃이 무작동이라 탈취 토큰을 회수할 수 없습니다.
  • 이메일 인증이 아예 불가능했음 — SecurityConfig 화이트리스트가 존재하지 않는 /users/send-code
    가리키고, 실제 경로인 /auth/*anyRequest().authenticated() 에 걸려 있었습니다.
    즉 "가입하려면 먼저 로그인해야" 했고, 메일의 인증 링크도 401 이었습니다.
  • JWT 필터가 토큰이 없을 때도 SecurityContext 를 삭제 — 자기가 세우지 않은 컨텍스트를 지우는
    필터라 앞단에서 인증을 넣는 경로를 조용히 무력화합니다.

3. 이메일 인증번호

문제조치
Math.random() 으로 인증번호 생성 (예측 가능)SecureRandom
시도 횟수 제한 없음 → 6자리(90만 개)를 5분 안에 전수 조회 가능코드당 5회 초과 시 코드 폐기
재발송 간격 없음60초 쿨다운 (발송 실패 시 해제)
문자열 equals 비교상수 시간 비교
인증 링크에 운영 IP 하드코딩 + 존재하지 않는 /users/verify 경로설정값 + /auth/verify

4. 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분 추가
  • AOP 키를 IP 대신 인증 principal 우선으로 변경 (NAT 공유 + IP 갈아타기 우회 동시 해결)

5. 커뮤니티 — 자동 숨김 정상화 + N+1 제거

ModerationService 가 신고 누적 시 isActive=false 로 감추는데, getAllPostsfindAll 을 써서
필터가 없었고 상세도 ID 만 알면 열렸습니다. 숨김 기능이 사실상 동작하지 않았습니다.
(인기·최신·검색 쿼리는 처음부터 isActive 를 걸고 있어서 더 눈에 띄지 않았습니다.)

N+1 은 세 군데였습니다.

원인조치
post.getAuthor().getId() — 필드 접근 엔티티의 프록시는 getId() 만으로 초기화됨@EntityGraph(author) (ToOne 이라 페이징과 안전)
post.getTags() 게시글마다 1회@BatchSize(100). 컬렉션을 페이징 쿼리에 join fetch 하면 Hibernate 가 전체를 읽어 메모리 페이징(HHH000104) 하므로 그 방향은 피함
comment.getReplies()재귀 호출 — 댓글 트리 노드 수만큼활성 댓글 전체 1회 조회 후 메모리에서 트리 조립. 부모가 숨김된 고아 답글은 최상위로 승격해 유실 방지

목록 20건 기준 41 → 3 쿼리.

6. 기타

  • KakaoUtilserverAddress / serverPort 삭제 — 선언만 있고 미사용이었고,
    기본값에 운영 IP 가 박혀 있었으며 server.address 는 원래 바인딩 주소를 뜻하는 Boot 표준 속성이라 용도도 맞지 않았습니다.
  • 문서 생성기 Base URL 을 대상 서버의 servers[0].url 에서 읽도록 변경 (하드코딩 IP 제거)
  • 이관으로 참조가 끊긴 응답 DTO 5개 삭제

🏫 테스트 결과

372 tests, 0 failures, 5 skipped
LINE coverage 34.94%
BUILD SUCCESSFUL (jacocoTestCoverageVerification 포함)
  • 80건 추가. 라인 커버리지 16% → 34.9%, ratchet 0.150.30
  • 보안 핵심 클래스 4종(CurrentUserFacade, JwtAuthenticationFilter, RateLimitInterceptor,
    EmailVerificationService)을 60% 고기준 목록에 추가
  • AccessControlContractTest 를 확장해 권한 상승·토큰 종류·가입 흐름을 실제 HTTP 응답 코드로 고정
신규 테스트고정하는 계약
UserServiceSignUpTest가입 시 role/provider/emailVerified 를 서버가 정한다
CurrentUserFacadeTest본인 확인. 관리자도 본인 전용 경로로는 남을 못 건드린다
JwtAuthenticationFilterTest토큰이 없을 때 기존 인증을 지우지 않는다
RateLimitInterceptorTestRedis 장애에도 fail-open
EmailVerificationServiceTest난수·시도 제한·쿨다운
CommunityMapperCommentTreeTest댓글 트리 조립 정확성
CommunityServiceHiddenPostTest숨김 글이 목록·상세에 안 나온다

로컬에서는 FlywaySchemaValidationTest, CareCodeMariaDbIntegrationTest 2개 클래스가 skip 됩니다.
Testcontainers 1.21.3 의 docker-java 가 Docker Engine 29.5.3 의 /info 에 400 을 받는
툴체인 비호환 때문이며, 저장소 설정 문제가 아닙니다. CI(ubuntu-latest)에서는 정상 실행되고
skip 되면 빌드를 실패시키는 게이트가 이미 있으므로 이 PR 의 CI 결과로 확인 부탁드립니다.

🏫 To Reviewer

봐주셨으면 하는 지점입니다.

  1. /users 구 경로 유지 여부PUT /users/{userId}/location 등은 프런트가 실제로 호출 중이라
    경로를 유지하고 본인 확인만 추가했습니다. /users/me/* 별칭도 함께 넣었으니,
    프런트 전환이 끝나면 구 경로를 제거하면 됩니다.
  2. UserDto 를 가입 요청 본문으로 그대로 받는 구조 — 값이 무시된다는 사실은
    UserService.createUser 가 보장하지만 Swagger 에는 role 등이 여전히 노출됩니다.
    전용 SignUpRequest DTO 분리는 프런트 계약에 영향이 있어 이번 PR 에는 넣지 않았습니다.
  3. RATE_LIMIT_TRUST_XFF 를 prod 에서 true 바꿨습니다.
    Nginx 를 거치지 않고 앱 포트(8082)가 직접 노출되면 헤더 위조가 가능하므로,
    보안그룹에서 프록시만 접근 가능한 상태인지 확인이 필요합니다.
  4. jwt.refresh-token.store prod 기본값을 redis 바꿨습니다. 운영 Redis 가용성 전제입니다.
    (로컬/CI 는 none 유지라 Redis 없이 기동됩니다.)

🏫 참고

프런트의 postSignup(POST /users) 은 백엔드에 매핑이 없지만, UI 에서 호출되지 않는 죽은 코드이고
스키마가 role: ['ADMIN', ...] 을 클라이언트에게 고르게 하고 있어 백엔드에 추가하지 않았습니다.
추가했다면 위 (1) 번과 같은 구멍을 다시 여는 셈입니다. 프런트에서 정리하는 편이 맞다고 봅니다.

권한 상승 (두 경로)
- 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.
리뷰 중 확인한 두 가지를 고친다.
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.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment