Skip to content

Commit 507f5aa

Browse files
author
Include Security
authored
Merge pull request #1 from slab/sn/quality
Improve code quality
2 parents 217a2c1 + 87190f5 commit 507f5aa

File tree

4 files changed

+307
-90
lines changed

4 files changed

+307
-90
lines changed

.gitignore

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Created by https://www.toptal.com/developers/gitignore/api/elixir
2+
# Edit at https://www.toptal.com/developers/gitignore?templates=elixir
3+
4+
### Elixir ###
5+
/_build
6+
/cover
7+
/deps
8+
/doc
9+
/.fetch
10+
erl_crash.dump
11+
*.ez
12+
*.beam
13+
/config/*.secret.exs
14+
.elixir_ls/
15+
16+
### Elixir Patch ###
17+
18+
# End of https://www.toptal.com/developers/gitignore/api/elixir

lib/safeurl.ex

Lines changed: 234 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,84 @@
11
defmodule SafeURL do
22
@moduledoc """
3-
A library for mitigating Server Side Request Forgery vulnerabilities in Elixir. Private/reserved
4-
IP addresses are blacklisted by default, and users can add additional CIDR ranges to blacklist
5-
or alternatively whitelist specific CIDR ranges to which the application is allowed to make requests.
6-
7-
Examples:
8-
9-
iex(10)> SafeURL.get("https://10.0.0.1/ssrf.txt")
10-
{:error, :restricted}
11-
12-
iex(10)> SafeURL.get("https://google.com/")
13-
{:ok,
14-
%HTTPoison.Response{
15-
body: "{...}",
16-
headers: [
17-
{"..."}
18-
],
19-
request: %HTTPoison.Request{
20-
body: "",
21-
headers: [],
22-
method: :get,
23-
options: [],
24-
params: %{},
25-
url: "https://google.com/"
26-
},
27-
request_url: "https://google.com/",
28-
status_code: 301
29-
}}
3+
`SafeURL` is library for mitigating Server Side Request
4+
Forgery vulnerabilities in Elixir. Private/reserved IP
5+
addresses are blacklisted by default, and users can add
6+
additional CIDR ranges to blacklist, or alternatively
7+
whitelist specific CIDR ranges to which the application is
8+
allowed to make requests.
9+
10+
You can use `allowed?/2` or `validate/2` to check if a
11+
URL is safe to call, or just call it directly via `get/4`
12+
which will validate it automatically before calling, and
13+
return an error if it is not.
14+
15+
16+
## Examples
17+
18+
iex> SafeURL.allowed?("https://includesecurity.com")
19+
true
20+
21+
iex> SafeURL.validate("http://google.com/", schemes: ~w[https])
22+
{:error, :restricted}
23+
24+
iex> SafeURL.validate("http://230.10.10.10/")
25+
{:error, :restricted}
26+
27+
iex> SafeURL.validate("http://230.10.10.10/", blacklist_reserved: false)
28+
:ok
29+
30+
iex> SafeURL.get("https://10.0.0.1/ssrf.txt")
31+
{:error, :restricted}
32+
33+
iex> SafeURL.get("https://google.com/")
34+
{:ok, %HTTPoison.Response{...}}
35+
36+
37+
## Options
38+
39+
`SafeURL` can be configured to customize and override
40+
validation behaviour by passing the following options:
41+
42+
* `:blacklist_reserved` - Blacklist reserved/private IP
43+
ranges. Defaults to `true`.
44+
45+
* `:blacklist` - List of CIDR ranges to blacklist. This is
46+
additive with `:blacklist_reserved`. Defaults to `[]`.
47+
48+
* `:whitelist` - List of CIDR ranges to whitelist. If
49+
specified, blacklists will be ignored. Defaults to `[]`.
50+
51+
* `:schemes` - List of allowed URL schemes. Defaults to
52+
`["http, "https"]`.
53+
54+
If `:blacklist_reserved` is `true` and additional hosts/ranges
55+
are supplied with `:blacklist`, both of them are included in
56+
the final blacklist to validate the address. If whitelisted
57+
ranges are supplied with `:whitelist`, all blacklists are
58+
ignored and any hosts not explicitly declared in the whitelist
59+
are rejected.
60+
61+
These options can be set globally in your `config.exs` file:
62+
63+
config :safeurl,
64+
blacklist_reserved: true,
65+
blacklist: ~w[100.0.0.0/16],
66+
schemes: ~w[https]
67+
68+
Or they can be passed to the function directly, overriding any
69+
global options if set:
70+
71+
iex> SafeURL.validate("http://10.0.0.1/", blacklist_reserved: false)
72+
:ok
73+
74+
iex> SafeURL.validate("https://app.service/", whitelist: ~w[170.0.0.0/24])
75+
:ok
76+
77+
iex> SafeURL.validate("https://app.service/", blacklist: ~w[170.0.0.0/24])
78+
{:error, :restricted}
79+
3080
"""
81+
3182
@reserved_ranges [
3283
"0.0.0.0/8",
3384
"10.0.0.0/8",
@@ -46,87 +97,188 @@ defmodule SafeURL do
4697
"240.0.0.0/4"
4798
]
4899

100+
101+
102+
# Public API
103+
# ----------
104+
105+
49106
@doc """
50-
Validate a URL and execute a GET request using `HTTPoison` with the specified headers and options.
107+
Validate a string URL against a blacklist or whitelist.
108+
109+
This method checks if a URL is safe to be called by looking at
110+
its scheme and resolved IP address, and matching it against
111+
reserved CIDR ranges, and any provided whitelist/blacklist.
112+
113+
Returns `true` if the URL meets the requirements,
114+
`false` otherwise.
115+
116+
## Examples
51117
52-
Available options and defaults:
53-
* `:blacklist_private` - Blacklist private/reserved IP ranges (default: `true`)
54-
* `:blacklist` - List of CIDR ranges to blacklist. Additive with `:blacklist_private` (default: `nil`)
55-
* `:whitelist` - List of CIDR ranges to whitelist. If specified, blacklists will be ignored (default: `nil`)
56-
* `:schemes` - List of valid URL schemes (default: `["http, "https"]`)
118+
iex> SafeURL.allowed?("https://includesecurity.com")
119+
true
57120
58-
Options specified in `httpoison_options` will be passed directly to `HTTPoison` when the request is executed.
121+
iex> SafeURL.allowed?("http://10.0.0.1/")
122+
false
123+
124+
iex> SafeURL.allowed?("http://10.0.0.1/", whitelist: ~w[10.0.0.0/8])
125+
true
126+
127+
## Options
59128
60-
If `:blacklist_private` is `true` and additional hosts/ranges are supplied with `:blacklist`, the
61-
lists are additive. If whitelisted ranges are supplied with `:whitelist`, all blacklists are ignored
62-
and any hosts not explicitly declared in the whitelist are rejected.
129+
See [`Options`](#module-options) section above.
63130
64-
If the URL is safe, this function returns the `HTTPoison` result directly; otherwise, `{:error, :restricted}`.
65131
"""
66-
def get(url, options \\ [], headers \\ [], httpoison_options \\ []) do
67-
if validate_url(url, options) do
68-
HTTPoison.get(url, headers, httpoison_options)
132+
@spec allowed?(binary(), Keyword.t()) :: boolean()
133+
def allowed?(url, opts \\ []) do
134+
uri = URI.parse(url)
135+
opts = build_options(opts)
136+
address = resolve_address(uri.host)
137+
138+
cond do
139+
uri.scheme not in opts.schemes ->
140+
false
141+
142+
opts.whitelist != [] ->
143+
ip_in_ranges?(address, opts.whitelist)
144+
145+
true ->
146+
!ip_in_ranges?(address, opts.blacklist)
147+
end
148+
end
149+
150+
151+
@doc """
152+
Alternative method of validating a URL, returning atoms instead
153+
of booleans.
154+
155+
This calls `allowed?/2` underneath to check if a URL is safe to
156+
be called. If it is, it returns `:ok`, otherwise
157+
`{:error, :restricted}`.
158+
159+
## Examples
160+
161+
iex> SafeURL.validate("https://includesecurity.com")
162+
:ok
163+
164+
iex> SafeURL.validate("http://10.0.0.1/")
165+
{:error, :restricted}
166+
167+
iex> SafeURL.validate("http://10.0.0.1/", whitelist: ~w[10.0.0.0/8])
168+
:ok
169+
170+
## Options
171+
172+
See [`Options`](#module-options) section above.
173+
174+
"""
175+
@spec validate(binary(), Keyword.t()) :: :ok | {:error, :restricted}
176+
def validate(url, opts \\ []) do
177+
if allowed?(url, opts) do
178+
:ok
69179
else
70180
{:error, :restricted}
71181
end
72182
end
73183

184+
74185
@doc """
75-
Validate a string URL against a blacklist or whitelist.
186+
Validate a URL and execute a GET request using `HTTPoison`.
76187
77-
See documentation for `SafeURL.get()` for available options and defaults.
188+
If the URL is safe, this function will execute the request using
189+
`HTTPoison`, returning the result directly. Otherwise, it will
190+
return `{:error, :restricted}`.
191+
192+
`headers` and `httpoison_options` will be passed directly to
193+
`HTTPoison` when the request is executed.
194+
195+
See `allowed?/2` for more details on URL validation.
196+
197+
## Examples
198+
199+
iex> SafeURL.get("https://10.0.0.1/ssrf.txt")
200+
{:error, :restricted}
201+
202+
iex> SafeURL.get("https://google.com/")
203+
{:ok, %HTTPoison.Response{...}}
204+
205+
iex> SafeURL.get("https://google.com/", schemes: ~w[ftp])
206+
{:error, :restricted}
207+
208+
## Options
209+
210+
See [`Options`](#module-options) section above.
78211
79-
Returns `true` if the URL meets the requirements, `false` otherwise.
80212
"""
81-
def validate_url(url, options \\ []) do
82-
blacklist_private = Keyword.get(options, :blacklist_private, true)
83-
blacklist = Keyword.get(options, :blacklist, [])
84-
whitelist = Keyword.get(options, :whitelist, [])
85-
schemes = Keyword.get(options, :schemes, ["http", "https"])
86-
host_info = URI.parse(url)
87-
88-
# TODO: Refactor this to use idiomatic elixir control flow
89-
if validate_scheme(host_info.scheme, schemes) == false do
90-
false
91-
else
92-
addr = resolve_address(host_info.host)
93-
if length(whitelist) != 0 do
94-
validate_whitelist(addr, whitelist)
213+
@spec get(binary(), Keyword.t(), HTTPoison.headers(), Keyword.t()) ::
214+
{:ok, HTTPoison.Response.t()} | {:error, :restricted}
215+
def get(url, options \\ [], headers \\ [], httpoison_options \\ []) do
216+
with :ok <- validate(url, options) do
217+
HTTPoison.get(url, headers, httpoison_options)
218+
end
219+
end
220+
221+
222+
223+
224+
# Private Helpers
225+
# ---------------
226+
227+
228+
# Return a map of calculated options
229+
defp build_options(opts) do
230+
schemes = get_option(opts, :schemes)
231+
whitelist = get_option(opts, :whitelist)
232+
blacklist = get_option(opts, :blacklist)
233+
234+
blacklist =
235+
if get_option(opts, :blacklist_reserved) do
236+
blacklist ++ @reserved_ranges
95237
else
96-
if blacklist_private == false do
97-
validate_blacklist(addr, blacklist)
98-
else
99-
validate_blacklist(addr, @reserved_ranges ++ blacklist)
100-
end
238+
blacklist
101239
end
102-
end
240+
241+
%{schemes: schemes, whitelist: whitelist, blacklist: blacklist}
103242
end
104243

105-
defp resolve_address(hostname) do
106-
# Don't resolve hostname in DNS if it's an IP address
107-
{result, value} = hostname |> to_charlist() |> :inet.parse_address()
108-
if result != :ok do
109-
{_, ip} = DNS.resolve(hostname)
110-
# TODO: safely handle multiple IPs/round-robin DNS
111-
List.first(ip)
244+
245+
# Get the value of a specific option, either from the application
246+
# configs or overrides explicitly passed as arguments.
247+
defp get_option(opts, key) do
248+
if Keyword.has_key?(opts, key) do
249+
Keyword.get(opts, key)
112250
else
113-
value
251+
Application.get_env(:safeurl, key)
114252
end
115253
end
116254

117-
defp validate_scheme(scheme, allowed_schemes) do
118-
Enum.member?(allowed_schemes, scheme)
119-
end
120255

121-
defp validate_whitelist(address, whitelist) do
122-
Enum.any?(whitelist, fn range ->
123-
InetCidr.contains?(InetCidr.parse(range), address)
124-
end)
256+
# Resolve hostname in DNS to an IP address (if not already an IP)
257+
defp resolve_address(hostname) do
258+
hostname
259+
|> to_charlist()
260+
|> :inet.parse_address()
261+
|> case do
262+
{:ok, ip} ->
263+
ip
264+
265+
{:error, :einval} ->
266+
# TODO: safely handle multiple IPs/round-robin DNS
267+
case DNS.resolve(hostname) do
268+
{:ok, ips} -> List.first(ips)
269+
{:error, _reason} -> nil
270+
end
271+
end
125272
end
126273

127-
defp validate_blacklist(address, blacklist) do
128-
!Enum.any?(blacklist, fn range ->
129-
InetCidr.contains?(InetCidr.parse(range), address)
274+
275+
defp ip_in_ranges?({_, _, _, _} = addr, ranges) when is_list(ranges) do
276+
Enum.any?(ranges, fn range ->
277+
range
278+
|> InetCidr.parse()
279+
|> InetCidr.contains?(addr)
130280
end)
131281
end
282+
283+
defp ip_in_ranges?(_addr, _ranges), do: false
132284
end

0 commit comments

Comments
 (0)