Spring Security 에서 권한 목록에 없는 API 는 전부 공개되고 있었습니다
보호 목록에 접두사 하나가 추가되자 그 아래 있던 공개 API 가 경고 없이 403 을 반환하게 됐습니다
비회원도 사용할 수 있어야 하는 API 가 배포 후 403 을 반환하고 있었습니다. 그 API 를 개발할 때 인가 설정을 수정한 적이 없었습니다. 변경된 것은 몇 주 전에 다른 사람이 추가한 보호 경로 접두사 하나였습니다.
원인은 SecurityConfiguration 의 구조였습니다.
공개 여부가 명시되지 않고 목록에 없다는 것으로 결정됩니다
설정이 이런 모양이었습니다.
http.authorizeRequests()
.antMatchers(GUEST_URI).hasAnyRole(...)
.antMatchers(USER_URI).hasAnyRole(...)
.antMatchers(CORP_URI).hasAnyRole(...)
.antMatchers("/**").permitAll(); // 나머지 전부
롤별 목록에 없으면 마지막 줄에 매칭되어 공개가 됩니다. 즉 "이 API 는 공개" 라고 적힌 곳이 어디에도 없습니다. 공개 여부는 선언으로 결정되지 않고, 목록에 없다는 사실로만 결정됩니다.
이 구조에서 두 가지 문제가 발생합니다.
- 보호 목록에 접두사를 하나 추가하면 그 아래 있던 공개 API 가 경고 없이 차단됩니다. 이번 건이 정확히 그 형태였습니다. 마이페이지 접두사를 보호 목록에 추가했는데, 그 아래 있던 비회원용 API 도 함께 차단됐습니다.
- 반대로 보호 접두사 안에 예외를 추가하려면 보호 규칙보다 먼저 매칭되는 permit 규칙이 필요합니다. 규칙 순서에 의존한다는 뜻이고, 나중에 목록을 수정하면 다시 차단되거나 반대로 과다 노출됩니다.
둘 다 배포 전에는 확인되지 않습니다. 컴파일도 통과하고 애플리케이션도 정상적으로 기동됩니다.
예외 규칙을 추가하는 대신 URL 을 변경했습니다
문제의 API 는 "이 조건이면 얼마가 적립되는지" 를 계산해 주는 것이었습니다. 세션을 조회하지 않습니다. 입력은 조회 구간뿐이고 응답은 공개 요율표와 진행 중인 이벤트입니다. 처음부터 로그인이 필요 없는 API 였는데, 코드가 마이페이지 패키지에 있다는 이유로 URL 도 마이페이지 아래에 있었습니다.
그래서 permit 예외를 추가하는 대신 URL 을 예매 네임스페이스로 변경했습니다. 보안 설정은 한 줄도 변경하지 않았습니다.
before GET /mypage/point/earn-preview ← 보호 접두사 아래
after POST /booking/point/earn-preview ← 공개 네임스페이스
코드는 그대로 포인트 패키지에 유지했습니다. 산정 규칙은 적립 도메인의 것이고, 호출하는 쪽이 예매 흐름일 뿐입니다. 패키지와 URL 이 꼭 같은 기준을 따를 이유는 없었습니다.
인가가 경로로 결정되는 구조에서는 URL 을 인가 등급에 일치시켜 두는 쪽이 설정으로 예외를 추가하는 쪽보다 안전합니다. 예외는 규칙 순서에 의존하지만 경로는 그렇지 않습니다.
같은 서비스의 API 세 개를 인가 기준으로 다시 분류해 봤습니다.
| 경로 | 인가 | 왜 |
|---|---|---|
| 마이페이지 적립 예정 조회 | 로그인 필수 | 세션 회원의 예약을 조회합니다 |
| 적립 예상액 미리보기 | 비로그인 가능 | 세션을 조회하지 않습니다 |
| 여정조회 응답에 포함된 예상액 | 비로그인 가능 | 같은 이유 |
기준이 "무엇을 읽는가" 하나로 정리되니 어느 쪽에 둘지도 자동으로 결정됐습니다.
회귀 가드
경로를 변경해 수정했어도 다음에 누군가 목록을 수정하면 같은 문제가 발생할 수 있습니다. 그래서 다음 두 방향을 모두 테스트로 검사하도록 했습니다.
- 공개되어야 할 경로가 차단되는 것
- 차단돼야 할 경로가 공개되는 것
롤 목록 상수를 리플렉션으로 조회해서 매칭 결과를 검사합니다. 목록을 문자열로 복사해 두면 테스트와 설정의 값에 차이가 날 수 있으므로, 설정이 실제로 사용하는 배열을 그대로 조회하도록 했습니다.
String[] userUri = (String[]) readStaticField(SecurityConfiguration.class, "USER_URI");
assertThat(matchesAny(userUri, "/booking/point/earn-preview")).isFalse();
이 테스트가 검사하는 것은 "어떤 경로가 어느 등급인가" 하나뿐입니다. 인증 로직 자체는 검사하지 않습니다. 그래도 이번 사고는 전부 경로별 인가 등급 설정에서 발생했습니다.
정리하면, 이 구조에서 공개 API 는 목록에 없어서 공개인 것이고, 그래서 다른 사람이 보호 목록에 접두사 한 줄을 추가하면 차단됩니다. 수정한 방법은 예외 규칙 추가가 아니라 URL 변경이었고, 재발 방지는 롤 목록을 직접 조회하는 매칭 테스트입니다.