Nginx 환경에서 클라이언트 실제 IP 추출 시 보안 취약점 및 해결 정리
WAS 환경에서 클라이언트 IP 획득 로직 확인 결과, 신뢰 프록시 IP가 아닌 경우 remoteAddr 를 그대로 반환. X-Forwarded-For 헤더가 존재하면 쉼표로 분리하여 IP 목록 생성 후, 역순으로 신뢰 프록시 대역을 건너뛰며 최초의 외부 IP 를 실제 클라이언트 IP 로 확정하도록 Nginx 설정과 WAS 추출 로직을 정리함.
1. 무넺
Nginx 뒤에 WAS를 둘 경우
request.getRemoteAddr()는 Nginx 내부 IP만 반환함.인터넷에 널리 퍼진 다중 헤더 순차 검증 방식(
X-Forwarded-For,Proxy-Client-IP,WL-Proxy-Client-IP등)을 그대로 사용할 경우 헤더 위조(IP Spoofing)에 노출됨.관리자 접근 제어(IP 차단/허용)가 무력화될 수 있어 Nginx 설정 및 WAS 추출 로직을 신뢰 프록시 기반으로 정리함.
2. 기존 방식의 문제
기존 다중 헤더 순차 조회 코드
public String getIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
문제 분석
클라이언트 조작 가능 헤더 무조건 신뢰 (IP Spoofing)
X-Forwarded-For는 클라이언트가 임의로 요청 헤더에 삽입할 수 있음.공격자가
X-Forwarded-For: 127.0.0.1또는 허용된 관리자 IP를 주입하여 전송 시, 첫 번째 값을 그대로 읽어 인증을 통과하게 됨.
다중 프록시 환경에서 쉼표(,) 미처리
- 프록시나 로드밸런서를 거칠 때마다 IP가 쉼표로 연결(
Client_IP, Proxy1_IP, Proxy2_IP)되나, 전체 문자열을 그대로 반환하여 IP 형식 검증 실패 또는 DB 조회 오류 발생.
- 프록시나 로드밸런서를 거칠 때마다 IP가 쉼표로 연결(
환경에 맞지 않는 불필요한 헤더 검사
WL-Proxy-Client-IP(WebLogic),Proxy-Client-IP(Apache Web Server) 등 현재 인프라와 무관한 웹로직/구형 프록시 헤더를 불필요하게 검사함.
3. X-Forwarded-For 동작 원리 및 검증 기준
X-Forwarded-For 구조
X-Forwarded-For: <Client_IP>, <Proxy1_IP>, <Proxy2_IP>
프록시 서버는 이전 연결의 출발지 IP를 기존
X-Forwarded-For문자열 끝에 추가함.맨 왼쪽(
Client_IP)은 클라이언트가 얼마든지 위조하여 보낼 수 있으므로 신뢰할 수 없음.안전하게 신뢰할 수 있는 구간은 **직접 구축/관리하는 마지막 프록시(오른쪽 끝)**부터임.
올바른 IP 추출 기준
getRemoteAddr()및 XFF의 가장 오른쪽 IP부터 역방향(오른쪽 → 왼쪽)으로 검사.사전에 지정된 신뢰 프록시(Trusted Proxy) 대역에 속하는 IP는 건너뜀.
신뢰 프록시 대역을 벗어난 최초의 외부 IP를 실제 클라이언트 IP로 확정함.
4. 해결 방안 1: Nginx 단 설정
Nginx의 ngx_http_realip_module을 사용하여 Nginx 단에서 실제 IP를 판별하고 WAS에 단일 헤더로 전달하는 방식.
nginx.conf
# 신뢰할 수 있는 상위 프록시/로드밸런서/내부망 대역 등록
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
set_real_ip_from 127.0.0.1;
# 실제 IP를 추출할 헤더 지정
real_ip_header X-Forwarded-For;
# 신뢰 프록시를 제외하고 가장 마지막 외부 IP를 $remote_addr에 바인딩
real_ip_recursive on;
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://was_upstream;
# Nginx가 검증한 실제 클라이언트 IP를 단일 헤더로 WAS에 전달
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
real_ip_recursive on: 오른쪽 끝부터 거슬러 올라가며set_real_ip_from에 포함된 신뢰 프록시를 제외한 첫 번째 비신뢰 IP를$remote_addr로 교체함.WAS에서는
request.getHeader("X-Real-IP")또는request.getRemoteAddr()만 확인하면 됨.
5. 해결 방안 2: Java / Spring 단 구현
Nginx 설정을 변경하기 어렵거나 WAS 단에서 직접 검증해야 할 경우의 구현.
ClientIpResolver.java
import org.springframework.security.web.util.matcher.IpAddressMatcher;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import java.util.*;
public class ClientIpResolver {
// 신뢰할 수 있는 내부 프록시 / 로드밸런서 IP 대역
private static final List<IpAddressMatcher> TRUSTED_PROXIES = Arrays.asList(
new IpAddressMatcher("10.0.0.0/8"),
new IpAddressMatcher("172.16.0.0/12"),
new IpAddressMatcher("192.168.0.0/16"),
new IpAddressMatcher("127.0.0.1/32")
);
public static String getClientIp(HttpServletRequest request) {
String remoteAddr = request.getRemoteAddr();
// 직전 연결 IP가 신뢰 프록시가 아니면 해당 IP를 그대로 반환
if (!isTrustedProxy(remoteAddr)) {
return remoteAddr;
}
String xffHeader = request.getHeader("X-Forwarded-For");
if (!StringUtils.hasText(xffHeader)) {
return remoteAddr;
}
// X-Forwarded-For IP 목록 분리
String[] rawIps = xffHeader.split(",");
List<String> ipList = new ArrayList<>();
for (String ip : rawIps) {
String trimmed = ip.trim();
if (StringUtils.hasText(trimmed)) {
ipList.add(trimmed);
}
}
// 오른쪽 끝(최근 프록시)부터 역순으로 탐색
for (int i = ipList.size() - 1; i >= 0; i--) {
String ip = ipList.get(i);
if (!isTrustedProxy(ip)) {
return ip; // 신뢰 대역을 벗어난 첫 번째 외부 IP
}
}
return remoteAddr;
}
private static boolean isTrustedProxy(String ip) {
if (!StringUtils.hasText(ip)) {
return false;
}
for (IpAddressMatcher matcher : TRUSTED_PROXIES) {
if (matcher.matches(ip)) {
return true;
}
}
return false;
}
}