Pam
Pam 블로그 주인장

특정 API의 IP 대역 제한(AOP, 커스텀 어노테이션, XFF)

특정 API의 IP 대역 제한(AOP, 커스텀 어노테이션, XFF)

이슈

개요

저번 RabbitMQ 게시글의 비즈콜 기능과 관련된 이슈이다.

손님이 비즈콜을 통해 매장에 전화를 하면 비즈콜은 서버에 CDR 정보를 전달해주며 CDR API를 실행시킨다.

세차새차에서는 이 CDR API를 통해 매장의 테블랫에 고객의 정보가 포함된 팝업창이 자동으로 생성되도록 처리하고 있다.

그러나 비즈콜에선 해당 CDR API에 대한 별도의 인증 절차를 제공해주지 않아 서버에서 직접 비즈콜 관련 API에 대한 접근을 제한하기로 했다.

요구사항은 아래와 같다.

  • 비즈콜 IP 대역에 포함되는 IP를 가진 클라이언트만 BixCallDcrController 하위의 모든 엔트포인트에 접근할 수 있도록 한다.
  • 서버에 들어오는 모든 요청이 아니라 특정 API에서만 인증절차를 거치도록 한다.
  • 비즈콜 관련 API에만 사용되는것이 아니라 범용적으로 사용 가능하도록 어노테이션 형태로 개발한다.

고려한 방법들

커스텀 어노테이션을 사용하는 건 이미 결정된 사항이고, 어떤 방식을 사용해 인증 절차를 거칠지에 대해 고민해봐야 했다.

고려한 방식으로는 아래 세가지가 있다.

  • 필터링 사용
  • 인터셉터 사용
  • AOP 사용

IP 필터링에 대해 구글링을 해보았을때 가장 많이 보인 방식이 필터링이었던거 같다.

필터링

1
2
3
4
5
6
7
8
9
10
11
12
13
@WebFilter(urlPatterns = "/targetUri/*") //비즈콜 api 경로
public class IPFilter implements Filter {	
	...
	
	@Override
	protected void doFilter(HttpServletRequest request, 
	HttpServletResponse response, FilterChain filterChain) 
		throws ServletException, IOException {
			//허용되지 않는 IP인지 검증
		}

	...
}

그러나 필터링은 전역적인 요청에 대해 사용되는 인증으로, 요구사항처럼 특정 어노테이션을 사용하고 있는 엔드포인트에만 검증을 하도록 하기 불편하다. 보기에도 예쁘지 않다.

url 패턴을 통해 필터링을 거는 것이 아니라 handlerMethod가 특정 커스텀 어노테이션을 가지고 있는지 확인하는 방식으로 어노테이션을 사용할 순 있겠으나 이런 방식을 사용하면 서버에 들어오는 모든 요청이 이러한 검증을 거치게 된다.

문제점

  • 어노테이션과 함께 사용하기 까다롭다.
  • 요구사항이 일부 기능에 대한 인증절차 추가인 것에 비해 필터링은 Spring 범위 이상으로 전역적이다.

인터셉터

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private IPFilter ipFilter;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(ipFilter)
	        //.addPathPatterns("/targetUri/*"); 
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class IPFilter implements HandlerInterceptor {
	@Override
	public void preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
		throws Exception {
		String clientIP = request.getRemoteAddr();
		String requestURI = request.getRequestURI();
		String controllerClassName = requestURI.split("/")[1];
		
		Class<?> controllerClass = Class.forName("com.example.controllers." + controllerClassName);
		IpBandLimiter annotation = controllerClass.getAnnotation(IpBandLimiter.class);
		// 클래스의 어노테이션 확인

		// HandlerMethod handlerMethod = (HandlerMethod) handler;
		// IpBandLimiter annotation = handlerMethod.getMethodAnnotation(IpBandLimiter.class); 
		// 메서드의 어노테이션 확인
		
		if (annotation != null) {
			String[] allowedIPs = annotation.allowedIps();
			// IP 검증
		}
	}

필터링 다음으로는 인터셉터를 생각했다. 다른 프로젝트에서는 특정 uri 하위의 엔드포인트에 접속하는 경우 사용자의 출석 정보를 업데이트 하기 위해 인터셉터를 사용했었다.

문제점

  • 리플렉션을 사용하면 클래스나 메서드에 특정 어노테이션이 붙어있는지 확인하기는 어렵지 않다.
  • 호출한 메서드에 특정 어노테이션이 포함돼있는지 확인하는 것도 어렵지 않다.
  • 그러나 어떤 엔드포인트가 어느 클래스에 포함돼있는지 확인하기는 어렵다.

물론 해당 인터셉터가 필요한 모든 메서드에 일일이 어노테이션을 붙인다면 위 문제를 해결할 수는 있으나 나는 클래스에 붙은 어노테이션이 클래스 하위의 모든 메서드에 적용되길 원했다.

필터 vs 인터셉터

필터인터셉터 필터

  • Spring 컨텍스트 외부에 있는 web 컨텍스트에서 동작
  • Spring보다 큰 범위의 요청(공통적인 보안, 로깅, 요청 인코딩 설정 등)에 대해 처리

인터셉터

  • Spring 컨텍스트에서 동작
  • Spring과 관련된 요청(컨트롤러 호출 전후의 로직 처리 등)에 대해 처리

AOP

최종적으로는 AOP를 사용하는 것으로 결정했다. 무엇보다 주어진 요구사항에 따라 개발하기에 적절했기 때문이다.

요구사항은 BizCallController 하위의 모든 엔드포인트를 타겟으로 하기에 나는 각 메서드마다 어노테이션을 붙이기 보다는 컨트롤러에 붙은 어노테이션이 하위 메서드에 모두 적용되길 바랬다.

인터셉터를 사용하면 메서드 자체에 붙어있는 커스텀 어노테이션의 존재 여부는 확인할 수 있으나 해당 엔드포인트가 어떤 클래스의 엔드포인트인지 알 수 없어 클래스에만 어노테이션을 붙이는 식으로 사용은 불가능했다.

그러나 AOP의 어드바이스를 사용하면 위 문제를 깔끔하게 해결할 수 있었다.

📝
AOP(Aspect Oriented Programming, 관점 지향 프로그래밍)
관심사로부터 횡단(공통) 관심사를 분리하여 관심사를 모듈화하는 소프트웨어 개발 패러다임

AOP는 아래와 같은 개념을 사용한다.

  • 관심사(Concern): 프로그램의 기능적 또는 비기능적(로깅, 보안, 트랜잭션 관리 등) 요구사항
  • 횡단 관심사(Cross-Cutting Concern): 여러 모듈에서 공통으로 사용되는 관심사(로깅, 인증 등)
  • 애스펙트(Aspect): 횡단 관심사를 모듈화한 것. 포인트컷(Pointcut)과 어드바이스(Advice)로 구성됨

    • 조인 포인트(Join Point): 프로그램 실행 중의 특정 시점 (메서드 호출, 예외 발생 등)
    • 포인트컷(Pointcut): 특정 규칙이나 표현식을 사용하여 조인 포인트를 결정
      • @Pointcut("execution(...)")
    • 어드바이스(Advice): 포인트컷에 의해 선택된 조인 포인트에서 실행되는 코드
      • Before, After, Around
    • PointCut Designator (PCD): 횡단 관심사가 적용될 지점을 지정하기 위한 표현식
      • within, target, annotation, @within, @target, @annotation

개발

커스텀 어노테이션 인터페이스

사용자가 직접 정의한 Annotation을 Custom Annotation이라 한다.

반대로 @Overrie, @Deprecated, @SuppressWarnings 등 SDK에 내장되어 있는 기본 Annotation은 Built-in Annotation이라 부른다.


1
2
3
4
5
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface IpBandLimiter {
	String[] allowedIps();
}
  • ElementType.TYPE: 클래스, 인터페이스, 열거형(enum), 애노테이션 타입에 적용할 수 있다.
  • ElementType.METHOD: 메소드에 적용할 수 있다.
  • RetentionPolicy.RUNTIME: 어노테이션의 수명이 런타임까지 유지된다.
  • allowedIps 속성을 통해 String 타입의 IP를 문자열 배열로 입력받는다.

AOP

‘접근 가능한 IP 인증’이라는 횡단 관심사를 모듈화하여 하나의 Aspect로 만드려고 한다.

이때 어드바이스는 Before, PCD는 annotation으로 하여 특정 어노테이션이 실행되기 전에 에스펙트가 실행될 수 있도록 구상했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Slf4j
@Aspect
@Component
public class IpLimiter {

	@Before("@annotation(ipBandLimiter)")
	public void ipBandLimiter(IpBandLimiter ipBandLimiter) throws RuntimeException {
		HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();
		String clientIp = requset.getRemoteAddr(); //클라이언트 IP 추출
		String[] allowedIPs = ipBandLimiter.allowedIps(); //어노테이션에 정의된 접근 가능 IP 목록
		
		if (!isAllowedIp(clientIp, allowedIPs)) {
			log.warn("Not allowed IP (client ip = " + clientIp + ")");
			throw new ApplicationException(NOT_ALLOWED_IP_ACCESS); //허용되지 않은 IP라면 예외를 발생시킨다.
		}
		log.info("Allowed IP (client ip = " + clientIp + ")");
	}

	private boolean isAllowedIp(String clientIP, String[] allowedIPs) {
		return Arrays.stream(allowedIPs).anyMatch(clientIP::startsWith);
	}
}
IpLimiter Aspect


1
2
3
4
5
@RestController
@RequestMapping("/v2/external/bizcall")
@RequiredArgsConstructor
@IpBandLimiter(allowedIps = { ... })
public class BizCallCdrController { ... }
실제 IpBandLimiter 어노테이션 적용


문제 발생

그러나 IpBandLimiter 어노테이션이 붙은 클래스의 메서드를 실행시켜도 IpLimiter가 실행되지 않았다… 후후

AspectJ 문서를 읽어보니 아래와 같은 설명을 발견할 수 있었다.


@annotation: Limits matching to join points where the subject of the join point (the method being run in Spring AOP) has the given annotation.


보아하니 @annotation메서드 수준의 어노테이션에서만 적용되는 것 같았다.

위 코드에서 나는 @ipBandLimiter를 메서드가 아닌 클래스에 적용했으니 클래스 수준의 어노테이션을 읽지 못하고 메서드에 어노테이션이 존재하지 않는 것으로 판단한 것이다.

실제로 어노테이션을 메서드 수준으로 옮겼더니 잘 작동되었다…


그러나 나는 클래스에 선언된 어노테이션이 하위 메서드에도 적용되길 원한다! AspectJ 문서에는 아래와 같은 PCD 또한 설명하고 있다.

@within: Limits matching to join points within types that have the given annotation (the execution of methods declared in types with the given annotation when using Spring AOP).


@within은 어노테이션이 주어진 어떤 타입(클래스) 하위의 메서드에 대해 조인 포인트를 제한한다. 이를 사용하면 클래스 하위의 메서드에도 해당 클래스에 적용된 에스펙트가 실행될 것이라 생각했다.

1
2
3
4
5
	@Before("@within(ipBandLimiter)")
	public void ipBandLimiter(IpBandLimiter ipBandLimiter) {
		...
	}
PCD 수정 1


코드를 위와 같이 수정했더니 BixCallDcrController 하위의 메서드를 실행해도 ipBandLimiter()가 실행되었다.

그러나 위 코드는 이제 메서드 수준의 어노테이션을 읽지 못할 것이다(클래스에는 어노테이션이 선언되지 않았지만, 메서드에는 선언된 경우 실행되지 않을 것)

따라서 메서드와 클래스 수준 어디에 어노테이션이 적용되어도 에스펙트가 실행될 수 있도록 코드를 아래와 같이 수정했다.

1
2
	@Before("@within(ipBandLimiter) || @annotation(ipBandLimiter)")
	public void ipBandLimiter(IpBandLimiter ipBandLimiter) {...}
PCD 수정 2


클라이언트 Ip 추출

이제 어노테이션을 통한 IP 제한 자체는 가능해졌으나 문제가 있다. 아래의 세차새차 인프라 일부를 확인해보자.

인프라 클라이언트 요청은 ALB를 타고 세차새차 서버로 들어오게 된다.

이렇게 되면 클라이언트 IP를 추출하는데 문제가 생긴다…

X-Forwarded-For

📝
X-Forwarded-For(XFF)
HTTP 프록시나 로드 밸런서를 통해 웹 서버에 접속하는 클라이언트의 원 IP 주소를 식별하는 사실상의 표준 헤더

일반적으로 클라이언트의 IP값은 request.getRemoteAddr()를 통해 가져올 수 있겠지만 중간에 프록시나 로드 밸런서를 거치는 경우는 다르다.

클라이언트와 서버 중간에서 트래픽이 프록시나 로드 밸런서를 거치는 경우 서버 접근 로그에는 클라이언트의 IP가 아니라 직전에 거쳐온 트래픽이나 로드 밸런서의 주소가 남기 때문이다.

따라서 클라이언트의 원래 IP를 확인하기 위해서는 X-Forwarded-For 헤더를 확인해야한다.


1
X-Forwarded-For: <client>, <proxy1>, <proxy2>

클라이언트의 요청이 로드 벨런서 A를 지난 후 X-Forwarded-For의 가장 첫번째 값은 Client Ip가 된다.

이후 다른 프록시나 로드 밸런서 B를 지나게 된다면 로드 벨런서 B의 ip값이 XFF 헤더 뒤에 추가될 것이다.

코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
	@Before("@within(ipBandLimiter) || @annotation(ipBandLimiter)")
	public void ipBandLimiter(IpBandLimiter ipBandLimiter) throws RuntimeException {
		HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();
		String clientIp = extractClientIp(request);
		...
	}

	private String extractClientIp(HttpServletRequest request) {
		String xffIps = request.getHeader("X-Forwarded-For");
		if (xffIps != null) {
			return xffIps.split(",")[0];
		}
		throw new ApplicationException(NOT_ALLOWED_IP_ACCESS);
	}

XFF 헤더의 가장 첫번째 값(clientIp) 가져오도록 코드를 작성했다. 만일 XFF헤더가 존재하지 않는다면 로드 벨런서를 거쳐온 요청이 아니므로, 정상적인 요청으로 판단하지 않아 예외를 발생시킨다.

테스트 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@SpringBootTest
@Transactional
public class IpLimiterTest {

	@Autowired
	private BizCallCdrController bizCallCDrController;
	@Autowired
	private StoreRepository storeRepository;
	@Autowired
	private MemberRepository memberRepository;
	@Autowired
	private StoreTestHelper storeTestHelper;

	@Test
	@DisplayName("허용되지 않은 ip는 타겟에 접근할 수 없다.")
	public void 허용되지_않은_ip는_타겟에_접근할_수_없다() throws Exception {
		//given
		MockHttpServletRequest request = new MockHttpServletRequest();
		String notAllowedIp = "197.2.72.178";
		request.addHeader("X-Forwarded-For", notAllowedIp);
		RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
		Store store = storeTestHelper.makeStaticRunningStore();
		memberRepository.save(store.getOwner());
		storeRepository.save(store);

		// when & then
		assertThrows(ApplicationException.class,
			() -> bizCallCDrController.cdrCallInboundInit("01", "20200202020202", "000-0000-0000", store.getTel(),
				"111-1111-1111", "memo", "memo2"));
	}

	@Test
	@DisplayName("허용된 ip는 타겟에 접근할 수 있다.")
	public void 허용된_ip는_타겟에_접근할_수_있다() throws Exception {
		//given
		MockHttpServletRequest request = new MockHttpServletRequest();
		String allowedIp = "210.109.108.133";
		request.addHeader("X-Forwarded-For", allowedIp);
		RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
		Store store = storeTestHelper.makeStaticRunningStore();
		memberRepository.save(store.getOwner());
		storeRepository.save(store);

		//when & then
		assertDoesNotThrow(
			() -> bizCallCDrController.cdrCallInboundInit("01", "20200202020202", "000-0000-0000", store.getTel(),
				"111-1111-1111", "memo", "memo2"));
	}
}

간단한 테스트 코드를 작성했다.

추가 고려 사항

XFF 헤더 조작

리뷰

프론트+백엔드 작업을 하시는 작업자분에게 위와 같은 리뷰가 달렸다.

이럴수가.. 코드를 작성할때는 생각해본적 없던 문제였다. 실무 개발자들은 다양한 가능성을 떠올릴수 있구나 싶었다.

리뷰가 달린 후 클라이언트의 XFF 헤더 조작 가능성에 대해 알아봤다.

Append

AWS 문서에서 ALB 설정에 관한 문서를 찾을 수 있었다.

image

https://docs.aws.amazon.com/ko_kr/elasticloadbalancing/latest/application/application-load-balancers.html


로드 벨런서의 xff_header_processing.mode는 기본적으로 Append로 설정되어 있다.

만일 클라이언트가 요청을 조작하여 요청에 조작된 IP를 가지는 XFF헤더를 추가해 전송한다면 로드 벨런서는 조작된 IP를 유지한 채 XFF 헤더 마지막에 client Ip를 덧붙이게 될 것이다.

Preserve는 요청에 담긴 XFF헤더에 값을 추가하지 않고 그대로 유지하는 설정이고 Remove는 아예 요청에서 XFF헤더를 제거해버리는 설정이다. XFF헤더의 값을 아예 교체하는 설정은 존재하지 않는 듯 하다.

이렇게 되면 문제가 발생할 수 있다. 사용자가 XFF헤더에 값을 추가해서 전송하면 서버는 진짜 Client Ip가 XFF 헤더의 어느 위치에 존재하는지 알 수 없다는 점이다.

set_real_ip_from

위 문제는 nginx 설정을 통해 해결할 수 있다.

set_real_ip_from

  • Defines trusted addresses that are known to send correct replacement addresses
  • 올바른 주소를 보내는 것으로 알려진 신뢰 가능한 주소를 정의한다.

real_ip_recursive

  • If recursive search is enabled, the original client address that matches one of the trusted addresses is replaced by the last non-trusted address sent in the request header field.
  • 만약 설정이 켜져있다면, 신뢰가능한 주소와 일치하는 원본 클라이언트 주소는 요청 헤더에 전송된 가장 마지막의 비신뢰 주소로 교체된다.

솔직히 문서만 봐서는 이해가 어려웠다. 해당 블로그 글이 많은 도움이 되었다.


문제상황을 가정하여 위 설정에 대해 설명을 해보려 한다. 클라이언트가 세차새차 서버에 아래와 같은 조작된 요청을 보내는 상황을 가정한다.

1
X-Forwarded-For: <비즈콜 IP>

그렇다면 로드 벨런서를 거쳐 nginx에 도달한 요청은 Append 설정에 의해 아래와 같이 바뀔 것이다.

1
X-Forwarded-For: <비즈콜 IP> <실제 클라이언트 IP> <사용한 로드 벨런서 IP>

nginx 설정이 되어있지 않는 경우 서버는 XFF 헤더의 가장 첫번째 값인 <비즈콜 IP>를 클라이언트 IP로 인식하고, 해당 요청이 비즈콜에서 온 것이라 판단하며 서버에 접근시킬 것이다.


위와 동일한 상황에서, 이번에는 아래와 같이 nginx가 설정되어있다 가정하자.

1
2
3
set_real_ip_from <사용하는 로드 벨런서 IP>
set_real_ip_from <사용하는 프록시 IP>
real_ip_recursive on;
nginx 설정


로드 벨런서를 거쳐 nginx에 도달한 클라이언트 요청은 이전 상황과 동일하다.

1
X-Forwarded-For: <비즈콜 IP> <실제 클라이언트 IP> <사용한 로드 벨런서 IP>

nginx에 따로 설정이 되어있지 않을 때는 가장 앞에 있는 <비즈콜 IP>를 클라이언트 IP로 인식했지만 이번에는 다르다.

<실제 클라이언트 IP>는 set_real_ip_from에 정의된 신뢰 가능한 주소가 아니다.

따라서 real_ip_recursive 에 의해 신뢰 가능한 주소로 정의된 <사용한 로드 벨런서 IP> 앞에 있는 <실제 클라이언트 IP>를 실제 클라이언트 IP로 인식한다.


그러나 위 설정을 아직 적용시키진 않았다 ^^;

비즈콜 IP 위조 자체가 치명적인 보안 문제를 발생시키기 보다는 사장님이 좀 짜증나는 정도가 전부이기 때문에 여기에 시간을 쓰기 보다는 더 급한 서비스 개발을 먼저 하기로 했다.

Slient fail?

추가적으로 고려해볼 사항이 하나 더 있다.

지금은 허용되지 않은 IP에서 접근할 시 NOT_ALLOWED_IP_ACCESS 예외를 반환하는데, 이렇게 해커에게 직접적으로 예외 발생 여부를 보여주는 건 좋지 않다고 한다.

따라서 해커의 요청을 거부할 시에는 실제로는 요청을 거부하되 클라이언트 상에서는 요청이 허용된 것처럼 넘겨주어야 한다는데 이것에 대한 정확한 명칭을 모르겠다 (검색해보았을때 가장 유사해보이는데 slient fail이었다.)



배운 점: AOP는 아직 알아야 할 부분이 많은 것 같다. 토비의 스프링에서 AOP에 대한 부분을 상당히 두껍게 다루던데(…) 그걸 읽어보며 공부하고 한번 정리하는 것도 괜찮을 것 같다.