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.

4 Likes