[Cloudflare] X-Forwarded-For를 믿었다가 조회수가 뚫린 이야기 — 프록시 뒤에서 방문자 IP를 신뢰하는 법
개인 블로그에 조회수와 좋아요 기능을 붙였다. 방문자를 구분해서 "한 사람이 하루에 한 번만" 조회수가 오르게 하려면 방문자를 식별할 무언가가 필요한데, 로그인 없는 익명 방문자에게 쓸 수 있는 건 결국 IP뿐이다. 원본 IP를 저장하면 개인정보라 곤란하니, IP를 해시해서 (글, IP해시, 날짜) 조합으로 하루 한 번만 집계하도록 만들었다. 겉보기엔 멀쩡했다. 그런데 배포를 마치고 스스로 공격해 보다가, 이 방문자 식별이 요청 헤더 하나로 통째로 뚫린다는 걸 발견했다.
문제: 클라이언트가 보낸 IP를 그대로 믿고 있었다
리버스 프록시(여기서는 Cloudflare) 뒤에 앱을 두면, 앱이 보는 TCP 소켓의 출발지 IP는 프록시의 IP다. 진짜 방문자 IP는 프록시가 헤더에 실어 전달한다. 그래서 관례적으로 X-Forwarded-For 헤더의 첫 항목을 방문자 IP로 읽는다. 내 코드도 그랬다.
export function getClientIp(headers: Headers): string {
const forwarded = headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
const realIp = headers.get("x-real-ip");
if (realIp) return realIp.trim();
return "unknown";
}
문제는 X-Forwarded-For가 클라이언트가 임의로 붙여 보낼 수 있는 평범한 요청 헤더라는 점이다. 프록시 체인은 각 홉이 자기가 관찰한 IP를 헤더 뒤에 덧붙이는 구조라, 클라이언트가 미리 넣어둔 값은 체인의 맨 앞에 남는다. 그리고 내 코드는 하필 그 맨 앞을 신뢰했다. 즉 방문자가 헤더 값만 매 요청 바꿔주면 얼마든지 다른 사람인 척할 수 있다.
재현은 curl 한 줄이면 된다. 헤더 값을 바꿔가며 같은 글에 조회 요청을 쏘면:
curl -X POST -H 'X-Forwarded-For: <아무 주소나 A>' https://example.com/api/posts/1/view
# {"views":1,"counted":true}
curl -X POST -H 'X-Forwarded-For: <아무 주소나 B>' https://example.com/api/posts/1/view
# {"views":2,"counted":true} ← 다른 방문자로 집계됨
counted:true가 계속 나온다. 헤더를 회전시키는 스크립트 하나로 조회수도 좋아요도 무한히 부풀릴 수 있다는 뜻이다. 게다가 같은 getClientIp를 로그인 실패 횟수 제한(레이트리밋)도 쓰고 있었으니, 브루트포스 방어까지 같은 방식으로 우회됐다. 함수 하나의 신뢰 실수가 세 기능을 동시에 뚫은 셈이다.
해결: 프록시가 붙인 헤더만 믿고, 없으면 닫는다
Cloudflare 뒤에 있다면 답은 정해져 있다. Cloudflare 엣지는 진짜 방문자 IP를 CF-Connecting-IP 헤더에 넣어 전달하고, 클라이언트가 이 헤더를 위조해 보내도 엣지에서 자기 값으로 덮어쓴다. 그러니 클라이언트가 건드릴 수 있는 X-Forwarded-For 대신 이걸 믿어야 한다.
한 가지 더. CF-Connecting-IP가 없는 경우의 처리다. 로컬 개발 환경엔 Cloudflare가 없으니 그때는 기존 X-Forwarded-For 폴백이 필요하다. 하지만 프로덕션에서 이 헤더가 없다면? 그건 뭔가 잘못된 상황이고, 여기서 위조 가능한 헤더로 되돌아가면 취약점이 그대로 되살아난다. 그래서 프로덕션에서는 폴백하지 않고 unknown으로 fail-closed 시켰다. 방문자 구분이 뭉개지더라도(가용성 손해) 위조를 허용하는 것보다 낫다는 판단이다. 프로덕션 여부는 NODE_ENV로 가른다.
export function getClientIp(headers: Headers): string {
// 1. Cloudflare 엣지가 세팅하는, 클라이언트가 위조 못 하는 값
const cf = headers.get("cf-connecting-ip");
if (cf && cf.trim()) return cf.trim();
// 2. 프로덕션에서 이 헤더가 없으면 fail-closed
// (위조 가능한 XFF로 되돌아가지 않는다)
if (process.env.NODE_ENV === "production") return "unknown";
// 3. 개발/테스트에는 Cloudflare가 없으니 기존 폴백 유지
const forwarded = headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
const realIp = headers.get("x-real-ip");
if (realIp) return realIp.trim();
return "unknown";
}
함수 하나만 고치면 이 함수를 공유하는 세 호출부(조회수·좋아요·로그인 레이트리밋)가 한꺼번에 교정된다는 점이 이 구조의 장점이었다.
검증하다 만난 반전: 위조 헤더는 앱까지 오지도 않았다
보안 수정에서 가장 위험한 건 "고쳤다고 믿는 것"이다. 그래서 배포 후 프로덕션에 직접 확인했다. 먼저 터널이 정말 CF-Connecting-IP를 앱까지 전달하는지 임시 로그로 찍어 보니, 완전한 IPv4 값이 잘 도착했다. 그다음 아까의 X-Forwarded-For 스푸핑을 다시 시도하니, 이번엔 헤더를 바꿔도 두 번째 요청이 counted:false—같은 실제 IP로 묶여 하루 중복으로 처리됐다. 스푸핑이 막힌 것이다.
마지막으로 새 신뢰 앵커 자체를 공격해 봤다. 클라이언트가 CF-Connecting-IP를 직접 위조해 보내면 어떻게 될까? 결과가 뜻밖이었다.
curl -X POST -H 'CF-Connecting-IP: <위조 주소>' https://example.com/api/posts/1/view
# error code: 1000
앱이 이 헤더를 무시하는 정도가 아니라, Cloudflare 엣지가 요청 자체를 거부했다(같은 URL을 헤더 없이 보내면 정상 200). 클라이언트는 이 헤더를 애초에 통과시킬 수조차 없다는 뜻이다. 앱 레벨의 방어 위에 엣지 레벨의 방어가 한 겹 더 있었던 셈이다.
교훈
- 프록시 뒤에서 클라이언트가 보낸 헤더로 신원을 판단하지 마라.
X-Forwarded-For는 클라이언트가 쓰는 값이다. 신뢰 홉이 붙인 값(CF-Connecting-IP, 또는 신뢰 프록시 수를 아는 경우 XFF의 오른쪽에서 N번째)만 믿어야 한다. 맨 앞 항목은 특히 위험하다. - 애매하면 닫아라(fail-closed). 신뢰할 소스가 없을 때 위조 가능한 값으로 폴백하는 건 취약점을 남기는 것이다. 가용성을 조금 잃더라도 안전한 기본값이 낫다.
- 새로 도입한 신뢰 앵커 자체를 공격해 보라. "XFF를 CF-Connecting-IP로 바꿨다"에서 멈추면, 그 새 헤더가 위조 가능한지 검증하지 않은 것이다. 취약점을 옮기기만 했을 수도 있다. 다행히 이 경우엔 엣지가 막아 줬지만, 그건 확인해서 안 것이지 가정할 게 아니었다.
- 고쳤다는 건 주장이고, 증명은 별개다. 단위 테스트로 로직을 굳히고, 프로덕션에서 실제 공격을 재현해 막히는 걸 눈으로 봐야 끝난 것이다.
댓글
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.