Handling the "chain of trust" of the end user's real IP

Background

Discourse needs to be aware of the end user’s real IP address.

However, an end user never directly connects to Discourse since there is always one or more upstream web servers (nginx running in the Discourse container) in place. Thus, we need a way to pass along that information to Discourse in a trusted manner.

The x-forwarded-for header is the solution. In this topic I will describe the specific mechanisms for properly handling that information and how we’re expecting it to be propagated.

:information_source: anytime “nginx” is referred to below, it means the nginx running inside the Discourse container, not any other nginx which may exist elsewhere.

As Seen In discourse_docker Templates

The various templates for upstream proxies (e.g. cloudflare.template.yml or fastly.template.yml) have been updated to use predictable filenames in outlets rather than relying on text substitution (which is brittle).

We intend to continue using this predictable format in the future and encourage everyone to write this customisations in this manner.

Filenames

server/real-ip-header.conf

This file contains the header that nginx will use as the source of truth, e.g.:

real_ip_header x-forwarded-for;

or as set in the Cloudflare template:

real_ip_header cf-connecting-ip;

server/real-ip-recursive.conf

If this file exists, it controls recursion in processing the “real IP” header. You’l need to enable this if there is more than one proxy adding headers in front of nginx.

real_ip_recursive on;

See “More than one proxy?” below for why you might need this.

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

This file contains directives that tell nginx which IP addresses to trust, and we can have as many files and directives as necessary.

The templates in discourse_docker create these files as necessary (e.g. set-real-ip-from-cloudflare.conf) and if you have additional needs you can add your own.

Example:

If you are running in AWS and have an ALB in front of the Discourse container, you can add an additional file by adding the following to your container definition (adapted to your environment):

run:
  - file:
      path: /etc/nginx/conf.d/outlets/server/set-real-ip-from-aws.conf
      chmod: 644
      # AWS VPC is 10.42.0.0/16, trust any connections from the ALB networks
      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;

More than one proxy?

If there is more than one proxy in front of Discourse, the simplest solution is for each proxy to perform the appropriate steps to trust its upstream and set the downstream x-forwarded-for appropriately.

Example:

Cloudflare → Load Balancer → [(Discourse container) nginx → Discourse]

The simplest option here is for the Load Balancer to process the x-forwarded-for (or cf-connecting-ip) as necessary to determine the real end user’s IP address and set its x-forwarded-for appropriately. When doing so, nginx only needs to trust the Load Balancer and doesn’t need to know or care that Cloudflare exists to work properly.

This is not always possible, thus sometimes Discourse needs to know about multiple proxies in front of it. If the Load Balancer only adds to x-forwarded-for, nginx will receive an HTTP request from a Load Balancer IP with a x-forwarded-for header that looks like:

x-forwarded-for: real_end_user_ip, cloudflare_ip

To process this, first nginx determines whether the source address of the connection (the Load Balancer IP) is trusted (see set_real_ip_from) and if so, will process the last IP of the x-forwarded-for header.

Since that IP address is cloudflare_ip, nginx then needs to do this again, checking if cloudflare_ip is trusted, and using the next IP address which is real_end_user_ip.

5 Likes

I have a /data/lc-manager-playbook/discourse/docker-templates/allow-local-proxy.template.yml with

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 {

That seems to still work today.

Is there some reason not to distribute such a template?

1 Like

This template is totally fine, it’ll work today and the foreseeable future.

Essentially, future proofing and resilience.

If the file ever changes, i.e. if we remove or change the types { string you’re looking for, it’ll just stop working.

If you change it to use a before-server or server outlet, it’ll still work even then.

2 Likes

What is the purpose of setting X-Forwarded-For at all now? It is intended/common to contain not only the client IP (as far as it is known) but the proxy chain as well.

In the commit message you write:

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

Isn’t this a bug in Discourse then, not parsing/using the common value of that header consistently (resp correctly for whichever task where it is parsed), rather then the value of the header (and Nginx as proxy commonly appending to it) being an issue?

If Discourse does not need to know the proxy chain, and the intended setup is instead to pass the true client IP only (as far as it is known), then the X-Forwarded-For looses its purpose. The X-Real-IP header is set already, matching the purpose, now rendering X-Forwarded-For redundant.

This is fair, we could just delete it and arrive at the same result (except… see below)

The application boundary really is nginx itself, not Discourse or rails. Thus, the decision on exactly which remote proxies to trust is made at the application entry point, which is nginx. It can then pass that decision on to Discourse.

By default, Rails only trusts local addresses when processing x-f-f, so we do that at a different spot where we can easily control it.

Actually, turns out Rails doesn’t even look at the x-real-ip header… the headers it looks at are

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

Somehow that’s made it all the way from

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;

We’ll have to do some digging, but for now the answer is “it works”. Which I suppose is how we first ended up like this.

Looks like maybe a gem uses it?

1 Like

Got it, so it is more about what Rails or other gems do with the headers, and less about what Discourse code does.

Interesting that Rails does not make use of X-Real-IP, which is probably less commonly used than X-Forwarded-For, but certainly better known than Forwarded and Client-IP :thinking:.

Probably X-Real-IP is then obsolete in the Nginx config. Discourse expands uses it along with X-Forwarded-For in logs, if I interpret it correctly? I couldn’t find any other explicit use/mention in the code:

The below just looked wrong in two ways when I saw it while debugging shared rate limiting and logged errors about the invalid “unix:” client IP after our Discourse upgrade (we use a UNIX socket proxy in front of the container and do rely on X-Forwarded-For).

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;

But I get the “it works” to reliably make $remote_addr the only point of truth, and real_ip_header the canonical way for admins to control the single IP Discourse/Rails gets. I see it was added to Serve Discourse from a subfolder (path prefix) instead of a subdomain already :+1:.

Really useful writeup. One thing that bites people in practice: if the trusted-proxy list is misconfigured, XFF becomes spoofable and you end up logging an attacker-supplied “real IP” — so the count-from-the-known-hop discipline you describe is doing a lot of heavy lifting. Out of curiosity, when Discourse sits behind Cloudflare, is the guidance to rely on CF-Connecting-IP, or to normalize it into XFF upstream and let this same chain handle it?