콘텐츠로 이동

X-Forwarded 헤더에서 진짜 클라이언트 IP는 어떻게 읽을까요?

로그에 찍힌 IP가 사용자 주소처럼 보이죠? 사실은 바로 앞 프록시 주소일 수도 있어요.

Proxy, Reverse Proxy, 그리고 Load Balancer에서는 브라우저와 앱 서버 사이에 리버스 프록시나 로드 밸런서가 설 수 있다는 큰 그림을 봤어요. 그리고 502, 503, 504는 어디서 만든 응답일까요?에서는 앞단과 오리진 사이에서 응답의 목소리가 달라질 수 있다는 걸 봤죠.

이번에는 응답 코드가 아니라 클라이언트 IP를 읽어볼게요.

운영 로그에 이런 줄이 있다고 해볼게요.

remote_addr=10.0.4.12
x-forwarded-for=203.0.113.25, 198.51.100.7
x-forwarded-proto=https
x-forwarded-host=shop.example.com

처음 보면 203.0.113.25가 사용자 같고, 10.0.4.12는 내부 주소처럼 보여요. 맞을 수도 있어요. 하지만 그냥 맨 앞 값을 믿으면 안 돼요.

오늘의 질문은 이거예요.

"이 IP는 누가 적은 값이고, 어디까지 믿어도 될까요?"

Forwarded 헤더는 RFC 7239에서 표준화한 HTTP 확장 헤더예요. 이 RFC는 프록시가 끼면 원래 클라이언트 IP, 원래 Host, 원래 protocol 같은 정보가 사라지거나 바뀔 수 있고, 이를 HTTP 헤더로 전달할 수 있다고 설명해요. 동시에 X-Forwarded-For, X-Forwarded-By, X-Forwarded-Proto 같은 헤더는 흔히 쓰이는 비표준 계열이라고 짚어요.

이 글의 범위

여기서는 특정 프레임워크 설정법보다, X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host, 표준 Forwarded 헤더를 어떤 순서로 읽고 어디까지 신뢰해야 하는지에 집중해요. 제품마다 헤더를 붙이는 방식과 trust proxy 설정 이름은 달라요.


앱 서버가 처음 보는 주소는 "바로 앞 사람"이에요

택배를 생각해볼게요. 고객이 택배를 보냈는데, 중간 물류센터가 여러 번 갈아 실었어요. 마지막 창고 직원이 상자를 받으면, 상자를 손에 건넨 사람은 고객이 아니라 바로 앞 물류센터 직원이에요.

웹 서버도 비슷해요.

flowchart LR
    A[사용자<br/><small>203.0.113.25</small>] --> B[CDN<br/><small>198.51.100.7</small>]
    B --> C[로드 밸런서<br/><small>10.0.4.12</small>]
    C --> D[앱 서버<br/><small>요청 처리</small>]

앱 서버 입장에서 TCP 연결을 직접 열고 들어온 상대는 10.0.4.12일 수 있어요. 그래서 앱의 remote addressremote_addr에는 사용자가 아니라 로드 밸런서 주소가 찍힐 수 있죠.

물류 장면 웹 요청 장면
고객 실제 사용자 클라이언트
중간 물류센터 CDN, 프록시, 로드 밸런서
마지막으로 상자를 건넨 직원 앱 서버가 보는 transport remote address
운송장에 적힌 원래 보낸 사람 X-Forwarded-For 또는 Forwarded: for=
배송 방식 메모 X-Forwarded-Proto 또는 Forwarded: proto=
원래 목적지 이름 X-Forwarded-Host 또는 Forwarded: host=

핵심은 이거예요.

앱이 직접 본 주소와, 헤더에 적힌 원래 주소는 성격이 달라요.

직접 본 주소는 네트워크 연결에서 나온 값이고, forwarded 계열 헤더는 HTTP 요청 안에 적힌 값이에요. HTTP 헤더는 중간 프록시가 붙여줄 수도 있지만, 클라이언트가 마음대로 보내버릴 수도 있어요.

X-Forwarded-For는 보통 왼쪽부터 경로를 남겨요

가장 자주 보는 건 X-Forwarded-For예요.

X-Forwarded-For: 203.0.113.25, 198.51.100.7

많은 환경에서는 왼쪽부터 이렇게 읽어요.

flowchart LR
    A[203.0.113.25<br/><small>원래 클라이언트 후보</small>] --> B[198.51.100.7<br/><small>앞쪽 프록시 후보</small>] --> C[10.0.4.12<br/><small>현재 연결의 remote address</small>]

즉, 앱 서버가 보는 remote_addr10.0.4.12이고, 헤더가 203.0.113.25, 198.51.100.7이라면 전체 후보 체인은 이렇게 볼 수 있어요.

203.0.113.25 -> 198.51.100.7 -> 10.0.4.12 -> app

하지만 여기서 바로 "맨 왼쪽이 진짜 사용자"라고 외우면 위험해요.

왜냐하면 X-Forwarded-For는 표준 Forwarded보다 오래 널리 쓰인 관례에 가깝고, 무엇보다 요청 헤더라서 위조될 수 있기 때문이에요.

GET /admin HTTP/1.1
Host: shop.example.com
X-Forwarded-For: 127.0.0.1

만약 인터넷에서 들어온 이 값을 앱이 그대로 믿고 "127.0.0.1이면 내부 관리자군요"라고 판단하면 큰 사고가 나요. 그래서 forwarded 헤더 읽기의 핵심은 파싱보다 신뢰 경계예요.

인터넷에서 온 X-Forwarded-For를 그대로 믿으면 안 돼요

사용자는 요청에 임의의 X-Forwarded-For를 넣을 수 있어요. 이 헤더를 신뢰하려면, 먼저 내가 신뢰하는 프록시가 기존 값을 지우거나 정리한 뒤 새로 붙이는 구조인지 확인해야 해요.


먼저 신뢰할 프록시를 정해야 해요

클라이언트 IP를 안전하게 읽으려면 이런 질문을 해야 해요.

"우리 앱 바로 앞의 remote_addr는 신뢰하는 프록시인가요?"

flowchart TD
    A[요청 도착] --> B{remote_addr가<br/>신뢰 프록시 목록에 있나요?}
    B -->|아니오| C[forwarded 헤더를 믿지 않음<br/><small>remote_addr를 클라이언트로 사용</small>]
    B -->|예| D[헤더 체인을 오른쪽에서 왼쪽으로 확인]
    D --> E{다음 주소도<br/>신뢰 프록시인가요?}
    E -->|예| D
    E -->|아니오| F[처음 만나는 비신뢰 주소를<br/>클라이언트 후보로 사용]

이 흐름이 중요한 이유가 있어요. 공격자가 맨 왼쪽에 가짜 IP를 넣어도, 우리가 오른쪽에서부터 신뢰하는 프록시를 벗겨내는 방식으로 읽으면 위험을 줄일 수 있어요.

예를 들어 이런 값이 있다고 해볼게요.

remote_addr=10.0.4.12
X-Forwarded-For: 10.1.2.3, 203.0.113.25, 198.51.100.7

그리고 우리 신뢰 목록이 이렇다고 해볼게요.

trusted proxies:
- 10.0.0.0/8
- 198.51.100.7

그러면 읽는 감각은 이래요.

오른쪽부터 본 값 신뢰하나요? 판단
remote_addr=10.0.4.12 앱 바로 앞 프록시로 인정
198.51.100.7 신뢰 프록시로 인정
203.0.113.25 아니오 클라이언트 후보
10.1.2.3 확인하지 않음 앞쪽 값은 사용자가 위조했을 수 있음

그래서 이 예시에서는 맨 왼쪽 10.1.2.3이 아니라 203.0.113.25를 사용자 후보로 읽는 쪽이 더 안전해요.

오른쪽에서 왼쪽으로 벗겨요

X-Forwarded-For를 안전하게 읽을 때는 "맨 왼쪽이 진짜"보다 오른쪽에서부터 내가 신뢰하는 프록시를 하나씩 벗긴다고 생각하는 편이 좋아요.

X-Forwarded-Proto와 Host는 URL 판단에 영향을 줘요

forwarded 계열 헤더는 IP만 다루지 않아요.

프록시가 TLS를 앞단에서 끝내면, 앱 서버와 프록시 사이는 내부 HTTP일 수 있어요.

sequenceDiagram
    participant U as 사용자 브라우저
    participant P as 리버스 프록시
    participant A as 앱 서버

    U->>P: HTTPS 요청<br/>https://shop.example.com/login
    P->>A: HTTP 내부 요청<br/>Host: app.internal
    P->>A: X-Forwarded-Proto: https<br/>X-Forwarded-Host: shop.example.com
    A-->>P: 응답
    P-->>U: HTTPS 응답

앱이 자기 요청을 내부 HTTP로만 보면 이런 문제가 생길 수 있어요.

헤더 앱이 이 값을 쓰는 장면 잘못 읽으면
X-Forwarded-Proto: https redirect URL, secure cookie 판단 http://로 리다이렉트하거나 쿠키 보안 판단이 틀어짐
X-Forwarded-Host: shop.example.com absolute URL 생성, tenant 선택 내부 host로 링크를 만들거나 엉뚱한 tenant 선택
X-Forwarded-Port: 443 외부 포트 판단 :8080 같은 내부 포트가 노출됨
X-Forwarded-For 로그, rate limit, abuse 대응 프록시 주소만 보고 모두 같은 사용자로 봄

하지만 HostProto도 마찬가지로 신뢰 문제가 있어요. 외부 사용자가 보낸 X-Forwarded-Host를 그대로 믿고 redirect URL을 만들면, open redirect나 잘못된 링크 생성으로 이어질 수 있어요.

그래서 일반 원칙은 이렇게 잡는 게 좋아요.

  1. 앱 바로 앞 프록시가 신뢰되는지 확인해요.
  2. 프록시가 외부에서 온 forwarded 계열 헤더를 정리하는지 확인해요.
  3. 앱은 신뢰 프록시가 보장한 헤더만 사용해요.
  4. 보안 판단에는 allowlist와 별도 정책을 같이 둬요.

표준 Forwarded 헤더는 한 줄에 묶어서 표현해요

X-Forwarded-*가 널리 쓰이지만, 표준화된 헤더는 Forwarded예요.

Forwarded: for=203.0.113.25;proto=https;host=shop.example.com

프록시가 여러 개라면 쉼표로 이어질 수 있어요.

Forwarded: for=203.0.113.25;proto=https;host=shop.example.com, for=198.51.100.7

RFC 7239는 Forwarded가 프록시 과정에서 사라지거나 바뀐 정보를 전달하기 위한 선택적 요청 헤더라고 설명해요. 주요 parameter는 아래처럼 읽을 수 있어요.

Forwarded parameter X-Forwarded-*와 비교하면 의미
for= X-Forwarded-For 요청을 만든 클라이언트나 앞쪽 프록시 식별자
by= X-Forwarded-By에 가까움 프록시의 사용자 쪽 인터페이스 식별자
host= X-Forwarded-Host 프록시가 받은 원래 Host
proto= X-Forwarded-Proto 프록시가 받은 요청 scheme, 보통 http 또는 https

Forwarded의 장점은 for, proto, host 같은 값이 한 요소 안에 묶여 있어 어떤 프록시가 어떤 값을 붙였는지를 더 잘 맞춰볼 수 있다는 점이에요. 반대로 X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host는 서로 다른 헤더라서 여러 프록시가 섞일 때 값의 짝을 맞추기 어려울 수 있어요.

다만 현실에서는 여전히 X-Forwarded-*를 더 많이 만날 수 있어요. 그래서 둘 중 하나만 "정답"으로 외우기보다, 내가 쓰는 CDN, 로드 밸런서, 프레임워크가 무엇을 붙이고 무엇을 읽는지 확인해야 해요.

로그에서는 네 가지를 같이 남겨요

클라이언트 IP 문제를 나중에 디버깅하려면 한 값만 남기면 부족해요.

로그 필드 왜 남기나요?
remote_addr 앱과 직접 연결된 바로 앞 주소를 알기 위해
raw X-Forwarded-For 프록시 체인이 어떻게 전달됐는지 보기 위해
계산된 client_ip 앱이 최종적으로 누구를 클라이언트로 판단했는지 보기 위해
X-Forwarded-Proto / Host URL, redirect, secure cookie 문제를 확인하기 위해
request id 프록시 로그와 앱 로그를 같은 요청으로 묶기 위해

예를 들면 이런 식이에요.

remote_addr=10.0.4.12
xff="203.0.113.25, 198.51.100.7"
client_ip=203.0.113.25
xfp=https
xfh=shop.example.com
request_id=req_8321b

이렇게 남겨두면 나중에 rate limit이 이상하거나, 특정 사용자 차단이 잘못 걸리거나, HTTPS redirect가 꼬였을 때 훨씬 빨리 원인을 좁힐 수 있어요.

어디서 자주 문제가 생길까요?

모든 사용자가 같은 IP로 보일 때

앱이 X-Forwarded-For를 전혀 읽지 않고 remote_addr만 보면 모든 사용자가 로드 밸런서 IP로 보일 수 있어요. 그러면 rate limit, abuse 탐지, 지역 통계가 전부 이상해져요.

아무나 내부 IP인 척할 수 있을 때

반대로 앱이 X-Forwarded-For를 무조건 믿으면 사용자가 127.0.0.1이나 사내 IP를 헤더에 넣어서 내부 사용자처럼 보이려 할 수 있어요. IP 기반 접근 제어가 있다면 특히 위험해요.

HTTPS인데 앱은 HTTP라고 착각할 때

TLS가 프록시에서 끝나고 앱에는 HTTP로 들어오면, 앱은 외부 요청이 HTTPS였는지 모를 수 있어요. 이때 X-Forwarded-Proto를 안전하게 읽지 못하면 http:// redirect가 생기거나 secure cookie 설정이 흔들릴 수 있어요.

Host 기반 라우팅이 꼬일 때

멀티테넌트 서비스나 여러 도메인을 한 앱에서 받는 구조에서는 원래 Host가 중요해요. 프록시가 내부 Host로 바꿔서 보내는데 앱이 X-Forwarded-Host를 안전하게 처리하지 못하면 tenant 선택, canonical URL, OAuth callback URL이 꼬일 수 있어요.

잘못 읽기 쉬운 함정

X-Forwarded-For의 맨 왼쪽을 항상 진짜 사용자로 보기

맨 왼쪽 값은 진짜일 수도 있지만, 사용자가 직접 넣은 가짜 값일 수도 있어요. 신뢰 프록시 목록을 기준으로 오른쪽에서부터 확인해야 해요.

remote_addr를 항상 진짜 사용자로 보기

프록시 뒤의 앱에서 remote_addr는 보통 바로 앞 프록시 주소예요. 이 값만 보면 모든 사용자가 같은 주소처럼 보일 수 있어요.

프록시가 헤더를 정리해줄 거라고 가정하기

앞단이 외부에서 들어온 X-Forwarded-*를 지우고 새로 붙이는지, 기존 값을 append하는지, 아예 건드리지 않는지는 설정에 따라 달라요. 확인 없이 앱에서 믿으면 안 돼요.

X-Forwarded-Proto만 보면 보안 판단이 끝난다고 보기

proto=https도 헤더 값이에요. 신뢰 프록시가 보장한 값인지 확인해야 하고, 중요한 보안 판단은 별도 allowlist, HSTS, secure cookie 정책과 함께 봐야 해요.

표준 Forwarded만 쓰면 모든 문제가 사라진다고 보기

Forwarded는 더 구조화된 표준 헤더지만, 그래도 요청 헤더예요. 신뢰할 수 없는 경로에서 들어온 값은 여전히 믿으면 안 돼요.

자, 정리해볼까요?

오늘 우리가 배운 것

  • 프록시 뒤의 앱 서버가 직접 보는 remote_addr는 실제 사용자 IP가 아니라 바로 앞 프록시 주소일 수 있어요.
  • X-Forwarded-For는 원래 클라이언트와 중간 프록시 체인을 남기는 데 널리 쓰이지만, 비표준 계열이고 위조될 수 있어요.
  • 안전하게 읽으려면 remote_addr가 신뢰 프록시인지 확인하고, 오른쪽에서 왼쪽으로 신뢰 프록시를 벗겨가며 클라이언트 후보를 찾아야 해요.
  • X-Forwarded-Proto, X-Forwarded-Host는 redirect, secure cookie, absolute URL, tenant 선택에 영향을 줄 수 있어요.
  • 표준 Forwarded 헤더는 for, by, host, proto를 한 줄에 묶어 표현하지만, 이것도 신뢰 경계 없이 믿으면 안 돼요.
  • 로그에는 raw 헤더와 계산된 client_ip를 함께 남겨야 나중에 오해를 줄일 수 있어요.

이어서 보면 좋은 글

댓글