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:
- recognize
X-Forwarded-*
headers1 from the proxy - set the HSTS header2 for HTTPS requests
- permanently redirect HTTP requests to use HTTPS
- 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 toPlug.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.Endpoint
3 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 toPlug.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.SSL
4 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:
- recognize
X-Forwarded-*
headers1 from the proxy- set the HSTS header2 for HTTPS requests
- permanently redirect HTTP requests to use HTTPS
- 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 theconn
’sscheme
/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:
- handle
X-Forwarded-*
headers - set the HSTS header2 for https requests
- 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
.
-
X-Forwarded-For
,X-Forwarded-Host
, andX-Forwarded-Proto
↩ ↩2 -
Phoenix has version 1.5.5 at the time of writing ↩
-
Plug has version 1.10.4 at the time of writing ↩