Dissection Photo by Shane Aldendorff on Unsplash

Recently I set out to configure a service of ours to run behind a reverse proxy with TLS termination.

With Phoenix being the great framework it is, I assumed this would be a breeze. But it turned out that reality had different plans for me.

In this post I’ll tell you a story on all the different bits of documentation I rummaged through, the things I learned from reading the source code of Plug.SSL, the solution I arrived at, and the proposal I submitted to plug.

What we want to achieve

In a nutshell we want to be able to:

  1. recognize X-Forwarded-* headers1 from the proxy
  2. set the HSTS header2 for HTTPS requests
  3. permanently redirect HTTP requests to use HTTPS
  4. exclude some requests from the redirect (and as such the HSTS header)

Phoenix and SSL

When looking up information on SSL and Phoenix, you’ll most likely come across this guide on the Phoenix website.

In a nutshell the guide mostly talks about how to configure your application to serve requests over SSL. I won’t go into the details of this, as the guide covers that perfectly already.

What I want to draw your attention to instead is a little piece further below:

Forcing requests to use SSL:

In many cases, you’ll want to force all incoming requests to use SSL by redirecting http to https. This can be accomplished by setting the :force_ssl option in your endpoint. It expects a list of options which are forwarded to Plug.SSL. By default it sets the “strict-transport-security” header in https requests, forcing browsers to always use https. If an unsafe request (http) is sent, it redirects to the https version using the :host specified in the :url configuration. To dynamically redirect to the host of the current request, :host must be set nil.

Alright, this seems like a good start!

After all we don’t want to handle SSL ourselves - the proxy will do that for us. Instead we want to handle X-Forwarded-* headers and only sometimes set the HSTS header2 and redirect to HTTPS, so we can’t be that far off.

To hexdocs.pm we go!

hexdocs.pm - Phoenix.Endpoint

On the hexdocs page for Phoenix.Endpoint3 we can find a section talking about the :force_ssl configuration we saw earlier in the SSL guide. So what do the docs have to tell us about this?

:force_ssl - ensures no data is ever sent via HTTP, always redirecting to HTTPS. It expects a list of options which are forwarded to Plug.SSL. By default it sets the “strict-transport-security” header in HTTPS requests, forcing browsers to always use HTTPS. If an unsafe request (HTTP) is sent, it redirects to the HTTPS version using the :host specified in the :url configuration. To dynamically redirect to the host of the current request, set :host in the :force_ssl configuration to nil

Alright, this is nothing new. It’s basically the same information we saw earlier in the SSL guide.

Let’s look at the docs for Plug.SSL4 then!

hexdocs.pm - Plug.SSL

As soon as we open the docs for Plug.SSL we’re gonna be greeted with a sight for sore eyes:

x-forwarded-*

If your Plug application is behind a proxy that handles HTTPS, you may need to tell Plug to parse the proper protocol from the x-forwarded-* header. This can be done using the :rewrite_on option:

plug Plug.SSL, rewrite_on: [:x_forwarded_proto, :x_forwarded_host, :x_forwarded_port]

Has our quest come to an end? We set the :force_ssl option for our Phoenix.Endpoint to [rewrite_on: [...]] and all our SSL worries disappear?

Well, not quite.

Remember our initial goals?

In a nutshell we want to be able to:

  1. recognize X-Forwarded-* headers1 from the proxy
  2. set the HSTS header2 for HTTPS requests
  3. permanently redirect HTTP requests to use HTTPS
  4. exclude some requests from the redirect (and as such the HSTS header)

Setting :force_ssl to [rewrite_on: [...]] certainly achieves goals 1 to 3, but what about 4?

For reasons which are beyond this blog post, we want to exclude certain requests from the SSL handling. To be specific we want to exclude them from the redirect.

Surely Plug.SSL offers us a solution to exclude some requests, right?

So, I’ve continued to rummage through the docs of Plug.SSL and I come across an option which looks promising:

:exclude - exclude the given hosts from redirecting to the https scheme. Defaults to ["localhost"]. It may be set to a list of binaries or a tuple {module, function, args}.

That’s … not what I was looking for.

Excluding some hosts isn’t quite doing the trick. For this particular use-case we need to look at more than just the host. There must be something else.

Alright, I think we have to no choice: let’s dive into the source code of Plug.SSL!

Looking into Plug.SSL’s source code

As Plug.SSL is a Plug (duh) what’s better than to start at it’s init/1 and call/2 functions?

@impl true
def init(opts) do
  host = Keyword.get(opts, :host)
  rewrite_on = List.wrap(Keyword.get(opts, :rewrite_on))
  log = Keyword.get(opts, :log, :info)
  exclude = Keyword.get(opts, :exclude, ["localhost"])
  {hsts_header(opts), exclude, host, rewrite_on, log}
end

Alright, so the init/1 function extracts some configuration values, applies some defaults, and builds the HSTS header2. Fair enough, the usual Plug.init/1 stuff.

Now let’s look at the more interesting call/2:

@impl true
def call(conn, {hsts, exclude, host, rewrite_on, log_level}) do
  conn = rewrite_on(conn, rewrite_on)

  cond do
    excluded?(conn.host, exclude) -> conn
    conn.scheme == :https -> put_hsts_header(conn, hsts)
    true -> redirect_to_https(conn, host, log_level)
  end
end

Again, no surprises here. In a nutshell this:

  • handles the X-Forwarded-* headers - if configured to do so - and updates the conn’s scheme/port/host
  • sets the HSTS header2, if the request scheme was https
  • redirects the client to use https, if the request scheme wasn’t https

So what is redirect_to_https/3 doing exactly? Maybe we’ll find something there? (Spoiler: we won’t)

defp redirect_to_https(%{host: host} = conn, custom_host, log_level) do
  status = if conn.method in ~w(HEAD GET), do: 301, else: 307

  scheme_and_host = "https://" <> host(custom_host, host)
  location = scheme_and_host <> conn.request_path <> qs(conn.query_string)

  log_level &&
    Logger.log(log_level, fn ->
      # ...
    end)

  conn
  |> put_resp_header("location", location)
  |> send_resp(status, "")
  |> halt
end

Okay, so this function is building up the location to redirect to, does a bit of logging, aaaand … sends the redirect. That’s it. No further configuration in sight.

How do we now go about excluding our requests from the redirect?

Looking for a Solution

A Custom SSL Plug

One option is to wrap the Plug.SSL plug into a plug of our own and only call it for certain requests. A naive implementation might look like this:

plug MaybeSSL, ssl_config: :given_here

# ...

defmodule MaybeSSL do
  @behaviour Plug

  @impl true
  def init(opts), do: Plug.SSL.init(opts)

  @impl true
  def call(conn, ssl_opts) do
    if exclude_from_ssl_redirect?(conn) do
      conn
    else
      Plug.Conn.call(opts, ssl_opts)
    end
  end

  defp exclude_from_ssl_redirect?(conn) do
    # Exclusion logic here ...
  end
end

But this would also disable the X-Forwarded-* header handling for the excluded requests.

While not the end of the world, it certainly can be improved upon.

Divide and Conquer Plug.SSL

If you squint your eyes a bit you can see that Plug.SSL actually does 3 things:

  1. handle X-Forwarded-* headers
  2. set the HSTS header2 for https requests
  3. redirect non-https requests to use https

While steps 2 and 3 belong together - it only makes sense to set HSTS header when the route really only accepts HTTPS traffic - step 1 could easily live in a separate plug.

Based on this a fantasy pipeline for our use-case could look like this:

plug Plug.SSL.RewriteOn, [:x_forwarded_proto, ...]
plug MaybeRedirectToHttps, [...]

# ...

defmodule MaybeRedirectToHttps do
  @behaviour Plug

  @impl true
  def init(opts), do: Plug.SSL.EnforceHttps.init(opts)

  @impl true
  def call(conn, redirect_opts) do
    if exclude_from_redirect?(conn) do
      conn
    else
      Plug.SSL.EnforceHttps.call(conn, redirect_opts)
    end
  end

  defp exclude_from_redirect?(conn) do
    # Exclusion logic here ...
 end
end

With this in place we can have our SSL cake and eat it too!

Sadly, as you’ve already seen, isn’t this something which Plug offers. As such you’d have to copy-paste the relevant code from Plug.SSL and create your own plugs (which is exactly what we ended up doing).

A Proposal for Plug.SSL

You can now probably guess what my proposal to plug looks like.

The idea is that Plug.SSL’s API stays at it is, but that the underlying implementation delegates to 2 new plugs:

  • Plug.SSL.RewriteOn
  • Plug.SSL.EnforceHttps

This way Plug.SSL acts as a “sane default” which can be used as is. But if you need to tweak SSL handling beyond what it’s API offers you, can reach for these lower-level plugs directly.

If you’re interested you can find the proposal here.


UPDATE: Originally this post suggested to split Plug.SSL into three (3) plugs: Plug.SSL.RewriteOn, Plug.SSL.SetHSTSHeader, and Plug.SSL.RedirectToHttps.

After a fruitful discussion with José on the proposal we realized that setting the HSTS header2 and redirecting to HTTPS belong together. To avoid conveying the impression that separating the two is fine (it’s not), we decided to update the post. You can still see the original proposal in the plug issue.

The end result from the discussion with José has been implemented in our PR to plug.

  1. X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto  2

  2. HTTP Strict Transport Security  2 3 4 5 6 7

  3. Phoenix has version 1.5.5 at the time of writing 

  4. Plug has version 1.10.4 at the time of writing