Using IIS as a Reverse Proxy

2021-Jul-06

Introduction

When your application/service is hosted in the cloud, or perhaps in a Kubernetes cluster, there will often be a load balancer or ingress providing a gateway to your application. The load balancer may convert requests, perhaps modifying paths, or converting HTTPS to HTTP.

This article, will describe using a local IIS Reverse Proxy to emulate this environment locally.

The code for this article can be found here:
github.com/.../ReverseProxy

Installation

I'm mostly writing this article as a reminder to myself of the problems I've seen getting this to work in the past.

In order to reverse proxy, you'll need to ensure that the IIS Rewrite Module and the the IIS Application Request Routing V3.0 module are both installed.

On certain machines, I had to reinstall the rewrite module as described here:
weblog.west-wind.com/.../windows-10-upgrade-and-iis-503-errors

In order to add the custom headers in the example below, you will want to unlock the following configuration setting at the server level in IIS Manager: system.webServer/rewrite/allowedServerVariables

Open IIS Manager, navigate to the server level node, and select configuration:

IIS Manager Server Level

Then locate the appropriate section:

IIS Configuration Manager

... and select the option to unlock it (so sites can modify it/add to it):

IIS Configuration Manager - Unlock Section

Example

There is an example solution on GitHub here:
github.com/.../ReverseProxy

The solution contains 2 projects:

  • Proxy: The IIS Reverse Proxy on port 8125;
  • Server: A simple ASP.NET Core site hosted in-process in Kestrel on port 5000.
This allows requests to 8125 to be reverse proxied to the application running on 5000:
Reverse Proxy Ports

The reverse proxy is configured in the web.config of the Proxy project:

        
  <system.webServer>
    <rewrite>
      <allowedServerVariables>
        <add name="HTTP_X_Forwarded_Proto" />
        <add name="HTTP_X_Forwarded_Port" />
      </allowedServerVariables>
      <rules>
        <rule name="ReverseProxyInboundRule1" stopProcessing="true">
          <match url="(.*)" />
          <action type="Rewrite" url="http://localhost:5000/{R:1}" />
          <serverVariables>
            <set name="HTTP_X_Forwarded_Proto" value="{MapProtocol:{HTTPS}}" />
            <set name="HTTP_X_Forwarded_Port" value="{SERVER_PORT}" />
          </serverVariables>
        </rule>
      </rules>
      <rewriteMaps>
        <rewriteMap name="MapProtocol">
          <add key="on" value="https" />
          <add key="off" value="http" />
        </rewriteMap>
      </rewriteMaps>
    </rewrite>
  </system.webServer>
        
    

Note, the <rule> and <action> tags are the key part to get the reverse proxy working. The <serverVariables> are to deal with headers described below.

Headers

When hosted in the cloud, the headers you receive from the load balancer might help determine what the original URL was. You might want to use this to provide external links to your site, or to add the original URL to log messages.

As an example, the AWS ELB (documented here: X-Forwarded headers) adds headers to let you know the original scheme and port that were used.

These lines in the web.config can re-create the same headers locally:

        
...
    <allowedServerVariables>
        <add name="HTTP_X_Forwarded_Proto" />
        <add name="HTTP_X_Forwarded_Port" />
    </allowedServerVariables>
    ...
        <serverVariables>
            <set name="HTTP_X_Forwarded_Proto" value="{MapProtocol:{HTTPS}}" />
            <set name="HTTP_X_Forwarded_Port" value="{SERVER_PORT}" />
        </serverVariables>
    ...
    <rewriteMaps>
        <rewriteMap name="MapProtocol">
            <add key="on" value="https" />
            <add key="off" value="http" />
        </rewriteMap>
    </rewriteMaps>
...
        
    
(Note, the server variables in the configuration are prefixed by HTTP_, and dashes are converted to underscores.)

We can use a couple of extension methods to obtain the original port and scheme that were used (and we can infer the original hostname - or make this configurable if required):

        
    public static string ProxyScheme(this HttpRequest request)
    {
        return request.Headers.ContainsKey("X-Forwarded-Proto")
            ? request.Headers["X-Forwarded-Proto"].ToString()
            : request.Scheme;
    }

    public static string ProxyHost(this HttpRequest request)
    {
        // potentially make this configurable
        var knownApplicationExternalAddress = "localhost:8125";

        // if the port is forwarded, we know it came from the external host
        return request.Headers.ContainsKey("X-Forwarded-Port")
            ? knownApplicationExternalAddress
            : request.Host.Value;
    }
        
    

We can get the URL as seen by ASP.NET Core using:

$"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}"
... and we can get the original URL using the above extension methods:
$"{context.Request.ProxyScheme()}://{context.Request.ProxyHost()}{context.Request.Path}{context.Request.QueryString}"

Now we can see the original and re-written urls in the browser:

Rewritten HTTP URL

The browser sent a request to http://localhost:8125, and this was proxied to the Kestrel process at http://localhost:5000.

Now if we set up the IIS site to have a certificate, and request the URL using HTTPS, then we can see the original scheme being passed through too:

Rewritten HTTPS URL