May 23, 2018

Mapping Headers in Nginx

^ Ad space to help offset hosting costs :D

Tiny note here: This video's audio quality isn't as good as usual (and I apologize for my clicky keyboard! That won't be the usual :D

When we use our applications behind some sort of proxy, we usually need to make the application aware it's behind a proxy.

This lets the application know to use the Forwarded or the X-Forwarded-* headers to know the protocol/schema (http vs https), port, and real client IP address.

For Laravel, the TrustedProxy package does this. It uses the underlying Symfony HTTP Request class and sets a proxy as "trusted". If the proxy is trusted, and has either Forwarded or X-Forwarded-* headers set, then the application uses those headers.

The Problem

The problem is that Symfony (as of Symfony 4) currently only allows those two header sets to be used.

  • The Forwarded header, which contains all the client information in the header value (client ip, protocol, port, host)
  • The X-Forwarded-* headers (-Port, -Proto, -For, -Host)

However, we aren't always lucky enough to have these two headers be available to use. Notably, AWS Cloudfront only provides the Cloudfront-Forwarded-Proto header for passing along the schema (http vs https).

Cloudfront will, however, add the X-Forwarded-For header. I'm not sure why they strip out the other X-Forwarded-* headers. You can see Cloudfront's header behavior here.

The Solution

So, in our case, our application won't correctly read the Cloudfront-Forwarded-Proto header that our web server receives.

Luckily, we can use Nginx to remap the Cloudfront header to a header of our choosing!

We'll do this while still avoiding "if" statements inside of Nginx configuration.

Our tool of choice here will be Nginx's map feature.

The first thing to note is that map is in the http context, not within the server context, so we need to configure it outside of any particular server {...} block. This may mean that it could affect other or all server's configured within Nginx.

Map

Here's what it looks like!

upstream app {
    server 127.0.0.1:8080;
}

map $http_cloudfront_forwarded_proto $cloudfront_proto {
    default "http";
    https "https";
}

server {
    listen 80 default_server;

    root /var/www/html;

    index index.html index.htm;

    server_name front.example.org;

    charset utf-8;

    location / {
        include proxy_params;
        proxy_set_header X-Forwarded-Proto $cloudfront_proto;
        proxy_pass http://app;
        proxy_redirect off;

        # Handle Web Socket connections
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Here Nginx is proxying to an application listening on http port 8080.

Outside of the server {...} block (which is inside of Nginx's http {...} block, altho that's not necessariy obvious) we set a map:

map $http_cloudfront_forwarded_proto $cloudfront_proto {
    default "http";
    https "https";
}

The variable $http_cloudfront_forwarded_proto is provided for us. Nginx takes any HTTP headers, lower-cases them, and converts dashes to underscores. They become accessible as variables starting with $http_.

So, our map function here is reading values for HTTP header Cloudfront-Forwarded-Proto via $http_cloudfront_forwarded_proto. We're telling Nginx to map another value onto a new variable that we created named $cloudfront_proto.

Inside of the map, everything on the left are possible values for the $http_cloudfront_forwarded_proto variable. If one matches, then the value on the right is given to variable $cloudfront_proto.

Using map lets us:

  1. Set a default
  2. Arbitrarily set the value of our mapped variable

In this case, we're defaulting to http when Cloudfront-Forwarded-Proto is either missing or has a value other than https.

Passing the Header

In our server configuration, we pass that header off to our application via proxy_set_header:

location / {
    include proxy_params;
    proxy_set_header X-Forwarded-Proto $cloudfront_proto;
    proxy_pass http://app;
    proxy_redirect off;
}

We create header X-Forwarded-Proto (which our application will use!) and give it the value of our mapped/created variable $cloudfront_proto.

Edge Cases

One issue with our configuration above is that it would ignore any value of X-Forwaded-Proto if it was present!

To take advantage of the case where X-Forwarded-Proto was included (and so we don't ignore it), I use this more complex mapping:

map "$http_cloudfront_forwarded_proto:$http_x_forwarded_proto" $cloudfront_proto {
    default "http";
    ":http" "http";
    ":https" "https";
    "http:" "http";
    "https:" "https";
    "https:http" "https";
    "http:https" "https";
    "https:https" "https";
}

Some things to note:

  1. We can create a string from multiple variables to map from. In this case, I combine X-Forwaded-Proto and Cloudfront-Forwarded-Proto, concatenating them together with a : in betwen.
  2. I then test possible combinations of the resulting string, with a preference to use https in the cases where both headers are set but conflict with eachother.

Since our default is http, we can actually get rid of any of the combinations that result in the value http being used:

map "$http_cloudfront_forwarded_proto:$http_x_forwarded_proto" $cloudfront_proto {
    default "http";
    ":https" "https";
    "https:" "https";
    "https:http" "https";
    "http:https" "https";
    "https:https" "https";
}

It's still a bit ugly, but it works!

This can be be simplified greatly with the use of a regular expression:

map "$http_cloudfront_forwarded_proto:$http_x_forwarded_proto" $cloudfront_proto {
    default "http";
    "~https" "https";
}

That's much cleaner and provides the same functionality. Any instance of "https" in the header value will set the X-Forwarded-Proto header value to https.

FastCGI & PHP

In my example above, we had Nginx proxying over HTTP to our application code. What if we're using FastCGI instead (for example, to proxy to a PHP application using PHP-FPM)?

FastCGI in Nginx has no equivalent of proxy_set_header, since it doesn't actually send an HTTP request to PHP. Instead, Nginx (following FastCGI spec and PHP convention) converts headers to proxy_params, which get sent to PHP-FPM.

For PHP, we can set a new proxy param that would get read as an HTTP header by PHP. These all start with HTTP_ within PHP's global $_SERVER variable.

location ~ \.php$ {
    include fastcgi_params;
    fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
    fastcgi_param HTTP_X_FORWARDED_PROTO $cloudfront_proto;
}

Here we used fastcgi_param to include what PHP will read as the HTTP header X-Forwarded-Proto!

We set X-Forwarded-Proto to the value of our mapped variable $cloudfront_proto via this line in our configuration:

fastcgi_param HTTP_X_FORWARDED_PROTO $cloudfront_proto;

Result

Now our application can read the X-Forwared-Proto header that it expects to find! We can use this technique to map any custom/unexpected headers to ones that are more commonly used/expected in our applications this way.

All Topics