Skip to content

Commit a6d285c

Browse files
committed
Add hosts/paths/conn to Plug.SSL's exclude
1 parent 3937809 commit a6d285c

File tree

2 files changed

+151
-52
lines changed

2 files changed

+151
-52
lines changed

lib/plug/ssl.ex

Lines changed: 87 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ defmodule Plug.SSL do
3434
defaults to `false`
3535
* `:subdomains` - a boolean on including subdomains or not in HSTS,
3636
defaults to `false`
37-
* `:exclude` - exclude the given hosts from redirecting to the `https`
38-
scheme. Defaults to `["localhost", "127.0.0.1"]`. It may be set to a list of binaries
39-
or a tuple [`{module, function, args}`](#module-excluded-hosts-tuple).
37+
* `:exclude` - exclude certain request from redirecting to the `https` scheme.
38+
It defaults to `[hosts: ["localhost", "127.0.0.1"]]`. See the
39+
["Exclude option"](#module-exclude-option) section below
4040
* `:host` - a new host to redirect to if the request's scheme is `http`,
4141
defaults to `conn.host`. It may be set to a binary or a tuple
4242
`{module, function, args}` that will be invoked on demand
4343
* `:log` - The log level at which this plug should log its request info.
44-
Default is `:info`. Can be `false` to disable logging.
44+
Default is `:info`. Can be `false` to disable logging
4545
4646
## Port
4747
@@ -50,23 +50,37 @@ defmodule Plug.SSL do
5050
want to redirect to HTTPS on another port, you can sneak it alongside
5151
the host, for example: `host: "example.com:443"`.
5252
53-
## Excluded hosts tuple
53+
## Exclude option
5454
55-
Tuple `{module, function, args}` can be passed to be invoked each time
56-
the plug is checking whether to redirect host. Provided function needs
57-
to receive at least one argument (`host`).
55+
There are many situations where one may want to avoid `Plug.SSL` from
56+
redirecting, such as requests coming from `localhost` or `127.0.0.1`,
57+
or from health check endpoints.
5858
59-
For example, you may define it as:
59+
This can be done via the `:exclude` option, which allows you to specify
60+
conditions to skip the redirect. As long as any of the conditions match,
61+
the route will be excluded, it must be one of:
6062
61-
plug Plug.SSL,
62-
rewrite_on: [:x_forwarded_proto],
63-
exclude: {__MODULE__, :excluded_host?, []}
63+
* `[hosts: list_of_hosts, ...]` - skips redirection if the request
64+
matches any of the given hosts
6465
65-
where:
66+
* `[paths: list_of_paths, ...]` - skips redirection if the request
67+
matches any of the given paths
6668
67-
def excluded_host?(host) do
68-
# Custom logic
69-
end
69+
* `[conn: {mod, fun, args}, ...]` - calls the given `mod`, `fun`,
70+
and `args` with `Plug.Conn` prepended to the list of arguments.
71+
The plug will be excluded if the call returns `true`
72+
73+
The default value is `[hosts: ["localhost", "127.0.0.1"]]`. If you pass
74+
any additional value, you must explicitly preserve the above if you want
75+
the hosts to remain excluded.
76+
77+
For example, you may define it as:
78+
79+
plug Plug.SSL,
80+
exclude: [
81+
hosts: ["localhost", "127.0.0.1"],
82+
paths: ["/health"]
83+
]
7084
7185
"""
7286
@behaviour Plug
@@ -139,12 +153,12 @@ defmodule Plug.SSL do
139153
Layer Security Cheat Sheet. General purpose web applications should default to
140154
TLSv1.3 with ALL other protocols disabled.
141155
142-
The **Compatible** cipher suite supports TLSv1.2 and TLSv1.3. This
143-
suite provides strong security while maintaining compatibility with a wide
144-
range of modern clients.
156+
The **Compatible** cipher suite supports TLSv1.2 and TLSv1.3. This
157+
suite provides strong security while maintaining compatibility with a wide
158+
range of modern clients.
145159
146-
Legacy protocols TLSv1.1 and TLSv1.0 are officially deprecated by
147-
[RFC 8996](https://www.rfc-editor.org/rfc/rfc8996.html) and are
160+
Legacy protocols TLSv1.1 and TLSv1.0 are officially deprecated by
161+
[RFC 8996](https://www.rfc-editor.org/rfc/rfc8996.html) and are
148162
considered insecure.
149163
150164
[Test your ssl configuration](https://ssl-config.mozilla.org/)
@@ -343,25 +357,72 @@ defmodule Plug.SSL do
343357
:ok
344358
end
345359

346-
rewrite_on = Plug.RewriteOn.init(Keyword.get(opts, :rewrite_on))
360+
exclude =
361+
if exclude = Keyword.get(opts, :exclude) do
362+
validate_exclude!(exclude)
363+
else
364+
[hosts: ["localhost", "127.0.0.1"]]
365+
end
366+
347367
log = Keyword.get(opts, :log, :info)
348-
exclude = Keyword.get(opts, :exclude, ["localhost", "127.0.0.1"])
368+
rewrite_on = Plug.RewriteOn.init(Keyword.get(opts, :rewrite_on))
349369
{hsts_header(opts), exclude, host, rewrite_on, log}
350370
end
351371

372+
defp validate_exclude!(exclude) when is_list(exclude) do
373+
Enum.map(exclude, fn
374+
# TODO: Deprecate me on Plug v1.20
375+
binary when is_binary(binary) ->
376+
{:hosts, [binary]}
377+
378+
{:hosts, hosts} when is_list(hosts) ->
379+
{:hosts, hosts}
380+
381+
{:paths, paths} when is_list(paths) ->
382+
{:paths, Enum.map(paths, &Plug.Router.Utils.split/1)}
383+
384+
{:conn, {mod, fun, args}} when is_atom(mod) and is_atom(fun) and is_list(args) ->
385+
{:conn, {mod, fun, args}}
386+
387+
other ->
388+
raise ArgumentError,
389+
"invalid entry in :exclude, expected host or path, got: #{inspect(other)}"
390+
end)
391+
end
392+
393+
defp validate_exclude!({m, f, a}) do
394+
IO.warn(
395+
"exclude: {mod, fun, args} is deprecated, " <>
396+
"please use exclude: [conn: {mod, fun, args}], which will receive the whole connection instead"
397+
)
398+
399+
{m, f, a}
400+
end
401+
402+
defp validate_exclude!(exclude) do
403+
raise ArgumentError, ":exclude must be a list, got: #{inspect(exclude)}"
404+
end
405+
352406
@impl true
353407
def call(conn, {hsts, exclude, host, rewrite_on, log_level}) do
354408
conn = Plug.RewriteOn.call(conn, rewrite_on)
355409

356410
cond do
357-
excluded?(conn.host, exclude) -> conn
411+
excluded?(conn, exclude) -> conn
358412
conn.scheme == :https -> put_hsts_header(conn, hsts)
359413
true -> redirect_to_https(conn, host, log_level)
360414
end
361415
end
362416

363-
defp excluded?(host, list) when is_list(list), do: :lists.member(host, list)
364-
defp excluded?(host, {mod, fun, args}), do: apply(mod, fun, [host | args])
417+
defp excluded?(conn, list) when is_list(list) do
418+
Enum.any?(list, fn
419+
{:hosts, hosts} -> conn.host in hosts
420+
{:paths, paths} -> conn.path_info in paths
421+
{:conn, {mod, fun, args}} -> apply(mod, fun, [conn | args])
422+
end)
423+
end
424+
425+
defp excluded?(conn, {mod, fun, args}), do: apply(mod, fun, [conn.host | args])
365426

366427
# http://tools.ietf.org/html/draft-hodges-strict-transport-sec-02
367428
defp hsts_header(opts) do

test/plug/ssl_test.exs

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,11 @@ defmodule Plug.SSLTest do
182182
end
183183
end
184184

185-
def excluded_host?(host) do
186-
host == System.get_env("EXCLUDED_HOST")
187-
end
185+
def excluded_host?(%Plug.Conn{} = conn, field),
186+
do: Map.fetch!(conn, field) == System.get_env("EXCLUDED_HOST")
187+
188+
def excluded_host?(host) when is_binary(host),
189+
do: host == System.get_env("EXCLUDED_HOST")
188190

189191
defp call(conn, opts \\ []) do
190192
opts = Keyword.put_new(opts, :log, false)
@@ -198,29 +200,6 @@ defmodule Plug.SSLTest do
198200
refute conn.halted
199201
end
200202

201-
test "excludes localhost" do
202-
conn = call(conn(:get, "https://localhost/"))
203-
assert get_resp_header(conn, "strict-transport-security") == []
204-
refute conn.halted
205-
end
206-
207-
test "excludes custom" do
208-
conn = call(conn(:get, "https://example.com/"), exclude: ["example.com"])
209-
assert get_resp_header(conn, "strict-transport-security") == []
210-
refute conn.halted
211-
end
212-
213-
test "excludes tuple" do
214-
System.put_env("EXCLUDED_HOST", "10.0.0.1")
215-
216-
conn =
217-
conn(:get, "https://10.0.0.1/")
218-
|> call(exclude: {__MODULE__, :excluded_host?, []})
219-
220-
assert get_resp_header(conn, "strict-transport-security") == []
221-
refute conn.halted
222-
end
223-
224203
test "when true" do
225204
conn = call(conn(:get, "https://example.com/"), hsts: true)
226205
assert get_resp_header(conn, "strict-transport-security") == ["max-age=31536000"]
@@ -264,6 +243,65 @@ defmodule Plug.SSLTest do
264243
end
265244
end
266245

246+
describe ":exclude" do
247+
test "excludes localhost by default" do
248+
conn = call(conn(:get, "https://localhost/"))
249+
assert get_resp_header(conn, "strict-transport-security") == []
250+
refute conn.halted
251+
end
252+
253+
test "excludes hosts" do
254+
conn = call(conn(:get, "https://localhost.com/"), exclude: [hosts: ["example.com"]])
255+
assert get_resp_header(conn, "strict-transport-security") == ["max-age=31536000"]
256+
refute conn.halted
257+
258+
conn = call(conn(:get, "https://example.com/"), exclude: [hosts: ["example.com"]])
259+
assert get_resp_header(conn, "strict-transport-security") == []
260+
refute conn.halted
261+
end
262+
263+
test "excludes paths" do
264+
conn = call(conn(:get, "https://localhost/"), exclude: [paths: ["/health"]])
265+
assert get_resp_header(conn, "strict-transport-security") == ["max-age=31536000"]
266+
refute conn.halted
267+
268+
conn = call(conn(:get, "https://localhost/health"), exclude: [paths: ["/health"]])
269+
assert get_resp_header(conn, "strict-transport-security") == []
270+
refute conn.halted
271+
end
272+
273+
test "excludes conn mfargs" do
274+
System.put_env("EXCLUDED_HOST", "10.0.0.1")
275+
276+
conn =
277+
call(conn(:get, "https://10.0.0.1/"),
278+
exclude: [conn: {__MODULE__, :excluded_host?, [:host]}]
279+
)
280+
281+
assert get_resp_header(conn, "strict-transport-security") == []
282+
refute conn.halted
283+
end
284+
285+
test "excludes deprecated binary" do
286+
conn = call(conn(:get, "https://example.com/"), exclude: ["example.com"])
287+
assert get_resp_header(conn, "strict-transport-security") == []
288+
refute conn.halted
289+
end
290+
291+
test "excludes deprecated tuple" do
292+
System.put_env("EXCLUDED_HOST", "10.0.0.1")
293+
294+
ExUnit.CaptureIO.capture_io(:stderr, fn ->
295+
conn =
296+
conn(:get, "https://10.0.0.1/")
297+
|> call(exclude: {__MODULE__, :excluded_host?, []})
298+
299+
assert get_resp_header(conn, "strict-transport-security") == []
300+
refute conn.halted
301+
end)
302+
end
303+
end
304+
267305
describe ":rewrite_on" do
268306
test "rewrites http to https based on x-forwarded-proto" do
269307
conn =

0 commit comments

Comments
 (0)