donghakim.dev — zsh
← ls ../blog

Spring Security 에서 권한 목록에 없는 API 는 전부 공개되고 있었습니다

보호 목록에 접두사 하나가 추가되자 그 아래 있던 공개 API 가 경고 없이 403 을 반환하게 됐습니다

backend · cover image

비회원도 사용할 수 있어야 하는 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 변경이었고, 재발 방지는 롤 목록을 직접 조회하는 매칭 테스트입니다.

#api-design#authorization#spring-security#testing