로그 포맷이라고 부를 수 있을 최소 요건을 갖춘 것 같아, 로그 기능을 적용한 버전을 재배포 하고 결과를 살펴봤습니다.
확실히 정돈된 형식으로 로그가 쌓이는 모습에 기쁨도 잠시,, 무언가 이상한 점이 눈에 띄었습니다. 바로, 어떤 클라이언트가 요청을 전송하든 CLIENT IP:
속성에 동일한 IP가 출력되고 있었습니다. 원인을 찾아가 보았습니다.
먼저 클라이언트의 IP를 포함해 로그를 출력하는 코드를 살펴보면 Spring 서버는 로깅 처리를 위해 생성한 MdcLoggingFilter
필터에서 HttpServletRequest
으로부터 getRemoteAddr()
메서드를 통해 클라이언트의 IP를 추출합니다. 사용된 HttpServletRequest.getRemoteAddr()
메서드는 Spring 애플리케이션에 요청을 전달한 마지막 프록시의 IP 주소를 반환합니다.
출처 : ServletRequest 공식 문서 |
따라서 해당 프로젝트의 아키텍처 상 HttpServletRequest.getRemoteAddr()
메서드는 프록시 역할을 하는 Nginx 컨테이너의 IP를 반환하던 것 이었습니다.
그림으로 보면 getRemoteAddr()
는 빨간색 reverse proxy 서버의 IP를 반환합니다.
정말 그러한지 궁금하니 SSH 프로토콜로 접속해 docker inspect {nginx 컨테이너 명}
명령어를 수행하고, Networks
에 속한 IPAddress
를 살펴보았더니 아래와 같이 정말 문제의 IP를 확인할 수 있었습니다. (참고한 링크 : 도커 네트워크 기본)
이러한 문제를 해결하기 위해 사실상 표준헤더인 X-Forwarded-For
를 사용했습니다.
X-Forwarded-For
의 구조는 다음과 같이, 클라이언트 IP와 거쳐온 프록시 IP들이 콤마로 구분되어 나열됩니다.
X-Forwarded-For: <client>, <proxy1>, <proxy2>
Nginx 설정을 통해 HTTP Header 에 클라이언트의 진짜 IP 정보를 추가하고, Spring 서버의 MdcLoggingFilter
필터에서 추가한 헤더로부터 IP를 추출하도록 수정하면 됩니다.
Nginx 헤더 설정
location /{URL패턴} {
...
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
...
}
$remote_addr
$proxy_add_x_forwarded_for
ClientIPResolver 유틸 클래스
Nginx 설정으로 고려해 요청에 대한 로그를 작성할 때 클라이언트의 올바른 IP를 추출하기 위해 ClientIPResolver
클래스를 다음과 같이 만들었습니다.
@Slf4j
public class ClientIPResolver {
private static final List<String> localhostList = List.of("...");
/**
* "x-forwarded-for", "x-real-ip" 헤더를 통해 클라이언트 IP를 반환한다.
* IP를 찾을 수 없는 경우 "unknown" 문자열을 반환한다.
* @param httpServletRequest
* @return 클라이언트 IP
*/
public static String getClientIP(HttpServletRequest httpServletRequest) {
String clientIP = null;
String xff = httpServletRequest.getHeader("X-Forwarded-For");
if (StringUtils.hasText(xff)){
List<String> xffList = Arrays.asList(xff.split(","));
clientIP = xffList.get(0).trim();
}
if (!StringUtils.hasText(clientIP)) {
clientIP = httpServletRequest.getHeader("x-real-ip");
}
if (!StringUtils.hasText(clientIP) && StringUtils.hasText(httpServletRequest.getHeader("host"))) {
clientIP = localhostList.contains(httpServletRequest.getHeader("host")) ? "localhost" : null;
}
if (!StringUtils.hasText(clientIP) && StringUtils.hasText(httpServletRequest.getRemoteAddr())) {
clientIP = httpServletRequest.getRemoteAddr();
}
if (!StringUtils.hasText(clientIP)) {
clientIP = "unknown";
}
return clientIP;
}
}
코드를 살펴보면 X-Forwarded-For
헤더에 담긴 속성 값이 존재한다면, 첫 번째 IP를 클라이언트의 IP으로 추정하며, X-Forwarded-For
헤더가 존재하지 않는다면 x-real-ip
헤더에 담긴 값을 클라이언트의 IP으로 추정합니다.
하지만 이 방식은 잠재적으로 올바른 클라이언트의 IP를 출력하지 않을 가능성을 내포하고 있었습니다.
X-Forwarded-For
헤더의 인덱스 0 즉, 첫 번째 IP를 항상 클라이언트 IP가 아닐 가능성이 있습니다.
가령, 중간 프록시 서버가 X-Forwarded-For 헤더를 수정하거나 새로 추가할 수 있으며, 악의적인 사용자가 X-Forwarded-For 헤더를 조작하여 가짜 IP를 추가할 수 있습니다.
따라서 더 안전한 방법은 신뢰할 수 있는 프록시 목록을 유지하고, X-Forwarded-For 헤더를 뒤에서부터 앞으로 순회하며 첫 번째로 나타나는 신뢰할 수 없는 IP를 클라이언트 IP로 간주하는 것이 권장됩니다.
신뢰할 수 있는 프록시 IP란
서버 관리자가 알고 있고 신뢰하는 프록시 서버의 IP 주소를 말합니다. 예를들어, 로드 밸런서
, 리버스 프록시 서버
, CDN 제공업체의 프록시 서버
등을 의미합니다. 이러한 프록시들은 X-Forwarded-For
헤더를 올바르게 처리하고 클라이언트 IP를 정확히 전달할 것으로 기대됩니다.
여러 프록시를 거치는 경우 실제 클라이언트 IP가 아닌 중간 프록시의 IP를 반환할 수 있는 문제를 내포하고 있습니다.
X-Forwarded-For
탐색 로직 개선위에서 고려한 프로세스를 ClientIpResolver 에 적용해
public class ClientIPResolver {
private static final List<String> TRUSTED_PROXIES = Arrays.asList("10.0.0.1", "192.168.1.1"); // 신뢰할 수 있는 프록시 IP 목록
public static String getClientIP(HttpServletRequest httpServletRequest) {
String xForwardedFor = httpServletRequest.getHeader("X-Forwarded-For");
if (StringUtils.hasText(xForwardedFor)) {
return getClientIPFromXForwardedFor(xForwardedFor);
}
String xRealIP = httpServletRequest.getHeader("X-Real-IP");
if (StringUtils.hasText(xRealIP)) {
return xRealIP;
}
return httpServletRequest.getRemoteAddr();
}
private static String getClientIPFromXForwardedFor(String xForwardedFor) {
String[] ips = xForwardedFor.split(",");
// 뒤에서부터 순회하며 신뢰할 수 없는 첫 번째 IP를 찾음
for (int i = ips.length - 1; i >= 0; i--) {
String ip = ips[i].trim();
if (!TRUSTED_PROXIES.contains(ip)) {
return ip;
}
}
// 모든 IP가 신뢰할 수 있는 경우, 맨 앞의 IP 반환
return ips[0].trim();
}
}
개선된 버전은 다음과 같은 방식으로 작동합니다:
1. X-Forwarded-For 헤더가 있는 경우, 이를 우선적으로 처리합니다.
2. X-Forwarded-For 헤더 처리 시:
단, 신뢰할 수 있는 프록시 목록(TRUSTED_PROXIES)을 정확히 관리해야 하며, 이는 네트워크 구성에 따라 적절히 설정해야 합니다.
참고 링크 : https://news.hada.io/topic?id=6098
참고 링크 : https://kkang-joo.tistory.com/42