nginx 뒤에 두면 로그인 잠금이 전원을 잠근다
운영 중인 스프링 서버를 nginx 뒤로 옮기는 작업을 하다가 application.yml에서 이런 주석을 봤습니다. “nginx를 앞에 두면 함께 켠다: SERVER_FORWARD_HEADERS_STRATEGY=framework”. 주석만 있고 설정은 꺼져 있었습니다.
이 설정이 꺼진 채로 nginx 뒤에 서면, IP 기준으로 로그인 실패를 세는 잠금이 이상하게 동작합니다. 한 명이 비밀번호를 틀리면 모든 사용자가 같이 잠기거든요. 설정 한 줄을 켜면 해결되는 것처럼 보이지만, nginx 쪽을 흔한 예시대로 두면 이번에는 누구나 IP를 위조해서 잠금을 피해 갈 수 있습니다.
이 글에서는 그 두 가지를 다룹니다. 표에 나오는 값은 전부 재현 실험의 실제 출력입니다.
왜 모두가 잠길까
nginx가 앞에 있으면 스프링 앱에 직접 연결하는 건 클라이언트가 아니라 nginx입니다. 아무 설정이 없으면 request.getRemoteAddr()는 모든 요청에 대해 nginx의 IP를 돌려줍니다. 실험에서도 설정을 끈 앱은 클라이언트 IP 대신 nginx 컨테이너 IP인 172.18.0.6을 받았습니다.
IP를 키로 실패 횟수를 세는 잠금이라면 모든 사용자의 실패가 키 하나에 쌓입니다. 누군가 비밀번호를 다섯 번 틀리면 그 키가 잠기고, 나머지 사용자는 비밀번호를 맞게 입력해도 로그인하지 못합니다.
// 예시: IP 기준으로 실패 횟수를 세는 잠금
public boolean isLocked(HttpServletRequest request) {
String ip = request.getRemoteAddr(); // nginx 뒤에서는 모든 요청이 같은 값
return failCount.getOrDefault(ip, 0) >= MAX_FAILS;
}
감사 로그에 접속 IP를 남기고 있었다면 그 기록도 전부 nginx IP가 됩니다.
스프링 설정 한 줄
nginx는 클라이언트 IP를 X-Forwarded-For 헤더에 담아 넘겨줄 수 있습니다. 스프링이 이 헤더를 읽도록 설정하면 됩니다.
server:
forward-headers-strategy: framework
framework로 두면 스프링의 ForwardedHeaderFilter가 요청을 감싸서, 이후 코드에서 getRemoteAddr()를 부르면 헤더에 담긴 클라이언트 IP가 나옵니다. 잠금 코드는 그대로 둬도 됩니다.
여기까지는 검색하면 쉽게 나오는 내용인데요. 실제로 문제가 되는 건 nginx 설정입니다.
흔한 nginx 예시는 위조된 IP를 그대로 넘깁니다
nginx 리버스 프록시 예시를 찾아보면 대부분 이렇게 되어 있습니다.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
$proxy_add_x_forwarded_for는 클라이언트가 보낸 X-Forwarded-For 값 뒤에 nginx가 본 IP를 덧붙입니다. 그런데 스프링의 ForwardedHeaderFilter는 이 헤더의 맨 왼쪽 값을 클라이언트 IP로 씁니다(스프링 소스, Spring 6.x도 같습니다). 맨 왼쪽은 클라이언트가 직접 적어 보낸 값이죠.
| nginx 설정 | 클라이언트가 보낸 헤더 | 앱이 받은 IP |
|---|---|---|
| 덧붙이기 | 없음 | 172.18.0.1 |
| 덧붙이기 | X-Forwarded-For: 1.2.3.4 |
1.2.3.4 |
덮어쓰기($remote_addr) |
X-Forwarded-For: 1.2.3.4 |
172.18.0.1 |
요청할 때마다 다른 IP를 넣으면 잠금 키가 계속 바뀌니까 몇 번이고 다시 시도할 수 있습니다. 그래서 보통 $remote_addr로 덮어쓰라고 하고, 표의 마지막 줄처럼 X-Forwarded-For 위조는 실제로 막힙니다.
X-Forwarded-For만 덮어쓰면 Forwarded 헤더로 뚫립니다
ForwardedHeaderFilter는 X-Forwarded-For 말고도 표준 Forwarded 헤더(RFC 7239)와 X-Forwarded-Host 같은 헤더를 함께 읽습니다. nginx는 따로 설정하지 않은 헤더를 그대로 넘기기 때문에, X-Forwarded-For만 덮어쓰면 나머지는 클라이언트가 보낸 값 그대로 앱에 도착합니다.
| 클라이언트가 보낸 헤더 | 앱이 받은 값 |
|---|---|
Forwarded: for=1.2.3.4 |
IP 1.2.3.4 |
X-Forwarded-Host: evil.example |
요청 URL http://evil.example/ip |
첫 번째는 잠금 우회가 여전히 된다는 뜻입니다. 두 번째는 범위가 더 넓습니다. 스프링이 요청 URL을 클라이언트가 보낸 호스트로 바꿔 버리기 때문에, 요청 URL로 링크를 만드는 코드가 있다면 그 링크가 공격자 도메인을 가리키게 됩니다. 비밀번호 재설정 메일이 대표적이에요.
Spring 문서도 이 부분을 짚고 있습니다. 신뢰 경계의 맨 앞에 있는 프록시는 Forwarded와 X-Forwarded-*를 모두 제거해야 한다고 적혀 있습니다(Spring Framework 문서).
location을 하나 추가했더니 설정이 사라졌습니다
헤더 설정을 server 블록에 한 번 적어 두고, 웹소켓 경로에만 Upgrade 헤더를 추가하는 구성은 흔합니다. 문제는 nginx의 상속 규칙입니다. 현재 블록에 proxy_set_header가 하나라도 있으면 바깥 블록의 proxy_set_header는 하나도 물려받지 않습니다(nginx 문서).
# /ws/ 는 server 블록의 X-Forwarded-For · Forwarded 설정을 물려받지 못한다
server {
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Forwarded "";
location / { proxy_pass http://app:8080; }
location /ws/ { proxy_pass http://app:8080/;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; }
}
실험해 보면 /에서는 Forwarded: for=1.2.3.4가 막혀서 172.18.0.1이 나오는데, /ws/에서는 같은 요청에 1.2.3.4가 나옵니다. X-Forwarded-For 위조도 /ws/에서는 그대로 통했습니다. 설정 파일을 보면 막혀 있는 것처럼 보여서 더 알아차리기 어렵습니다.
IP를 꺼내는 유틸이 따로 있다면
X-Forwarded-For, Proxy-Client-IP, WL-Proxy-Client-IP, X-Real-IP를 차례로 확인해서 클라이언트 IP를 꺼내는 유틸 메서드를 쓰는 프로젝트가 많습니다. 이런 유틸은 스프링 설정과 상관없이 헤더를 직접 읽습니다. 실험에서 X-Forwarded-For만 덮어쓴 nginx 뒤로 X-Real-IP: 1.2.3.4나 Proxy-Client-IP: 1.2.3.4를 보내면 앱에 그 값이 그대로 들어왔습니다.
가장 깔끔한 방법은 유틸을 없애고 getRemoteAddr()만 쓰는 겁니다. 스프링 설정이 켜져 있으면 그 값이 곧 클라이언트 IP입니다. 당장 유틸을 못 바꾼다면 nginx에서 해당 헤더도 덮어쓰거나 지워야 합니다.
권장 nginx 설정
정리하면, 클라이언트가 보낸 forwarded 헤더는 하나도 믿지 않고 nginx가 아는 값으로 채웁니다. 헤더 설정은 파일 하나에 모아 둡니다.
# /etc/nginx/proxy_headers.conf — location 마다 include 한다
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix ""; # 빈 값이면 nginx 가 헤더를 보내지 않는다
proxy_set_header Forwarded "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Proxy-Client-IP "";
proxy_set_header WL-Proxy-Client-IP "";
# 모르는 Host 로 온 요청은 끊는다
server {
listen 443 ssl default_server;
ssl_reject_handshake on;
}
server {
listen 443 ssl;
server_name example.com;
# ssl_certificate 등은 생략
location / {
proxy_pass http://app:8080;
include /etc/nginx/proxy_headers.conf;
}
location /ws/ {
proxy_pass http://app:8080/;
include /etc/nginx/proxy_headers.conf; # 여기에도 다시 넣는다
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
server_name과 기본 서버를 따로 둔 이유가 있습니다. $host는 클라이언트가 보낸 Host 헤더 값입니다. 서버 블록이 하나뿐이면 Host: evil.example로 보내도 그 블록이 요청을 받기 때문에, X-Forwarded-Host를 덮어써도 요청 URL이 evil.example이 됩니다. 처음 만든 권장 설정에서 실제로 이 구멍이 나왔고, 기본 서버가 모르는 Host를 먼저 끊도록 바꿔서 막았습니다. https에서는 ssl_reject_handshake on;으로 인증서 없이 연결을 거절할 수 있습니다(nginx 문서, 1.19.4부터). 실험실은 http라서 return 444;로 대신했습니다.
이 설정에 위조 헤더를 하나씩 보낸 결과입니다.
| 경로 | 클라이언트가 보낸 헤더 | 앱이 받은 값 |
|---|---|---|
/ |
없음 | 172.18.0.1, http://localhost:8087/ip |
/ |
X-Forwarded-For: 1.2.3.4 |
172.18.0.1, http://localhost:8087/ip |
/ |
Forwarded: for=1.2.3.4 |
172.18.0.1, http://localhost:8087/ip |
/ |
X-Forwarded-Host: evil.example |
172.18.0.1, http://localhost:8087/ip |
/ |
X-Forwarded-Proto: https |
172.18.0.1, http://localhost:8087/ip |
/ |
Host: evil.example |
응답 없이 연결 끊김 |
/ |
X-Real-IP: 1.2.3.4 |
X-Real-IP=172.18.0.1 |
/ |
Proxy-Client-IP: 1.2.3.4 |
Proxy-Client-IP=null |
/ws/ |
Forwarded: for=1.2.3.4 |
172.18.0.1 |
/ws/ |
X-Forwarded-For: 1.2.3.4 |
172.18.0.1 |
X-Forwarded-Host와 X-Forwarded-Port를 빈 값으로 지우지 않고 $host, $server_port로 채운 것도 실험 때문입니다. 지우기만 했더니 요청 URL에서 포트가 빠졌습니다(http://localhost/ip). 443만 쓴다면 차이가 없겠지만 채워 두는 편이 어디서든 맞습니다.
업계 표준과도 맞을까
$proxy_add_x_forwarded_for가 워낙 흔해서, 덮어쓰는 방식이 혹시 비표준이 아닌지 확인해 봤습니다.
- 쿠버네티스에서 널리 쓰여 온 ingress-nginx는 기본값(
use-forwarded-headers: false)이 이 방식입니다. 클라이언트가 보낸X-Forwarded-*헤더를 무시하고 nginx가 본 값으로 채운다고 문서에 적혀 있습니다(ingress-nginx 문서). - MDN은 속도 제한이나 IP 차단처럼 보안에 쓰는 값은 신뢰하는 프록시가 붙인 IP만 써야 하고, 맨 왼쪽 값은 보안 용도로 쓰면 안 된다고 설명합니다. 서버에 인터넷에서 직접 접속할 수 있으면
X-Forwarded-For의 어떤 값도 믿을 수 없다고도 적고 있습니다(MDN).
스프링 framework 방식은 맨 왼쪽 값을 읽으니, 맨 앞의 nginx가 그 자리를 자기가 본 IP로 채워 주는 게 표준 권고와 맞는 조합입니다. $proxy_add_x_forwarded_for는 프록시가 여러 단계일 때 경로를 남기려는 용도이고, 그때는 앱이 신뢰할 프록시 목록을 알고 오른쪽부터 걸러 읽어야 합니다.
native를 쓰는 경우
server.forward-headers-strategy에는 native도 있습니다. 스프링 대신 내장 톰캣이 헤더를 처리하는 방식이고, Spring Boot 문서는 프록시가 X-Forwarded-For와 X-Forwarded-Proto를 넘긴다면 native로 충분하다고 안내합니다(Spring Boot 문서).
실험해 보니 톰캣은 Forwarded 헤더는 읽지 않았지만 X-Forwarded-Host와 X-Forwarded-Proto는 받아들였습니다. X-Forwarded-For만 덮어쓴 상태에서 X-Forwarded-Host: evil.example을 보내면 요청 URL이 http://evil.example:8085/ip가 됐고, X-Forwarded-Proto: https를 보내면 https://localhost/ip가 됐습니다. native를 쓰더라도 위의 nginx 설정은 그대로 필요합니다.
애플리케이션 포트가 열려 있으면 소용없습니다
헤더를 믿는다는 건 모든 요청이 nginx를 거쳐 온다는 전제가 있어야 합니다. 실험에서 앱 포트로 바로 접속해서 X-Forwarded-For: 1.2.3.4를 보내자 앱은 그대로 1.2.3.4를 받았습니다.
도커를 쓴다면 앱 컨테이너 포트는 외부에 열지 말고 nginx와 같은 네트워크에서만 접근하게 둡니다.
services:
nginx:
image: nginx:stable
ports: ["80:80", "443:443"]
app:
image: my-app:latest
expose: ["8080"] # 같은 네트워크 안에서만 접근
내 서버에서 확인하는 방법
클라이언트 IP와 요청 URL을 돌려주는 확인용 엔드포인트를 잠깐 열어 두고 위조 헤더를 보내 봅니다.
// 확인이 끝나면 지운다
@GetMapping("/debug/ip")
public String ip(HttpServletRequest request) {
return request.getRemoteAddr() + " " + request.getRequestURL();
}
curl https://example.com/debug/ip
curl -H "X-Forwarded-For: 1.2.3.4" https://example.com/debug/ip
curl -H "Forwarded: for=1.2.3.4" https://example.com/debug/ip
curl -H "X-Forwarded-Host: evil.example" https://example.com/debug/ip
curl -H "Host: evil.example" https://example.com/debug/ip
처음 네 개는 내 공인 IP와 내 도메인이 나와야 하고, 마지막은 연결이 거절돼야 합니다. 1.2.3.4나 evil.example이 한 번이라도 나오면 위조가 통하는 상태입니다. 웹소켓처럼 다른 location이 있다면 그 경로로도 확인해 보세요.
더 생각해 볼 것
쿠버네티스에서는 설정하지 않아도 native가 켜집니다. Spring Boot는 쿠버네티스 같은 클라우드 환경을 감지하면 forward-headers-strategy 기본값을 native로 둡니다(Spring Boot 문서). 실험에서 설정이 없는 앱에 쿠버네티스 환경 변수만 넣고 직접 접속했더니 X-Forwarded-For: 1.2.3.4를 그대로 1.2.3.4로 받았습니다. 쿠버네티스라면 모두가 잠기는 문제보다 위조가 통하는지를 먼저 확인해야 합니다. ingress-nginx를 쓴다면 위 설정 대신 ingress-nginx 문서의 옵션을 봐야 합니다.
프록시가 두 단계 이상이면 조금 다릅니다. 로드밸런서나 CDN이 nginx 앞에 있으면 $remote_addr는 그 장비의 IP입니다. 이때는 덮어쓰기만으로는 안 되고, nginx real_ip 모듈로 신뢰할 프록시를 지정해서 진짜 클라이언트 IP를 꺼내야 합니다. 이 글의 실험은 nginx가 맨 앞에 있는 경우만 다뤘습니다.
잠금 키를 IP 하나로 잡지 않는 것도 방법입니다. 회사나 학교처럼 여러 사람이 공인 IP 하나를 같이 쓰는 곳에서는 프록시 설정과 상관없이 같은 문제가 생깁니다. IP와 아이디를 묶은 잠금과 아이디 단독 잠금을 같이 두면, 한 사람의 실수가 다른 사람에게 번지지 않으면서 특정 아이디를 노리는 시도도 막을 수 있습니다.
외부로 나가는 링크는 요청 URL로 만들지 않는 게 좋습니다. 비밀번호 재설정 링크 같은 건 설정값에 있는 서비스 도메인으로 만들면, 나중에 프록시 설정이 틀어져도 피해가 번지지 않습니다.
스프링 설정과 nginx 설정은 따로 바뀝니다. 둘이 맞아야만 안전한 구조라서, 서로 다른 저장소에 있다면 한쪽 설정 옆에 다른 쪽이 어떻게 되어 있어야 하는지 주석으로 남겨 두는 게 좋습니다. 처음에 봤던 그 application.yml 주석처럼요. 다만 주석만으로는 부족했으니, 이번에는 설정을 실제로 켜고 주석에 nginx 쪽 조건까지 적어 뒀습니다.
정리
- nginx 뒤에서 아무 설정이 없으면
getRemoteAddr()는 항상 nginx IP를 돌려주고, IP 기준 잠금은 모두를 잠급니다. - 스프링에
server.forward-headers-strategy: framework를 켭니다. - nginx는
X-Forwarded-For만 덮어써서는 안 됩니다.Forwarded를 지우고 나머지 forwarded 헤더도 nginx 값으로 채우고,server_name으로 모르는Host를 끊어야 합니다. - 헤더 설정은 파일로 빼서
location마다 include 합니다. - 클라이언트 IP는
getRemoteAddr()로만 읽고, 앱 포트는 외부에 열지 않습니다.