Skip to content

Commit a8c4fd2

Browse files
committed
Add :detailed_error option
1 parent 1152fc4 commit a8c4fd2

File tree

5 files changed

+96
-105
lines changed

5 files changed

+96
-105
lines changed

README.md

+10-22
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,20 @@ iex> SafeURL.allowed?("https://includesecurity.com")
4949
true
5050

5151
iex> SafeURL.validate("http://google.com/", schemes: ~w[https])
52-
{:error, :restricted}
52+
{:error, :unsafe_scheme}
5353

5454
iex> SafeURL.validate("http://230.10.10.10/")
55-
{:error, :restricted}
55+
{:error, :unsafe_reserved}
5656

5757
iex> SafeURL.validate("http://230.10.10.10/", block_reserved: false)
5858
:ok
5959

6060
# When HTTPoison is available:
6161

62-
iex> SafeURL.get("https://10.0.0.1/ssrf.txt")
63-
{:error, :restricted}
62+
iex> SafeURL.HTTPoison.get("https://10.0.0.1/ssrf.txt")
63+
{:error, :unsafe_reserved}
6464

65-
iex> SafeURL.get("https://google.com/")
65+
iex> SafeURL.HTTPoison.get("https://google.com/")
6666
{:ok, %HTTPoison.Response{...}}
6767
```
6868

@@ -86,6 +86,8 @@ following options:
8686
- `:dns_module` - Any module that implements the `SafeURL.DNSResolver` behaviour.
8787
Defaults to `DNS` from the [`:dns`][lib-dns] package.
8888

89+
- `:detailed_error` - Return specific error if validation fails. If set to `false`, `validate/2` will return `{:error, :restricted}` regardless of the reason. Defaults to `true`.
90+
8991
These options can be passed to the function directly or set globally in your `config.exs`
9092
file:
9193

@@ -103,7 +105,7 @@ Find detailed documentation on [HexDocs][docs].
103105

104106
## HTTP Clients
105107

106-
While SafeURL already provides a convenient [`get/4`][docs-get] method to validate hosts
108+
While SafeURL already provides a convenient [`SafeURL.HTTPoison.get/3`][docs-get] method to validate hosts
107109
before making GET HTTP requests, you can also write your own wrappers, helpers or
108110
middleware to work with the HTTP Client of your choice.
109111

@@ -137,29 +139,15 @@ iex> CustomClient.get("http://230.10.10.10/data.json", [], safeurl: [block_reser
137139

138140
### Tesla
139141

140-
For [Tesla][lib-tesla], you can write a custom middleware to halt requests that are not
141-
allowed:
142-
143-
```elixir
144-
defmodule MyApp.Middleware.SafeURL do
145-
@behaviour Tesla.Middleware
146-
147-
@impl true
148-
def call(env, next, opts) do
149-
with :ok <- SafeURL.validate(env.url, opts), do: Tesla.run(next)
150-
end
151-
end
152-
```
153-
154-
And you can plug it in anywhere you're using Tesla:
142+
For [Tesla][lib-tesla], `SafeURL` provides a helper middleware out-of-the-box, which you can plug anywhere you're using `Tesla`:
155143

156144
```elixir
157145
defmodule DocumentService do
158146
use Tesla
159147

160148
plug Tesla.Middleware.BaseUrl, "https://document-service/"
161149
plug Tesla.Middleware.JSON
162-
plug MyApp.Middleware.SafeURL, schemes: ~w[https], allowlist: ["10.0.0.0/24"]
150+
plug SafeURL.TeslaMiddleware, schemes: ~w[https], allowlist: ["10.0.0.0/24"]
163151

164152
def fetch(id) do
165153
get("/documents/#{id}")

lib/safeurl/safeurl.ex

+53-50
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@ defmodule SafeURL do
88
allowed to make requests.
99
1010
You can use `allowed?/2` or `validate/2` to check if a
11-
URL is safe to call. If the `HTTPoison` application is
12-
available, you can also call `get/4` directly which will
13-
validate the host before making an HTTP request.
11+
URL is safe to call.
1412
1513
1614
## Examples
@@ -19,18 +17,18 @@ defmodule SafeURL do
1917
true
2018
2119
iex> SafeURL.validate("http://google.com/", schemes: ~w[https])
22-
{:error, :restricted}
20+
{:error, :unsafe_scheme}
2321
2422
iex> SafeURL.validate("http://230.10.10.10/")
25-
{:error, :restricted}
23+
{:error, :unsafe_reserved}
2624
2725
iex> SafeURL.validate("http://230.10.10.10/", block_reserved: false)
2826
:ok
2927
3028
# If HTTPoison is available:
3129
3230
iex> SafeURL.HTTPoison.get("https://10.0.0.1/ssrf.txt")
33-
{:error, :restricted}
31+
{:error, :unsafe_reserved}
3432
3533
iex> SafeURL.HTTPoison.get("https://google.com/")
3634
{:ok, %HTTPoison.Response{...}}
@@ -57,6 +55,10 @@ defmodule SafeURL do
5755
`SafeURL.DNSResolver` behaviour. Defaults to `DNS` from
5856
the `:dns` package.
5957
58+
* `:detailed_error` - Return specific error if validation fails. If set to
59+
`false`, `validate/2` will return `{:error, :restricted}` regardless of
60+
the reason. Defaults to `true`.
61+
6062
6163
If `:block_reserved` is `true` and additional hosts/ranges
6264
are supplied with `:blocklist`, both of them are included in
@@ -105,12 +107,11 @@ defmodule SafeURL do
105107
"240.0.0.0/4"
106108
]
107109

108-
110+
@type error() :: :unsafe_scheme | :unsafe_allowlist | :unsafe_blocklist | :unsafe_reserved
109111

110112
# Public API
111113
# ----------
112114

113-
114115
@doc """
115116
Validate a string URL against a blocklist or allowlist.
116117
@@ -139,29 +140,19 @@ defmodule SafeURL do
139140
"""
140141
@spec allowed?(binary(), Keyword.t()) :: boolean()
141142
def allowed?(url, opts \\ []) do
142-
uri = URI.parse(url)
143-
opts = build_options(opts)
144-
address = resolve_address(uri.host, opts.dns_module)
145-
146-
cond do
147-
uri.scheme not in opts.schemes ->
148-
false
149-
150-
opts.allowlist != [] ->
151-
ip_in_ranges?(address, opts.allowlist)
152-
153-
true ->
154-
!ip_in_ranges?(address, opts.blocklist)
143+
case validate(url, opts) do
144+
:ok -> true
145+
{:error, _} -> false
155146
end
156147
end
157148

158-
159149
@doc """
160-
Alternative method of validating a URL, returning atoms instead
150+
Alternative method of validating a URL, returning result tuple instead
161151
of booleans.
162152
163153
This calls `allowed?/2` underneath to check if a URL is safe to
164-
be called. If it is, it returns `:ok`, otherwise
154+
be called. If it is, it returns `:ok`, otherwise an error tuple with a
155+
specific reason. If `:detailed_error` is set to `false`, the error is always
165156
`{:error, :restricted}`.
166157
167158
## Examples
@@ -180,48 +171,61 @@ defmodule SafeURL do
180171
See [`Options`](#module-options) section above.
181172
182173
"""
183-
@spec validate(binary(), Keyword.t()) :: :ok | {:error, :restricted}
174+
@spec validate(binary(), Keyword.t()) :: :ok | {:error, error() | :restricted}
184175
def validate(url, opts \\ []) do
185-
if allowed?(url, opts) do
186-
:ok
187-
else
188-
{:error, :restricted}
176+
uri = URI.parse(url)
177+
opts = build_options(opts)
178+
address = resolve_address(uri.host, opts.dns_module)
179+
180+
result =
181+
cond do
182+
uri.scheme not in opts.schemes ->
183+
{:error, :unsafe_scheme}
184+
185+
opts.allowlist != [] ->
186+
if ip_in_ranges?(address, opts.allowlist), do: :ok, else: {:error, :unsafe_allowlist}
187+
188+
opts.blocklist != [] and ip_in_ranges?(address, opts.blocklist) ->
189+
{:error, :unsafe_blocklist}
190+
191+
opts.block_reserved and ip_in_ranges?(address, @reserved_ranges) ->
192+
{:error, :unsafe_reserved}
193+
194+
true ->
195+
:ok
196+
end
197+
198+
with {:error, _} <- result do
199+
if opts.detailed_error, do: result, else: {:error, :restricted}
189200
end
190201
end
191202

192-
193203
# Private Helpers
194204
# ---------------
195205

196-
197206
# Return a map of calculated options
198207
defp build_options(opts) do
199208
schemes = get_option(opts, :schemes)
200209
allowlist = get_option(opts, :allowlist)
201210
blocklist = get_option(opts, :blocklist)
202211
dns_module = get_option(opts, :dns_module)
203-
204-
blocklist =
205-
if get_option(opts, :block_reserved) do
206-
blocklist ++ @reserved_ranges
207-
else
208-
blocklist
209-
end
210-
211-
%{schemes: schemes, allowlist: allowlist, blocklist: blocklist, dns_module: dns_module}
212+
block_reserved = get_option(opts, :block_reserved)
213+
detailed_error = get_option(opts, :detailed_error)
214+
215+
%{
216+
schemes: schemes,
217+
allowlist: allowlist,
218+
blocklist: blocklist,
219+
dns_module: dns_module,
220+
block_reserved: block_reserved,
221+
detailed_error: detailed_error
222+
}
212223
end
213224

214-
215225
# Get the value of a specific option, either from the application
216226
# configs or overrides explicitly passed as arguments.
217-
defp get_option(opts, key) do
218-
if Keyword.has_key?(opts, key) do
219-
Keyword.get(opts, key)
220-
else
221-
Application.get_env(:safeurl, key)
222-
end
223-
end
224-
227+
defp get_option(opts, key),
228+
do: Keyword.get_lazy(opts, key, fn -> Application.get_env(:safeurl, key) end)
225229

226230
# Resolve hostname in DNS to an IP address (if not already an IP)
227231
defp resolve_address(hostname, dns_module) do
@@ -241,7 +245,6 @@ defmodule SafeURL do
241245
end
242246
end
243247

244-
245248
defp ip_in_ranges?({_, _, _, _} = addr, ranges) when is_list(ranges) do
246249
Enum.any?(ranges, fn range ->
247250
range

mix.exs

+1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ defmodule SafeURL.MixProject do
5050
blocklist: [],
5151
allowlist: [],
5252
dns_module: DNS,
53+
detailed_error: true
5354
]
5455
end
5556

test/safeurl/tesla_middleware_test.exs

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ defmodule SafeURL.TeslaMiddlewareTest do
2222
Tesla.client([Tesla.Middleware.Logger, {TeslaMiddleware, dns_module: TestDNSResolver}])
2323

2424
assert capture_log(fn ->
25-
assert {:error, :restricted} = Tesla.get(client, "http://blocked")
26-
end) =~ "http://blocked -> error: :restricted"
25+
assert {:error, :unsafe_reserved} = Tesla.get(client, "http://blocked")
26+
end) =~ "http://blocked -> error: :unsafe_reserved"
2727
end
2828
end

test/safeurl_test.exs

+30-31
Original file line numberDiff line numberDiff line change
@@ -8,59 +8,58 @@ defmodule SafeURLTest do
88
def resolve(_domain), do: {:ok, [{192, 0, 78, 24}]}
99
end
1010

11-
12-
# setup_all do
13-
# global_whitelist = ["10.0.0.0/24"]
14-
# global_blacklist = ["8.8.0.0/16"]
15-
16-
# Application.put_env(:safeurl, :whitelist, global_whitelist)
17-
# end
18-
19-
describe "#allowed?" do
11+
describe "validate/2?" do
2012
test "returns true for only allowed schemes" do
2113
opts = [dns_module: TestDNSResolver]
22-
assert SafeURL.allowed?("http://includesecurity.com", opts)
23-
assert SafeURL.allowed?("https://includesecurity.com", opts)
24-
refute SafeURL.allowed?("ftp://includesecurity.com", opts)
14+
assert :ok = SafeURL.validate("http://includesecurity.com", opts)
15+
assert :ok = SafeURL.validate("https://includesecurity.com", opts)
16+
assert {:error, :unsafe_scheme} = SafeURL.validate("ftp://includesecurity.com", opts)
2517

2618
opts = [schemes: ~w[ftp], dns_module: TestDNSResolver]
27-
assert SafeURL.allowed?("ftp://includesecurity.com", opts)
28-
refute SafeURL.allowed?("http://includesecurity.com", opts)
19+
assert :ok = SafeURL.validate("ftp://includesecurity.com", opts)
20+
assert {:error, :unsafe_scheme} = SafeURL.validate("http://includesecurity.com", opts)
2921
end
3022

3123
test "returns false for reserved ranges" do
32-
refute SafeURL.allowed?("http://0.0.0.0/")
33-
refute SafeURL.allowed?("http://10.0.0.1/")
34-
refute SafeURL.allowed?("http://127.0.0.1/")
35-
refute SafeURL.allowed?("http://169.254.9.1/")
36-
refute SafeURL.allowed?("http://192.168.1.1/")
24+
assert {:error, :unsafe_reserved} = SafeURL.validate("http://0.0.0.0/")
25+
assert {:error, :unsafe_reserved} = SafeURL.validate("http://10.0.0.1/")
26+
assert {:error, :unsafe_reserved} = SafeURL.validate("http://127.0.0.1/")
27+
assert {:error, :unsafe_reserved} = SafeURL.validate("http://169.254.9.1/")
28+
assert {:error, :unsafe_reserved} = SafeURL.validate("http://192.168.1.1/")
3729
end
3830

3931
test "returns true for reserved ranges if overridden" do
4032
opts = [block_reserved: false]
4133

42-
assert SafeURL.allowed?("http://0.0.0.0/", opts)
43-
assert SafeURL.allowed?("http://10.0.0.1/", opts)
44-
assert SafeURL.allowed?("http://127.0.0.1/", opts)
45-
assert SafeURL.allowed?("http://169.254.9.1/", opts)
46-
assert SafeURL.allowed?("http://192.168.1.1/", opts)
34+
assert :ok = SafeURL.validate("http://0.0.0.0/", opts)
35+
assert :ok = SafeURL.validate("http://10.0.0.1/", opts)
36+
assert :ok = SafeURL.validate("http://127.0.0.1/", opts)
37+
assert :ok = SafeURL.validate("http://169.254.9.1/", opts)
38+
assert :ok = SafeURL.validate("http://192.168.1.1/", opts)
4739
end
4840

4941
test "blocking custom IP ranges" do
5042
opts = [blocklist: ["5.5.0.0/16", "100.0.0.0/24"], dns_module: TestDNSResolver]
5143

52-
assert SafeURL.allowed?("http://includesecurity.com", opts)
53-
assert SafeURL.allowed?("http://3.3.3.3", opts)
54-
refute SafeURL.allowed?("http://5.5.5.5", opts)
55-
refute SafeURL.allowed?("http://100.0.0.50", opts)
44+
assert :ok = SafeURL.validate("http://includesecurity.com", opts)
45+
assert :ok = SafeURL.validate("http://3.3.3.3", opts)
46+
assert {:error, :unsafe_blocklist} = SafeURL.validate("http://5.5.5.5", opts)
47+
assert {:error, :unsafe_blocklist} = SafeURL.validate("http://100.0.0.50", opts)
5648
end
5749

5850
test "only allows IPs in the allowlist when present" do
5951
opts = [allowlist: ["10.0.0.0/24"], dns_module: TestDNSResolver]
6052

61-
assert SafeURL.allowed?("http://10.0.0.1/", opts)
62-
refute SafeURL.allowed?("http://72.254.45.178", opts)
63-
refute SafeURL.allowed?("https://includesecurity.com", opts)
53+
assert :ok = SafeURL.validate("http://10.0.0.1/", opts)
54+
assert {:error, :unsafe_allowlist} = SafeURL.validate("http://72.254.45.178", opts)
55+
assert {:error, :unsafe_allowlist} = SafeURL.validate("https://includesecurity.com", opts)
56+
end
57+
58+
test "detailed_errors can be switched off" do
59+
opts = [blocklist: ["5.5.0.0/16"], dns_module: TestDNSResolver, detailed_error: false]
60+
assert {:error, :restricted} = SafeURL.validate("ftp://includesecurity.com", opts)
61+
assert {:error, :restricted} = SafeURL.validate("http://5.5.5.5", opts)
62+
assert {:error, :restricted} = SafeURL.validate("http://0.0.0.0/", opts)
6463
end
6564
end
6665
end

0 commit comments

Comments
 (0)