배경
웹 쇼핑몰 프로젝트를 하면서 스프링 컨트롤러에서 Get Mapping 요청시 쿼리 파라미터가 노출되는 문제를 마주했다. 당장 서비스를 런칭하는 것도 아니기 때문에 큰 문제가 있는 것은 아니었지만 내부 구조가 그대로 드러나는 것 같아 상당히 찜찜했다. 조금만 생각해보아도 보안상이 의슈로 연결되기 쉬운 부분이었다.
예컨대 커뮤니티 서비스에서 게시글을 검색하는 과정에서 다음과 같이 쿼리 파라미터가 노출된다.
articles?page=0&sort=&searchType=HASHTAG&searchValue=hashtag1
아래는 해당 URI를 매핑하는 search 메서드 구현부이다.
이와 같은 문제를 해결하는 방법으로 아래의 대안책들을 고려했었다.
- POST 방식 사용
POST 방식은 URL을 사용하지 않으며, HTTP 요청 본문에 데이터를 담아 전송하기 때문에 보안상 더욱 안전하다. 하지만 일반적인 조회에서 POST 요청을 보내는 것이 적절한 것인가에 대한 의문이 남는다.
- SSL 적용
SSL(Secure Socket Layer)은 데이터를 암호화하여 전송하는 보안 프로토콜이다. SSL을 적용하면, 전송 중인 데이터가 제3자에게 노출될 가능성을 줄일 수 있다. 하지만 별도 비용이 존재해 현재 프로젝트에서 당장 도입하기 어려운 것으로 판단했다.
또한 SSL은 HTTP 보안을 적용하는 것으로 서버와 클라이언트 사이의 데이터 전송 과정을 암호화하는 것이지 URL 자체를 감추지는 않는다.
- URL 인코딩 적용
URL 인코딩은 URL에서 사용하는 특수 문자를 변환하여 보안성을 높이는 방법이다. Spring에서는 URLEncoder 클래스를 사용하여 URL 인코딩을 할 수 있어 추후 이 방법으로의 리팩토링을 고려해본다.
- Redirect 사용
Redirect를 사용하면, 클라이언트는 실제 페이지 URL을 알 수 없다. 따라서, URL 노출 문제를 해결하는 가장 빠른 해결법이 될 것이다. 그러나 결국엔 서블릿 요청이 필요하게 되고 다른 URI을 이용한 Get 호출을 연쇄적으로 일으키므로 근본적인 해결점은 아니다.
무엇보다도 가장 문제가 될 수 있는 부분은 id 값이 그대로 노출되는 경우일 것이다.
예를들어 다음과 같은 API 호출이다.
[GET] /management/items/{itemId}
[GET] community/articles/{article-id}
게시글에 대한 단순 조회 같은 경우라면 Article의 PK 값인 article-id가 그대로 노출된다고 해서 크게 보안상 이슈가 될 가능성은 적을 수도 있다. 하지만 다음과 같이 item, user의 id가 노출되는 경우 서버 엔티티에 대한 보안 이슈가 심각해질 수 있다.
[POST] /item/modify/{itemId}
[POST] /order/{orderId}/cancel
[GET] /user/my-page/{user-id}
본 글에서는 식별자 노출을 해결할 수 있는 한가지 방법을 소개해보려고 한다.
문제
왜 문제가 될까?
DBMS의 Entity 개념에서 고유한 식별자는 중요한 개념이다. Entity의 생명주기에서 형태와 내용은 바뀔 수 있지만 연속성을 유지하기 위해서는 식별자는 유일하고 고유해야 한다.
일반적으로 DBMS는 자동 증가 속성(AUTO_INCREMENT)을 통해 PK 값을 증가시킬 수 있는 기능을 제공하며, JPA를 사용하는 프로젝트에서 @GeneratedValue 애노테이션을 PK 자동 생성 기능을 활용한다.
그러나 이렇게 생성된 pk로서의 id는 식별자라는 기능에는 충실하지만 부수적인 문제로서 보안, 비밀유지 측면에서 문제 소지 가능성을 갖는다.
예를 들어, userId라는 식별자를 생각해보자. id는 순번으로서 생성되기 때문에 임의의 숫자를 입력하면 타인의 마이페이지에 대한 접속 루트가 열리는 문제가 생길 수 있다. 또한 경쟁사의 악의적인 시도로 특정 연속하는 순서를 요청하면 자사 회원 수를 유추할 수도 있다.
다음 두 가지 사례를 살펴보자.
사례 1: 2020년 5월 특정 서비스의 실제 사례
- Bigint(Long) 형태의 유저 아이디를 URL PATH로 사용하여 유저의 거래 내역을 노출하는 GET API에 대해서, URL 숫자의 조작만으로 다른 이의 거래 내역이 손쉽게 드러나는 경우
사례 2: 외부 연동 서비스
- 외부 협력사와 자사 서비스 간에 상품 데이터 연동 과정에서 키 값을 시스템 내부의 PK로 사용했을 경우
- 양사간의 데이터가 자사 시스템 내부의 PK로 강하게 묶이는 문제
("패스트캠퍼스 - 비즈니스 성공을 위한 Java/Spring 기반 서비스 개발과 MSA 구축 by 이희창" 강의록 참고)
이처럼 DBMS에 고유하게 존재하는 PK 값이 인터페이스 레벨까지 올라와 버리면 내부 구조에 대한 노출로 인한 문제점을 야기하게 되므로 이에 대한 수정이 필요하다.
해결 케이스
대체키를 이용해 내부 사용 PK를 외부에 노출하지 않도록 할 수 있다.
대체키란 원래 자연키라는 용어와 대칭되는 개념이지만 Entity의 식별자와 동급의 의미를 갖는 추가 식별자의 개념으로 사용하였다.
VOC를 처리하는 MVP 프로젝트에서 운전기사 id를 통해 접속되는 마이페이지의 쿼리 파라미터를 수정하는 리팩토링을 진행한 케이스이다.
세부 사항은 다음과 같다.
- 기존 GET 요청 URI -> http://localhost:54380/delivery-driver/v1/my-page?id=1
- 변경 GET 요청 URI -> http://localhost:54380/delivery-driver/v2/my-page?token=ojH1C3ilyZb8Pmd
변경된 부분을 보면 token이라는 파라미터가 id 대신 사용되는 것을 볼 수 있다. 토큰은 랜덤 알파벳으로 조합된다. UUID를 사용할 수도 있었지만 알파벳 생성을 위해 apache commons의 lang 라이브러리에서 제공하는 랜덤 문자열 생성 방식을 사용하였다.
구현 코드는 다음과 같다.
public class DeliveryDriver extends BaseEntity {
private static final String DELIVERY_DRIVER_PREFIX = "deliveryDriver_";
//...
private String deliveryDriverToken;
// ...
private void generateToken() {
this.deliveryDriverToken = TokenGenerator.randomCharacterWithPrefix(DELIVERY_DRIVER_PREFIX);
}
}
public class TokenGenerator {
private static final int TOKEN_LENGTH = 30;
public static String randomCharacter(int length) {
return RandomStringUtils.randomAlphanumeric(length);
}
public static String randomCharacterWithPrefix(String prefix) {
return prefix + randomCharacter(TOKEN_LENGTH - prefix.length());
}
}
위의 코드에는 deliveryDriverToken이 추가된 것을 나타낸다. 해당 토큰은 prefix로 주어지는 문자열을 바탕으로 Generator를 통해 생성된다.
해당 토큰은 id 처럼 중복되지 않는 고유한 값을 갖도록 한다. DB에 실제 저장된 값을 보면 다음과 같다.
토큰 값의 Prefix를 정책에 맞게 암호화된 value로 지정하면 외부에 노출되어도 공개키와 같이 식별되지 않게 될 것이다.
서버에서 해당 토큰을 통해 조회하는 로직 역시 필요에 따라 더 구현할 수도 있다. 다음은 토큰 파라미터 값으로 "_" 뒷부분만 제공되는 시나리오에서 prefix를 내부적으로 가공하여 조회하는 로직으로 구성한 케이스이다.
public DeliveryDriverMyPageResponse getMyPage(String token) {
DeliveryDriver deliveryDriver = findByToken(identifyToken(token));
return DeliveryDriverMyPageResponse.builder()
.deliveryDriver(DeliveryDriverDto.from(deliveryDriver))
.vcos(searchDriverVocs(deliveryDriver))
.build();
}
private String identifyToken(String token) {
return "deliveryDriver_" + token;
}
private DeliveryDriver findByToken(String token){
return deliveryDriverRepository.findDeliveryDriverByDeliveryDriverToken(token).orElseThrow(DeliveryDriverNotFoundException::new);
}
결론
이처럼 시스템 내부에서 Entity 식별자로서의 Long 타입 id를 유지하면서 외부와의 노출시엔 랜덤 스트링으로 만든 대체키를 사용하면 PK 노출을 피할 수 있다.
'이슈와해결' 카테고리의 다른 글
리팩토링 회고 - 상태 패턴을 이용해서 복잡한 비즈니스 시나리오 검증 로직을 개선...! (1) | 2023.12.11 |
---|---|
28개(+α) 클래스를 검증해야 한다면? - 커스텀 애노테이션을 사용한 Payload 검증 방식 도전기 (1) | 2023.12.11 |
다수의 Validators 역할 위임 방식 회고 - Chain 패턴과 Optional을 이용한 우아한 플로우 탐색기 (0) | 2023.11.16 |
도메인 주도 개발 방법론(DDD)을 적용하여 3티어 아키텍처를 변경해보자 (1) | 2023.06.26 |
If 분기문 문제를 객체지향, 함수형 프로그래밍을 이용해 해결하기(feat. 우아한테크코스, 스프링 시큐리티) (0) | 2023.06.22 |