본문으로 건너뛰기
Architecture#nestjs#nginx#Express#X-Forwarded-For#trust proxy#리버스프록시#방문자분석

프록시 두 단 뒤 NestJS, trust proxy로 실제 방문자 IP 읽기

nginx 프록시 두 단 뒤의 NestJS에서 trust proxy를 true로 두면 X-Forwarded-For 위조가 통하고, 1이면 모든 방문자가 엣지 주소로 찍힙니다. 값별 req.ip를 직접 재 보고 2로 정한 과정입니다.

Ian4 min read5

관리자 대시보드에 최근 방문 기록 표를 붙이면서, 페이지뷰마다 방문자 IP와 User-Agent를 남기기로 했습니다. 컨트롤러에서는 req.ip 한 줄이면 됩니다. 하지만 우리 API 앞에는 프록시가 두 개 있고, 아무 설정 없이 읽은 req.ip는 방문자가 아니라 바로 앞 프록시의 주소입니다.

이 글에서는 Express의 trust proxy를 몇으로 둘지 정한 기준과, 값마다 req.ip가 어떻게 달라지는지 직접 잰 결과를 정리합니다.

요청이 지나는 길

N100 셀프호스팅 글에서 다룬 것처럼, 도메인은 공개 IP를 가진 엣지 서버를 가리킵니다. HTTPS는 엣지 nginx에서 끝나고, 요청은 Tailscale을 거쳐 홈서버 nginx로, 다시 Docker 컨테이너의 NestJS API로 넘어갑니다.

방문자 요청이 엣지 nginx, Tailscale, 홈서버 nginx, Docker를 거쳐 NestJS API에 도착하는 경로

API와 TCP 연결을 맺는 상대는 언제나 홈서버 쪽입니다. 컨테이너 안에서 연결 정보를 보면 Docker 네트워크의 게이트웨이 주소(172.x.0.1)가 찍힙니다. 방문자의 주소는 연결이 아니라 헤더로만 전달됩니다.

X-Forwarded-For 순서

두 nginx는 같은 헤더 설정을 씁니다.

nginx
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

$proxy_add_x_forwarded_for는 받은 X-Forwarded-For 뒤에 자신이 본 상대 주소($remote_addr)를 붙인 값입니다. 엣지는 방문자 주소를 붙이고, 홈서버는 엣지의 Tailscale 주소를 붙입니다. 그래서 API에 도착한 헤더는 방문자, 엣지 순서입니다.

X-Real-IP는 사정이 다릅니다. 홉마다 $remote_addr로 덮어쓰기 때문에 API에는 엣지 주소만 남습니다. 프록시가 두 단 이상이면 X-Real-IP로는 방문자를 알 수 없습니다.

X-Forwarded-For가 홉마다 오른쪽에 주소가 붙고, 방문자가 보낸 위조 값은 맨 왼쪽에 남는 모습

문제는 맨 왼쪽입니다. nginx는 받은 헤더를 지우지 않고 뒤에 붙이기만 합니다. 방문자가 X-Forwarded-For: 1.2.3.4를 직접 보내면 API에 도착하는 헤더는 1.2.3.4, 방문자, 엣지가 됩니다. 맨 왼쪽 값은 누구나 써 넣을 수 있고, 믿을 수 있는 건 우리 프록시가 오른쪽에 붙인 값뿐입니다.

오른쪽부터 세기

Express는 소켓의 상대 주소를 첫 번째 홉으로 보고, 나머지는 X-Forwarded-For를 오른쪽에서 왼쪽으로 읽습니다. trust proxy에 숫자 n을 주면 앞의 n개를 우리 프록시로 보고 건너뛴 뒤, 그다음 주소를 req.ip로 씁니다. true는 모두 믿기 때문에 맨 왼쪽 값을 씁니다.

주소 목록을 오른쪽부터 읽어 Docker 게이트웨이와 엣지를 건너뛰고 세 번째 주소를 방문자로 고르는 trust proxy 2의 동작

우리 프록시는 홈서버 nginx와 엣지 nginx 두 개입니다. 그래서 2입니다.

값마다 달라지는 req.ip

API가 쓰는 Express 4.22.1로, 홈서버 nginx가 넘겨주는 것과 같은 모양의 헤더를 보내 값마다 req.ip를 확인했습니다. 주소는 문서용 대역으로 바꿨습니다(방문자 203.0.113.7, 엣지 100.101.102.103, 위조 값 1.2.3.4).

trust proxy 값별 정상 요청과 위조 요청의 req.ip 비교, 2만 두 경우 모두 방문자 주소를 고른다

  • false: 모든 요청이 바로 앞 홉의 주소입니다. 운영에서는 방문자 전원이 Docker 게이트웨이 한 명이 됩니다.
  • 1: 모든 요청이 엣지 주소 하나로 찍힙니다.
  • true: 정상 요청은 맞지만, 위조한 값을 그대로 받습니다.
  • 3: 정상 요청도 맞아 보입니다. 헤더에 주소가 셋뿐이면 전부 믿고 맨 끝을 고르기 때문입니다. 위조하면 true와 똑같이 뚫립니다.

우리는 3처럼 실제보다 큰 숫자가 가장 위험하다고 봅니다. false나 1은 대시보드에 같은 주소만 줄지어 찍혀서 바로 티가 나지만, true와 큰 숫자는 평소에 정상으로 보여서 아무도 모르고 지나갑니다.

홉 수는 환경변수로

typescript
// apps/api/src/main.ts
// edge nginx (VPS) → home nginx → here: req.ip is the address the edge saw,
// not a client-supplied X-Forwarded-For entry
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS ?? 2));

2는 코드가 아니라 인프라에 속한 숫자입니다. 앞에 CDN을 하나 더 두면 3으로, 엣지를 걷어내면 1로 바꿔야 합니다. 그래서 환경변수로 뺐습니다. 컨트롤러는 req.ip를 그대로 씁니다.

typescript
@Post('pageview')
track(@Body() dto: TrackDto, @Req() req: Request) {
  return this.analytics.track({ ...dto, ip: req.ip, userAgent: req.get('user-agent') });
}

페이지뷰는 Next.js 서버가 아니라 브라우저가 API로 직접 보냅니다('use client' 컴포넌트의 fetch). 서버 컴포넌트에서 보냈다면 모든 방문자가 web 서버 주소로 찍혔을 겁니다.

숫자 대신 주소로 믿는 방법도 있습니다. app.set('trust proxy', 'loopback, uniquelocal, 100.64.0.0/10')처럼 Docker 대역과 Tailscale 대역만 믿게 하면, 같은 테스트에서 정상·위조 요청 모두 방문자 주소가 나옵니다. 요청 경로가 여러 갈래라 홉 수가 요청마다 다르다면 이쪽이 맞습니다. 우리는 경로가 하나뿐이고 두 홉을 모두 직접 운영하므로 숫자를 택했습니다.

운영 데이터 확인

IP를 남기기 시작한 10월 16일부터 197건이 쌓였습니다. 개별 주소는 열어 보지 않고 대역만 집계했습니다.

  • Tailscale·Docker·loopback 같은 사설·프록시 대역 주소: 0건
  • 서로 다른 주소: 42개

trust proxy가 false였다면 197건이 모두 Docker 게이트웨이 하나, 1이었다면 엣지 주소 하나였을 겁니다. IP는 관리자 인증 뒤에서만 조회되고, 개인정보 처리방침의 자동 수집 항목에도 적혀 있습니다.

마치며

  • 프록시 뒤에서 req.ip는 설정 하나에 따라 방문자도, 프록시도, 위조 값도 될 수 있습니다.
  • X-Forwarded-For는 오른쪽부터 우리 프록시 수만큼만 믿습니다. 맨 왼쪽 값은 방문자가 직접 쓴 값일 수 있습니다.
  • true와 실제보다 큰 홉 수는 평소에 정상처럼 보여서 더 위험합니다.
  • 홉 수는 인프라와 함께 바뀌므로 환경변수로 둡니다.

지금은 통계에만 쓰지만, IP로 rate limit이나 차단 규칙을 만들 계획이라면 그 전에 이 숫자부터 맞춰야 한다고 생각합니다. 위조 값이 통하는 상태라면 공격자는 요청마다 주소를 바꿔서 제한을 피할 수 있습니다.

플린랩스와 함께

함께 미래를 만들 준비가 되셨나요?

기획·설계부터 구축, 운영까지 — 플린랩스가 전 과정을 함께합니다.

인천, 북위 37.53도 동경 126.73도