본문으로 건너뛰기
wooncloud

X-Forwarded-For 맨 왼쪽 IP로 접근 제어하면 헤더 한 줄에 뚫린다

프록시는 클라이언트가 보낸 X-Forwarded-For 를 지우지 않고 뒤에 덧붙이므로 맨 왼쪽 값은 요청자가 정할 수 있습니다. 오른쪽에서 신뢰 프록시 수만큼 건너뛰는 trust proxy 설정을 정리했습니다.

·5 min read·— views··

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

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

맨 왼쪽은 누가 적은 값일까

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

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

X-Forwarded-For: 203.0.113.10

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

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

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

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

오른쪽에서부터 믿기

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

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

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

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>를 붙여도 차단된다는 걸 테스트로 고정해 두면 됩니다.

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

참고