최종 사용자의 실제 IP에 대한 "신뢰 체인" 처리

배경

Discourse는 최종 사용자의 실제 IP 주소를 인식해야 합니다.

하지만 최종 사용자는 항상 하나 이상의 상위 웹 서버(Discourse 컨테이너에서 실행되는 nginx)가 중간에 위치해 있기 때문에 Discourse에 직접 연결하지 않습니다. 따라서 이 정보를 Discourse에 신뢰할 수 있는 방식으로 전달하는 방법이 필요합니다.

x-forwarded-for 헤더가 바로 그 해결책입니다. 이 토픽에서는 해당 정보를 올바르게 처리하는 구체적인 메커니즘과 이를 전파하는 방법에 대해 설명하겠습니다.

:information_source: 아래에서 "nginx"라고 언급될 경우, 다른 곳에 존재할 수 있는 nginx가 아니라 Discourse 컨테이너 내부에서 실행되는 nginx를 의미합니다.

discourse_docker 템플릿에서 확인하기

상위 프록시를 위한 다양한 템플릿(예: cloudflare.template.yml 또는 fastly.template.yml)은 텍스트 치환(취약할 수 있음)에 의존하지 않고 아웃렛(outlets)에서 예측 가능한 파일 이름을 사용하도록 업데이트되었습니다.

우리는 향후에도 이 예측 가능한 형식을 계속 사용할 계획이며, 모든 사용자가 이러한 방식으로 커스터마이징을 작성하도록 권장합니다.

파일 이름

server/real-ip-header.conf

이 파일에는 nginx가 신뢰할 수 있는 출처(source of truth)로 사용할 헤더가 포함됩니다. 예시:

real_ip_header x-forwarded-for;

또는 Cloudflare 템플릿에 설정된 경우:

real_ip_header cf-connecting-ip;

server/real-ip-recursive.conf

이 파일이 존재하면 “실제 IP” 헤더 처리 시 재귀(recursion)를 제어합니다. nginx 앞에 헤더를 추가하는 프록시가 하나 이상 있는 경우 이 설정을 활성화해야 합니다.

real_ip_recursive on;

이것이 왜 필요한지 자세한 내용은 아래 “하나 이상의 프록시가 있는 경우?” 섹션을 참고하세요.

server/set-real-ip-from-ENVIRONMENT.conf

이 파일에는 nginx가 신뢰할 IP 주소를 지정하는 지시문(directives)이 포함되며, 필요에 따라 파일과 지시문을 여러 개 가질 수 있습니다.

discourse_docker의 템플릿은 필요에 따라 이러한 파일을 생성합니다(예: set-real-ip-from-cloudflare.conf). 추가적인 요구 사항이 있다면 직접 파일을 추가할 수 있습니다.

예시:

AWS에서 실행 중이고 Discourse 컨테이너 앞에 ALB(Application Load Balancer)가 있는 경우, 컨테이너 정의에 다음을 추가하여 추가 파일을 만들 수 있습니다(환경에 맞게 수정):

run:
  - file:
      path: /etc/nginx/conf.d/outlets/server/set-real-ip-from-aws.conf
      chmod: 644
      # AWS VPC는 10.42.0.0/16이며, ALB 네트워크에서 오는 모든 연결을 신뢰
      contents: |
        set_real_ip_from 10.42.66.0/24;
        set_real_ip_from 10.42.67.0/24;
  - file:
      path: /etc/nginx/conf.d/outlets/server/real-ip-header.conf
      chmod: 644
      contents: |
        real_ip_header x-forwarded-for;

하나 이상의 프록시가 있는 경우?

Discourse 앞에 프록시가 하나 이상 있는 경우, 가장 간단한 해결 방법은 각 프록시가 자신의 상위(upstream)를 신뢰하고 하위(downstream) x-forwarded-for를 적절히 설정하도록 필요한 단계를 수행하는 것입니다.

예시:

Cloudflare → Load Balancer → [(Discourse 컨테이너) nginx → Discourse]

여기서 가장 간단한 옵션은 Load Balancer가 x-forwarded-for(또는 cf-connecting-ip)를 처리하여 최종 사용자의 실제 IP 주소를 결정하고, 자신의 x-forwarded-for를 적절히 설정하는 것입니다. 이렇게 하면 nginx는 Load Balancer만 신뢰하면 되며, Cloudflare이 존재한다는 사실을 알거나 신경 쓸 필요가 없이 정상적으로 작동합니다.

항상 이것이 가능한 것은 아니므로, 때로는 Discourse가 앞에 있는 여러 프록시에 대해 알아야 할 수 있습니다. Load Balancer가 x-forwarded-for에 단순히 값을 추가하는 경우, nginx는 Load Balancer IP에서 HTTP 요청을 수신하며, x-forwarded-for 헤더는 다음과 같이 보입니다:

x-forwarded-for: real_end_user_ip, cloudflare_ip

이를 처리하려면, 먼저 nginx가 연결의 소스 주소(Load Balancer IP)가 신뢰할 수 있는지(set_real_ip_from 참조)를 결정하고, 신뢰할 수 있다면 x-forwarded-for 헤더의 마지막 IP를 처리합니다.

해당 IP 주소가 cloudflare_ip이므로, nginx는 이 과정을 다시 수행하여 cloudflare_ip가 신뢰할 수 있는지 확인하고, 다음 IP 주소인 real_end_user_ip를 사용합니다.

7개의 좋아요

저는 /data/lc-manager-playbook/discourse/docker-templates/allow-local-proxy.template.yml 파일을 가지고 있습니다.

after_bundle_exec:
  - replace:
    filename: /etc/nginx/conf.d/discourse.conf
    from: "types {"
    to: |
      set_real_ip_from 192.168.1.0/24;
      set_real_ip_from 192.168.11.0/24;
      set_real_ip_from 172.16.0.0/12;
      set_real_ip_from 10.0.0.0/8;
      real_ip_recursive on;
      real_ip_header X-Forwarded-For;
      types {

이것은 현재에도 여전히 작동하는 것 같습니다.

이러한 템플릿을 배포하지 않는 이유가 있을까요?

2개의 좋아요

이 템플릿은 완전히 괜찮습니다. 오늘도, 그리고 가까운 미래에도 잘 작동할 것입니다.

결국 미래 지향적 설계와 회복 탄력성(resilience) 문제입니다.

만약 파일이 변경된다면, 즉 찾고 있는 types { 문자열이 제거되거나 수정되면 더 이상 작동하지 않게 됩니다.

before-server 또는 server 아웃렛을 사용하도록 변경해 두면, 그때에도 계속 작동할 것입니다.

2개의 좋아요

이제 X-Forwarded-For 헤더를 설정하는 목적은 무엇인가요? 이 헤더는 클라이언트 IP(알려진 범위 내에서)뿐만 아니라 프록시 체인까지 포함하는 것이 의도적이고 일반적인 용도입니다.

커밋 메시지에서 다음과 같이 작성되어 있습니다:

and might end using `client_ip` or `proxyA_ip` depending on codepath.

이는 헤더의 값(그리고 프록시로 사용되는 Nginx가 일반적으로 해당 헤더에 값을 추가하는 행위)의 문제라기보다는, Discourse가 해당 헤더의 일반적인 값을 일관성 있게(또는 해당 헤더를 파싱하는 작업에 맞게) 올바르게 파싱하고 사용하지 않는 버그가 아닌가요?

Discourse가 프록시 체인을 알 필요가 없고, 의도된 구성은 대신 클라이언트 IP(알려진 범위 내에서)만 전달하는 것이라면, X-Forwarded-For는 그 목적을 잃게 됩니다. 이미 X-Real-IP 헤더가 설정되어 있으며 이는 해당 목적에 부합하므로, 이제 X-Forwarded-For는 중복이 됩니다.

정당한 지적입니다. 해당 헤더를 단순히 삭제해도 동일한 결과를 얻을 수 있습니다(다만… 아래를 참고하세요).

애플리케이션 경계는 실제로 Discourse나 Rails가 아니라 nginx 자체입니다. 따라서 정확히 어떤 원격 프록시를 신뢰할지에 대한 결정은 애플리케이션의 진입점인 nginx에서 내려집니다. 이후 nginx는 그 결정을 Discourse에 전달할 수 있습니다.

기본적으로 Rails는 x-f-f(x-forwarded-for)를 처리할 때 로컬 주소만 신뢰합니다. 따라서 우리는 이를 쉽게 제어할 수 있는 다른 지점에서 처리하고 있습니다.

사실 Rails는 x-real-ip 헤더를 확인조차 하지 않는 것으로 밝혀졌습니다… Rails가 확인하는 헤더는 다음과 같습니다.

  • forwarded
  • client-ip
  • x-forwarded-for

어떻게 된 일인지, 이 설정은 처음부터 이렇게 되어 있었습니다…

commit 21b562852885f883be43032e03c709241e8e6d4f (tag: v0.8.0)
Author: Robin Ward
Date:   Tue Feb 5 14:16:51 2013 -0500

    Initial release of Discourse

diff --git a/config/nginx.sample.conf b/config/nginx.sample.conf
new file mode 100644
index 00000000..62fabf4a
--- /dev/null
+++ b/config/nginx.sample.conf
…
+    proxy_set_header  X-Real-IP  $remote_addr;

조사를 더 해야겠지만, 당장은 "동작한다"가 답입니다. 아마도 처음에 이렇게 된 것도 그 때문이었을 것입니다.

어떤 gem이 이를 사용하는 것 같습니다?

1개의 좋아요

알겠습니다. 즉, 헤더를 어떻게 처리하는가는 Rails나 다른 gem들이 하는 일과 더 관련이 있고, Discourse 코드가 하는 일과는 덜 관련이 있다는 거군요.

Rails가 X-Real-IP를 사용하지 않는다는 점이 흥미롭네요. X-Forwarded-For보다는 덜 일반적으로 사용되지만, ForwardedClient-IP보다는 확실히 더 잘 알려진 헤더이거든요. :thinking:

아마 X-Real-IP는 Nginx 설정에서 이제 불필요한 것이겠죠. 제 이해가 맞다면, Discourse는 로그에서 X-Forwarded-For와 함께 X-Real-IP를 확장하여 사용하는 것 같습니다. 코드에서 다른 명시적인 사용처나 언급을 찾을 수 없었습니다:

아래 설정은 공유 레이트 리미팅을 디버깅하던 중, Discourse 업그레이드 후 잘못된 “unix:” 클라이언트 IP에 대한 오류가 기록되는 것을 보고 두 가지 측면에서 잘못되었다고 느꼈습니다. (우리는 컨테이너 앞에 UNIX 소켓 프록시를 사용하고 있으며 X-Forwarded-For에 의존하고 있습니다.)

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
```하지만, `$remote_addr`를 유일한 진실의 원천으로 만들고 `real_ip_header`를 관리자가 Discourse/Rails가 가져가는 단일 IP를 제어하는 표준적인 방법으로 사용하는 "동작한다"는 논리는 이해합니다. 이미 https://meta.discourse.org/t/serve-discourse-from-a-subfolder-path-prefix-instead-of-a-subdomain/30507 에 추가되었던 것을 확인했습니다. :+1:.

정말 유용한 글이었습니다. 실무에서 사람들이 자주 겪는 문제가 하나 있습니다. 신뢰할 수 있는 프록시(trusted-proxy) 목록이 잘못 설정되면 XFF가 스푸핑(spoofing)을 당할 수 있고, 결국 공격자가 제공한 "실제 IP"가 로깅되게 됩니다. 따라서 글에서 설명하신 알려진 홉(known-hop)부터 카운트하는 규율이 실제로는 매우 중요한 역할을 하고 있습니다. 궁금한 점이 있어서 여쭙는데, Discourse가 Cloudflare 뒤에 있는 경우, CF-Connecting-IP 헤더에 의존하는 것이 권장 사항인가요, 아니면 이를 XFF로 정규화하여 동일한 체인이 처리하도록 하는 것이 맞나요?

1개의 좋아요

네, 저희 Cloudflare 템플릿이 그렇게 동작합니다.