1. 개요

“클라이언트 IP” 는 레이트리밋, 감사 로그, 지역 판정의 기준이다. 그런데 앱이 보는 getRemoteAddr() 은 바로 앞 프록시의 주소다. 진짜 클라이언트 IP 는 헤더에 있고, 헤더는 누구나 붙일 수 있다. 세 프로젝트에서 세 번 다른 모양으로 이 문제를 밟았다. 정리하면 규칙은 하나다. 신뢰 경계를 그리고, 경계 안의 출처만 믿는다.


2. 핵심 내용

2-1. 사고: XFF 최우측은 엣지 IP 다

첫 구현이 X-Forwarded-For 의 최우측 값을 클라이언트 IP 로 썼다. “최우측이 가장 마지막에 붙인 신뢰할 수 있는 값” 이라는 어딘가의 조언을 따른 것이다. 반은 맞다. 최우측은 마지막 프록시가 붙였다. 그 마지막 프록시가 Cloudflare 면, 그 값은 Cloudflare 엣지 서버의 IP 다. 요청마다 다른 엣지를 탄다.

결과: 같은 공격자가 매 요청 다른 IP 로 보였다. IP 단위 레이트리밋은 아무것도 막지 않았다. 몇 달 동안.

2-2. XFF 의 구조

X-Forwarded-For: <클라이언트>, <프록시1>, <프록시2>

각 프록시는 자기가 받은 요청의 송신자 주소를 오른쪽에 덧붙인다. 클라이언트가 처음부터 X-Forwarded-For: 1.2.3.4 를 위조해서 보내면 그게 맨 왼쪽에 남는다. 그래서 왼쪽은 못 믿는다. 오른쪽에서 내가 아는 프록시 개수만큼 건너뛴 값이 신뢰할 수 있는 마지막 값이다.

Cloudflare 만 앞에 있으면 신뢰 홉 1. 오른쪽에서 두 번째 값이 클라이언트다. 게이트웨이도 XFF 를 붙이면 홉 2. 이 숫자가 인프라 구성과 함께 바뀌므로 설정값으로 둔다.

2-3. CF-Connecting-IP 를 1순위로

Cloudflare 는 CF-Connecting-IP 헤더에 클라이언트 IP 하나를 넣어 준다. 계산이 필요 없다. 단 조건이 있다. 요청이 실제로 Cloudflare 를 거쳤을 때만 믿을 수 있다. 오리진 IP 를 알아낸 공격자가 직접 붙어 이 헤더를 위조하면 그대로 믿게 된다.

그래서 게이트웨이에 Cloudflare 대역만 허용하는 정책을 건다. 이 정책이 있으면 CF-Connecting-IP 는 항상 Cloudflare 가 쓴 값이다. 정책이 없으면 이 헤더는 XFF 와 다를 바 없다. 헤더의 신뢰는 네트워크 정책이 만든다.

fun resolve(req: HttpServletRequest): String {
    req.getHeader("CF-Connecting-IP")?.takeIf(::isIpLiteral)?.let { return it }
    xffFromRight(req.getHeader("X-Forwarded-For"), trustedHops)?.takeIf(::isIpLiteral)?.let { return it }
    return req.remoteAddr
}

isIpLiteral 을 빠뜨리면 헤더에 "; DROP TABLE" 같은 문자열이 들어와 로그와 레이트리밋 키를 오염시킨다. 채택 전에 형식을 검사한다.

2-4. 서버 간 호출은 IP 가 하나다

Next.js 서버 액션이 백엔드를 클러스터 내부 주소로 직접 부른다. 이 경로는 Cloudflare 도 게이트웨이도 지나지 않는다. CF-Connecting-IP 도 XFF 도 없다. 백엔드가 보는 건 Next 파드 IP 하나 다.

로그인 레이트리밋이 “IP 당 분 10회” 면, 전 사용자의 로그인이 한 IP 로 합산된다. 한 사람이 비밀번호를 몇 번 틀리면 모두 잠긴다. 두 프로젝트에서 각각 겪었다.

Next 서버가 브라우저 요청의 IP 를 백엔드에 전달해야 한다. 어떻게 믿게 하나. 공유 시크릿 헤더 다.

X-Internal-Token: <공유 시크릿>
X-Real-Client-Ip: <브라우저의 CF-Connecting-IP>

백엔드는 X-Internal-Token 가 설정된 시크릿과 일치할 때만 X-Real-Client-Ip 를 믿는다. 아니면 무시하고 remoteAddr. 시크릿은 양쪽에 같은 k8s Secret 으로 주입한다. 값을 아는 사람 없이 by-construction 으로 일치시킨다.

flowchart LR
    B[브라우저] -->|CF-Connecting-IP| CF[Cloudflare] --> GW[Gateway<br/>CF 대역만] --> BE[백엔드]
    B -->|서버 액션| FE[Next 서버]
    FE -->|내부 주소<br/>X-Internal-Token + X-Real-Client-Ip| BE
    BE --> D{시크릿 일치?}
    D -->|예| A[X-Real-Client-Ip 채택]
    D -->|아니오| R[remoteAddr]

2-5. 이름을 CF-Connecting-IP 로 재사용하면 안 된다

“헤더를 새로 만들 필요 있나, Next 서버가 CF-Connecting-IP 를 그대로 전달하면 되지 않나.” 안 된다. 그 요청이 어떤 이유로든 Cloudflare 를 다시 지나면(예: 내부 주소가 아니라 공개 주소로 잘못 부른 경우), Cloudflare 는 클라이언트가 자기 헤더를 위조했다고 보고 403 을 낸다. 실제로 겪었다. 우리 헤더는 우리 이름으로.

2-6. 컴포넌트마다 신뢰 전략이 다르면

같은 백엔드 안에서 레이트리밋 필터는 CF-Connecting-IP → 내부 홉 → remoteAddr 인데, Turnstile 인터셉터는 XFF 첫 값을 본다든가, 감사 로그는 remoteAddr 만 남긴다든가 하는 식으로 컴포넌트마다 다르면, 같은 요청이 세 IP 로 기록된다. 조사할 때 맞춰 볼 수 없다.

ClientIpResolver 하나를 두고 모든 컴포넌트가 그걸 쓴다. 신뢰 전략은 한 곳에 있고, 바뀌면 한 곳만 고친다.


3. 마무리

요약

  • XFF 최우측은 마지막 프록시(엣지)의 IP 다. 회전한다. 오른쪽에서 신뢰 홉 수만큼 건너뛴다.
  • CF-Connecting-IP 는 게이트웨이가 Cloudflare 대역만 허용할 때만 믿을 수 있다. 헤더의 신뢰는 네트워크 정책이 만든다.
  • 서버 간 호출은 IP 가 하나다. 공유 시크릿 헤더로 원 IP 를 전달한다.
  • 그 헤더 이름을 CF-Connecting-IP 로 재사용하면 엣지가 403 을 낸다.
  • 채택 전 IP 리터럴 검사. 해석기는 하나.