---
title: "X-Forwarded-For 맨 왼쪽 IP로 접근 제어하면 헤더 한 줄에 뚫린다"
description: "프록시는 클라이언트가 보낸 X-Forwarded-For 를 지우지 않고 뒤에 덧붙이므로 맨 왼쪽 값은 요청자가 정할 수 있습니다. 오른쪽에서 신뢰 프록시 수만큼 건너뛰는 trust proxy 설정을 정리했습니다."
date: 2026-10-11
updated: 2026-10-11T08:57:40.426Z
tags: [security, express, proxy, x-forwarded-for, nginx]
canonical: https://blog.wooncloud.com/posts/x-forwarded-for-leftmost-ip-spoofing
---

특정 IP 목록에 든 곳에서만 파일을 내려받을 수 있게 하는 기능이 있었습니다. 요청 IP는 `X-Forwarded-For`(XFF) 헤더의 맨 왼쪽 값으로 뽑았습니다. "맨 왼쪽이 원래 클라이언트"라는 흔한 설명을 그대로 따른 겁니다.

요청은 CDN, 로드밸런서, nginx를 거쳐 앱으로 들어옵니다. 여기서 문제가 생깁니다.

## 맨 왼쪽은 누가 적은 값일까

CDN, 로드밸런서, nginx는 클라이언트가 보낸 XFF를 지우지 않고 뒤에 덧붙입니다. CloudFront와 ALB의 기본 동작이고, nginx는 `$proxy_add_x_forwarded_for`가 그렇게 합니다.

그러면 요청자가 이런 헤더를 직접 보냈을 때

```text
X-Forwarded-For: 203.0.113.10
```

앱에 도착하는 헤더는 이렇게 됩니다.

```text
X-Forwarded-For: 203.0.113.10, <실제 클라이언트 IP>, <CDN edge IP>
```

맨 왼쪽은 요청자가 직접 적은 값입니다. 허용 목록 비교가 이 값으로 이뤄지면 헤더 한 줄로 제한이 사라지죠. 비교가 부분 문자열 일치(`contains`)라면 허용 IP를 아무 문자열에 섞기만 해도 통과합니다.

![XFF 맨 왼쪽 값은 요청자가 직접 써넣을 수 있어 허용 판정에 쓰면 위조당하지만, 신뢰 프록시 수만큼 오른쪽에서 건너뛴 값을 쓰면 실제 클라이언트 IP로 판정된다.](/images/posts/x-forwarded-for-leftmost-ip-spoofing/6a65a807-912b-49cd-b232-d5e40d0b3433.webp)

"엣지가 XFF를 덮어쓴다"는 전제는 설정으로 강제하지 않는 한 성립하지 않습니다.

![요청자가 적은 값이 XFF 맨 왼쪽에 그대로 남으므로 맨 왼쪽으로 판정하면 위조가 통하고, 오른쪽에서 신뢰 프록시 수만큼 건너뛴 값을 쓰면 실제 클라이언트 IP로 판정된다.](/images/posts/x-forwarded-for-leftmost-ip-spoofing/78442d21-1a54-45d0-9e44-50592e19988b.svg)

## 오른쪽에서부터 믿기

**내가 운영하는 프록시 수만큼만 오른쪽에서 건너뛰고, 그 값을 씁니다.** Express라면 `trust proxy`에 홉 수를 주고 `req.ip`를 쓰면 됩니다.

체인이 클라이언트, CDN, LB, nginx, 앱 순이면 앱 앞 신뢰 프록시는 3개입니다. `app.set("trust proxy", 3)`을 주면 `req.ip`는 오른쪽 3개를 건너뛴 값, 즉 CDN이 본 실제 클라이언트 IP가 됩니다. 클라이언트가 왼쪽에 무엇을 적어도 바뀌지 않습니다.

XFF 원문은 로그나 감사 표시용으로만 쓰고 인가 판정에는 넣지 않습니다.

```ts
import express from "express";

const app = express();
app.set("trust proxy", 3); // CDN, LB, nginx

// 나쁜 예: 클라이언트가 정한 값
const unsafeIp = (req: express.Request) =>
  String(req.headers["x-forwarded-for"] ?? "").split(",")[0].trim();

// 좋은 예: 신뢰 프록시를 건너뛴 값
const clientIp = (req: express.Request) => (req.ip ?? "").replace(/^::ffff:/, "");
```

## 이것만은 조심하기

- `trust proxy`를 `true`(전부 신뢰)로 두면 다시 맨 왼쪽을 쓰게 되어 원점입니다.
- 홉 수 방식은 모든 요청이 CDN을 거친다는 전제가 있어야 합니다. 로드밸런서에 직접 붙을 수 있으면 체인이 짧아져 위조가 다시 됩니다. 보안 그룹을 CDN 대역으로 좁히거나, CDN이 붙이는 비밀 헤더를 검증해야 합니다.

회귀를 막으려면 허용 밖 IP에서 온 요청에 `X-Forwarded-For: <허용 IP>`를 붙여도 차단된다는 걸 테스트로 고정해 두면 됩니다.

흔한 설명을 그대로 믿지 말고 내 체인에서 헤더가 어떻게 쌓이는지 한 번 찍어 보는 게 먼저인 것 같습니다.

## 참고

- https://expressjs.com/en/guide/behind-proxies.html
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
- https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorCustomOrigin.html
- https://docs.aws.amazon.com/elasticloadbalancing/latest/application/x-forwarded-headers.html
- https://nginx.org/en/docs/http/ngx_http_proxy_module.html#var_proxy_add_x_forwarded_for
