RSS듀오랩스
보안

X-Forwarded-For 신뢰 경계: 덧붙이는 프록시와 덮어쓰는 플랫폼

작성자
듀오랩스 대표·10분 읽기

요청 제한을 분당 30회로 걸어 두었습니다. 접근 로그에는 같은 초에 수백 건이 찍혀 있는데 429는 한 건도 없습니다.

제한이 꺼진 것이 아닙니다. 버킷이 매번 새로 만들어진 것입니다. 카운터의 키가 요청마다 달랐고, 그 키를 정한 것은 서버가 아니라 요청을 보낸 쪽이었습니다.

첫 값이 클라이언트 주소라는 오해

X-Forwarded-For 를 읽는 코드는 대개 이렇게 생겼습니다.

const ip = req.headers["x-forwarded-for"]?.split(",")[0].trim();

첫 값을 쓰는 이유는 분명합니다. 이 헤더는 왼쪽이 원래 클라이언트이고 오른쪽으로 갈수록 서버에 가까운 프록시라고 알려져 있으니, 맨 왼쪽이 진짜 클라이언트라는 설명입니다. 이 설명은 헤더가 정직하게 채워졌을 때만 맞습니다.

헤더는 그냥 헤더입니다. 클라이언트가 처음부터 X-Forwarded-For: 203.0.113.9 를 붙여 보내면, 중간 프록시는 그 값을 지우지 않고 뒤에 자기가 본 주소를 덧붙입니다. 서버에 도착한 값은 203.0.113.9, 198.51.100.4 가 되고, 첫 값은 요청자가 직접 적은 문자열입니다.

첫 값은 가장 신뢰할 수 없는 자리입니다. 그 자리를 식별자로 쓰면 요청자가 자기 버킷 이름을 고르는 셈이 됩니다.

명세가 정하지 않은 것

X-Forwarded-For 는 표준이 아닙니다. 널리 쓰이는 사실상의 관행이고, IETF가 이 자리를 표준화하면서 내놓은 것이 RFC 7239 의 Forwarded 헤더입니다. for=, by=, proto=, host= 를 한 헤더에 담고 각 홉을 쉼표로 잇는 형식입니다.

이름과 형식은 표준이 되었지만, 누가 이 값을 믿어도 되는지는 명세가 정해 주지 않습니다. RFC 7239 의 보안 고려 절은 이 헤더가 위조될 수 있으며 검증 없이 신뢰해서는 안 된다고 명시합니다. 명세가 할 수 있는 말은 거기까지입니다. 신뢰 여부는 배포 구조에 달려 있고, 배포 구조는 명세가 모릅니다.

그래서 이 문제는 라이브러리를 바꾼다고 풀리지 않습니다. 코드가 아니라 이 요청이 어디를 거쳐 왔는가를 아는 쪽이 답을 갖고 있습니다.

덧붙이는 프록시와 덮어쓰는 플랫폼

같은 헤더를 두고 앞단이 하는 일이 두 가지로 갈립니다. 이 차이를 모르면 같은 코드가 한 곳에서는 맞고 다른 곳에서는 틀립니다.

덧붙이는 앞단 덮어쓰는 앞단
하는 일 받은 값 뒤에 접속 주소를 이어 붙임 받은 값을 버리고 자기가 본 주소로 설정
대표 예 일반적인 리버스 프록시 기본 동작 요청이 자기 망을 통해서만 들어오는 관리형 플랫폼
클라이언트가 보낸 값 첫 값으로 살아남음 사라짐
읽어야 할 자리 뒤에서부터 (신뢰 홉 수만큼) 첫 값

nginx 의 realip 모듈set_real_ip_from 으로 신뢰할 대역을 적게 하고, 그 대역에서 온 요청에 한해 주소를 교체합니다. Express 의 trust proxy 설정은 숫자를 받는데, 그 숫자가 곧 뒤에서부터 몇 개를 건너뛸 것인가입니다. 두 기능 모두 "이 헤더를 믿어라"가 아니라 "어디까지가 우리 것인가"를 선언하게 만듭니다. 설계가 올바른 방향을 가리키고 있는 셈입니다.

홉 수를 세는 방식의 한계

뒤에서 N번째를 읽는 방식은 앞단의 개수가 고정일 때만 맞습니다. CDN을 하나 끼우거나, 관리 트래픽만 다른 경로로 들여보내거나, 내부망에서 오리진에 직접 닿는 경로가 하나 열리면 그 숫자가 달라집니다.

문제는 숫자가 달라졌을 때 아무 일도 일어나지 않는다는 점입니다. 파싱은 성공하고, 값은 IP 모양이고, 로그에는 그럴듯한 주소가 남습니다. 틀렸다는 신호가 없습니다. 인증이라면 실패로 드러났을 일이 식별에서는 조용히 지나갑니다.

저는 이것이 이 문제의 본질이라고 봅니다. 위조가 어려워서가 아니라 위조에 성공했는지를 서버가 알 방법이 없어서 위험합니다.

서명으로 출처를 증명하는 방법

값을 검사하는 대신 누가 그 값을 적었는지를 검사하면 홉 수 문제가 사라집니다.

앞단이 두 개의 헤더를 함께 넣습니다. 하나는 실제 접속 주소이고, 다른 하나는 앞단만 아는 공유 비밀입니다. 애플리케이션은 비밀이 맞을 때만 주소를 읽습니다.

function clientIp(headers) {
  const expected = process.env.EDGE_AUTH_TOKEN;
  if (!expected) return "unknown";

  const presented = headers.get("x-edge-auth");
  if (!presented || !timingSafeCompare(presented, expected)) return "unknown";

  const ip = headers.get("x-edge-client-ip")?.trim();
  return ip && looksLikeIp(ip) ? ip : "unknown";
}

이 방식의 장점은 앞단 구조가 바뀌어도 판단이 흔들리지 않는다는 것입니다. 홉이 몇 개든, 경로가 몇 개든, 비밀을 아는 것은 앞단뿐입니다. 비밀을 모르는 요청은 오리진에 직접 닿았다는 뜻이고, 그때는 헤더를 믿지 않으면 됩니다.

비교는 상수 시간으로 합니다. 비밀을 한 바이트씩 맞혀 가는 공격을 막기 위해서인데, 이 경로가 인증만큼 민감하지는 않더라도 비용이 거의 들지 않으므로 기본으로 두는 편이 낫습니다.

설정이 없을 때 무엇으로 접히는가

여기가 실제로 갈리는 지점입니다. 비밀이 아직 설정되지 않았을 때 어떻게 할 것인가.

흔한 선택은 예전 방식으로 되돌아가는 것입니다. EDGE_AUTH_TOKEN 이 없으면 X-Forwarded-For 를 쓰고, 그것도 없으면 X-Real-IP 를 쓰는 사슬입니다. 편리하지만 이 사슬이 곧 우회 경로입니다. 앞의 것을 넣을 수 없는 요청자는 뒤의 것을 넣습니다. 방어가 가장 약한 갈래의 강도로 수렴합니다.

다른 선택은 식별을 포기하는 것입니다. 증명되지 않으면 모두 같은 키 하나로 셉니다.

if (!expected) return "unknown";

개별 제한이 전체 합계 제한으로 바뀝니다. 언뜻 후퇴처럼 보이지만 방향은 반대입니다. 느슨해지는 것이 아니라 빡빡해집니다. 그리고 우회할 방법이 없습니다. 키를 정하는 주체가 요청자에서 서버로 넘어왔기 때문입니다.

트래픽이 많은 공개 엔드포인트에서는 전체 합계 제한이 정상 사용자를 막을 수 있습니다. 그 경우 답은 사슬을 되살리는 것이 아니라 비밀을 설정하는 것입니다. 저라면 설정이 빠진 상태를 배포 단계에서 경고로 남기겠습니다. 조용히 지나가면 과도기가 영구가 됩니다.

앞단이 사라지면 뒤집히는 벤더 헤더

이 문제에서 가장 다루기 어려운 것은 코드가 아니라 전제가 낡는 일입니다.

CF-Connecting-IPTrue-Client-IP 같은 벤더 헤더를 읽는 코드를 생각해 보겠습니다. 해당 CDN을 앞에 두고 있는 동안에는 올바른 코드입니다. 그 CDN을 걷어내면 그 헤더를 넣는 주체가 사라지고, 넣을 수 있는 것은 요청자뿐이 됩니다. 코드는 한 글자도 바뀌지 않았는데 신뢰할 수 있는 값에서 신뢰할 수 없는 값으로 뒤집힙니다.

이런 변경은 인프라 쪽 작업으로 처리되고 애플리케이션 저장소에는 흔적이 남지 않습니다. 주석에 "우리는 이 CDN 뒤에 있습니다"라고 적어 두어도 그 주석을 다시 읽을 계기가 없습니다.

그래서 제가 권하는 것은 신뢰 근거를 검증 가능한 형태로 두는 것입니다. 서명을 쓰면 전제가 코드 안에서 실행되므로, 앞단이 사라지면 비밀 검증이 실패하고 식별이 unknown 으로 접힙니다. 주석은 낡지만 검증은 낡지 않습니다.

배포된 앱에서 확인할 것 넷

배포된 애플리케이션을 점검한다면 이 순서로 보겠습니다.

첫째, 요청자 식별이 한 곳에서 이뤄지는지 봅니다. 두 군데에 있으면 한쪽만 고쳐집니다.

둘째, 그 함수가 헤더 사슬을 타는지 봅니다. ??|| 로 이어진 헤더 목록은 대부분 우회 경로입니다.

셋째, 앞단이 그 헤더를 덧붙이는지 덮어쓰는지 확인합니다. 문서가 아니라 실제 설정을 봅니다. 확인했다면 그 사실을 코드 옆에 남깁니다.

넷째, 오리진에 직접 닿는 경로가 있는지 봅니다. 내부망에서 포트로 접근할 수 있다면 앞단을 거치지 않는 요청이 가능하고, 그 요청은 모든 헤더를 자유롭게 정할 수 있습니다.

여기까지가 확실한 부분

헤더가 위조 가능하다는 것, 그리고 덧붙이는 앞단에서 첫 값이 요청자의 주장이라는 것은 구조적인 사실입니다.

반면 특정 플랫폼이 이 헤더를 어떻게 다루는지는 그 플랫폼의 동작이고 바뀔 수 있습니다. 문서에 적혀 있더라도 자기 환경에서 한 번 확인하는 편이 낫습니다. 확인 방법은 간단합니다. 제한이 걸린 엔드포인트에 위조 헤더 값을 바꿔 가며 한도를 넘겨 보내고, 차단이 걸리는지 보면 됩니다. 걸리면 앞단이 덮어쓰고 있는 것이고, 안 걸리면 요청자가 버킷을 고르고 있는 것입니다.

그리고 이 글은 식별에 대한 것이지 인증에 대한 것이 아닙니다. 서명된 헤더로 얻은 IP도 여전히 IP일 뿐이며, 권한 판단의 근거로 쓰기에는 부족합니다. 제한과 감사 기록의 키로는 충분하고, 그 이상은 아닙니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.