Skip to content

Commit 7e9add2

Browse files
committed
Added detailed documentation
1 parent 7288272 commit 7e9add2

File tree

5 files changed

+229
-32
lines changed

5 files changed

+229
-32
lines changed

README.md

+132-15
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ See the [Documentation][docs] on HexDocs.
2929
## Installation
3030

3131
To get started, add `safeurl` to your project dependencies in `mix.exs`. Optionally, you may
32-
also add `HTTPoison` to your dependencies for making requests directly through SafeURL:
32+
also add [`HTTPoison`][lib-httpoison] to your dependencies for making requests directly
33+
through SafeURL:
3334

3435
```elixir
3536
def deps do
@@ -40,6 +41,8 @@ def deps do
4041
end
4142
```
4243

44+
To use SafeURL with your favorite HTTP Client, see the [HTTP Clients][readme-http] section.
45+
4346
<br>
4447

4548

@@ -51,12 +54,10 @@ end
5154
CIDR ranges to the blocklist, or alternatively allow specific CIDR ranges to which the
5255
application is allowed to make requests.
5356

54-
You can use `allowed?/2` or `validate/2` to check if a URL is safe to call, or if you have
55-
the `HTTPoison` application available, just call it directly via `get/4` which will validate
56-
it automatically before calling, and return an error if it is not.
57-
57+
You can use `allowed?/2` or `validate/2` to check if a URL is safe to call. If you have the
58+
[`HTTPoison`][lib-httpoison] application available, you can also call `get/4` which will
59+
validate the host automatically before making a web request, and return an error otherwise.
5860

59-
### Examples
6061

6162
```elixir
6263
iex> SafeURL.allowed?("https://includesecurity.com")
@@ -80,8 +81,12 @@ iex> SafeURL.get("https://google.com/")
8081
{:ok, %HTTPoison.Response{...}}
8182
```
8283

84+
<br>
85+
8386

84-
### Configuration
87+
88+
89+
## Configuration
8590

8691
`SafeURL` can be configured to customize and override validation behaviour by passing the
8792
following options:
@@ -96,7 +101,8 @@ following options:
96101

97102
* `:schemes` - List of allowed URL schemes. Defaults to `["http, "https"]`.
98103

99-
* `:dns_module` - Any module that implements DNSResolver. Defaults to DNS from the `dns` package.
104+
* `:dns_module` - Any module that implements the `SafeURL.DNSResolver` behaviour.
105+
Defaults to `DNS` from the [`:dns`][lib-dns] package.
100106

101107
These options can be passed to the function directly or set globally in your `config.exs`
102108
file:
@@ -105,15 +111,121 @@ file:
105111
config :safeurl,
106112
block_reserved: true,
107113
blocklist: ~w[100.0.0.0/16],
108-
schemes: ~w[https]
114+
schemes: ~w[https],
115+
dns_module: MyCustomDNSResolver
109116
```
110117

111118
Find detailed documentation on [HexDocs][docs].
112119

113-
<!--
114-
TODO: Add section explaining how to use SafeURL with various HTTP libraries
115-
such as HTTPoison, Tesla, etc. once we remove HTTPoison as a dependency.
116-
-->
120+
<br>
121+
122+
123+
124+
125+
## HTTP Clients
126+
127+
While SafeURL already provides a convenient [`get/4`][docs-get] method to validate hosts
128+
before making GET HTTP requests, you can also write your own wrappers, helpers or
129+
middleware to work with the HTTP Client of your choice.
130+
131+
132+
### HTTPoison
133+
134+
For [HTTPoison][lib-httpoison], you can create a wrapper module that validates hosts
135+
before making HTTP requests:
136+
137+
```elixir
138+
defmodule CustomClient do
139+
def request(method, url, body, headers \\ [], opts \\ []) do
140+
{safeurl_opts, opts} = Keyword.pop(opts, :safeurl, [])
141+
142+
with :ok <- SafeURL.validate(url, safeurl_opts) do
143+
HTTPoison.request(method, url, body, headers, opts)
144+
end
145+
end
146+
147+
def get(url, headers \\ [], opts \\ []), do: request(:get, url, "", headers, opts)
148+
def post(url, body, headers \\ [], opts \\ []), do: request(:post, url, body, headers, opts)
149+
# ...
150+
end
151+
```
152+
153+
And you can use it as:
154+
155+
```elixir
156+
iex> CustomClient.get("http://230.10.10.10/data.json", [], safeurl: [block_reserved: false], recv_timeout: 500)
157+
{:ok, %HTTPoison.Response{...}}
158+
```
159+
160+
161+
### Tesla
162+
163+
For [Tesla][lib-tesla], you can write a custom middleware to halt requests that are not
164+
allowed:
165+
166+
```elixir
167+
defmodule MyApp.Middleware.SafeURL do
168+
@behaviour Tesla.Middleware
169+
170+
@impl true
171+
def call(env, next, opts) do
172+
with :ok <- SafeURL.validate(env.url, opts), do: Tesla.run(next)
173+
end
174+
end
175+
```
176+
177+
And you can plug it in anywhere you're using Tesla:
178+
179+
```elixir
180+
defmodule DocumentService do
181+
use Tesla
182+
183+
plug Tesla.Middleware.BaseUrl, "https://document-service/"
184+
plug Tesla.Middleware.JSON
185+
plug MyApp.Middleware.SafeURL, schemes: ~w[https], allowlist: ["10.0.0.0/24"]
186+
187+
def fetch(id) do
188+
get("/documents/#{id}")
189+
end
190+
end
191+
```
192+
193+
<br>
194+
195+
196+
197+
198+
## Custom DNS Resolver
199+
200+
In some cases you might want to use a custom strategy for DNS resolution. You can do so by
201+
passing your own implementation of [`SafeURL.DNSResolver`][docs-dns] in the global or local
202+
config.
203+
204+
Example use-cases of this are:
205+
206+
- Using a specific DNS server
207+
- Avoiding network access in specific environments
208+
- Mocking DNS resolution in tests
209+
210+
You can do so by implementing `DNSResolver`:
211+
212+
213+
```elixir
214+
defmodule TestDNSResolver do
215+
@behaviour SafeURL.DNSResolver
216+
217+
@impl true
218+
def resolve("google.com"), do: {:ok, [{192, 168, 1, 10}]}
219+
def resolve("github.com"), do: {:ok, [{192, 168, 1, 20}]}
220+
def resolve(_domain), do: {:ok, [{192, 168, 1, 99}]}
221+
end
222+
```
223+
224+
```elixir
225+
config :safeurl, dns_module: TestDNSResolver
226+
```
227+
228+
For more examples, see [`SafeURL.DNSResolver`][docs-dns] docs.
117229

118230
<br>
119231

@@ -149,9 +261,14 @@ This package is available as open source under the terms of the [MIT License][gi
149261
[hexpm]: https://hex.pm/packages/safeurl
150262
[github-license]: https://github.com/slab/safeurl-elixir/blob/master/LICENSE
151263
[github-fork]: https://github.com/slab/safeurl-elixir/fork
152-
153-
[docs]: https://hexdocs.pm/safeurl
154264
[slab]: https://slab.com/
155265
[includesecurity]: https://github.com/IncludeSecurity
266+
[readme-http]: #http-clients
156267

268+
[docs]: https://hexdocs.pm/safeurl
269+
[docs-get]: https://hexdocs.pm/safeurl/SafeURL.html#get/4
270+
[docs-dns]: https://hexdocs.pm/safeurl/SafeURL.DNSResolver.html
157271

272+
[lib-dns]: https://github.com/tungd/elixir-dns
273+
[lib-tesla]: https://github.com/elixir-tesla/tesla
274+
[lib-httpoison]: https://github.com/edgurgel/httpoison

lib/dns_resolver.ex

-3
This file was deleted.

lib/safeurl/dns_resolver.ex

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
defmodule SafeURL.DNSResolver do
2+
@moduledoc """
3+
In some cases you might want to use a custom strategy
4+
for DNS resolution. You can do so by passing your own
5+
implementation of `SafeURL.DNSResolver` in the global
6+
or local config.
7+
8+
By default, the `DNS` package is used for resolution,
9+
but you can replace it with a wrapper that uses
10+
different configuration or a completely different
11+
implementation altogether.
12+
13+
14+
## Use-cases
15+
16+
* Using a specific DNS server
17+
* Avoiding network access in specific environments
18+
* Mocking DNS resolution in tests
19+
20+
21+
## Usage
22+
23+
Start by creating a module that implements the
24+
`DNSResolver` behaviour. Currently, this means adding
25+
only one `c:resolve/1` callback that takes a host and
26+
returns a list of resolved IPs.
27+
28+
As an example, suppose you wanted to use
29+
[Cloudflare's DNS](https://1.1.1.1/dns/), you can do
30+
that by wrapping `DNS` with your own settings in a new
31+
module:
32+
33+
defmodule CloudflareDNS do
34+
@behaviour SafeURL.DNSResolver
35+
36+
@impl true
37+
def resolve(domain) do
38+
DNS.resolve(domain, :a, {"1.1.1.1", 53}, :udp)
39+
end
40+
end
41+
42+
To use it, simply pass it in the global config:
43+
44+
config :safeurl, dns_module: CloudflareDNS
45+
46+
You can also directly set the `:dns_module` in method options:
47+
48+
SafeURL.allowed?("https://example.com", dns_module: CloudflareDNS)
49+
50+
51+
## Testing
52+
53+
This is especially useful in tests where you want to
54+
ensure your HTTP Client wrapper with `SafeURL` is
55+
working as expected.
56+
57+
You can override the `:dns_module` config to ensure
58+
a specific IP is resolved for a domain or no network
59+
requests are made:
60+
61+
defmodule TestDNSResolver do
62+
@behaviour SafeURL.DNSResolver
63+
64+
@impl true
65+
def resolve("google.com"), do: {:ok, [{192, 168, 1, 10}]}
66+
def resolve("github.com"), do: {:ok, [{192, 168, 1, 20}]}
67+
def resolve(_domain), do: {:ok, [{192, 168, 1, 99}]}
68+
end
69+
70+
"""
71+
72+
@type resolution :: :inet.ip() | [:inet.ip()]
73+
@callback resolve(host :: binary()) :: {:ok, resolution()} | {:error, atom()}
74+
end

lib/safeurl.ex renamed to lib/safeurl/safeurl.ex

+8-2
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ defmodule SafeURL do
5353
* `:schemes` - List of allowed URL schemes. Defaults to
5454
`["http, "https"]`.
5555
56+
* `:dns_module` - Any module that implements the
57+
`SafeURL.DNSResolver` behaviour. Defaults to `DNS` from
58+
the `:dns` package.
59+
60+
5661
If `:block_reserved` is `true` and additional hosts/ranges
5762
are supplied with `:blocklist`, both of them are included in
5863
the final blocklist to validate the address. If allowed
@@ -65,7 +70,8 @@ defmodule SafeURL do
6570
config :safeurl,
6671
block_reserved: true,
6772
blocklist: ~w[100.0.0.0/16],
68-
schemes: ~w[https]
73+
schemes: ~w[https],
74+
dns_module: MyCustomDNSResolver
6975
7076
Or they can be passed to the function directly, overriding any
7177
global options if set:
@@ -272,7 +278,7 @@ defmodule SafeURL do
272278
{:error, :einval} ->
273279
# TODO: safely handle multiple IPs/round-robin DNS
274280
case dns_module.resolve(hostname) do
275-
{:ok, ips} -> List.first(ips)
281+
{:ok, ips} -> ips |> List.wrap() |> List.first()
276282
{:error, _reason} -> nil
277283
end
278284
end

test/safeurl_test.exs

+15-12
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
defmodule TestDNSResolver do
2-
@behaviour DNSResolver
3-
4-
@impl DNSResolver
5-
def resolve(_domain), do: {:ok, [{192, 0, 78, 24}]}
6-
end
7-
81
defmodule SafeURLTest do
92
use ExUnit.Case
103

4+
defmodule TestDNSResolver do
5+
@behaviour SafeURL.DNSResolver
6+
7+
@impl true
8+
def resolve(_domain), do: {:ok, [{192, 0, 78, 24}]}
9+
end
10+
11+
1112
# setup_all do
1213
# global_whitelist = ["10.0.0.0/24"]
1314
# global_blacklist = ["8.8.0.0/16"]
@@ -17,12 +18,14 @@ defmodule SafeURLTest do
1718

1819
describe "#allowed?" do
1920
test "returns true for only allowed schemes" do
20-
assert SafeURL.allowed?("http://includesecurity.com", dns_module: TestDNSResolver)
21-
assert SafeURL.allowed?("https://includesecurity.com", dns_module: TestDNSResolver)
22-
refute SafeURL.allowed?("ftp://includesecurity.com", dns_module: TestDNSResolver)
21+
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)
2325

24-
assert SafeURL.allowed?("ftp://includesecurity.com", schemes: ~w[ftp], dns_module: TestDNSResolver)
25-
refute SafeURL.allowed?("http://includesecurity.com", schemes: ~w[ftp], dns_module: TestDNSResolver)
26+
opts = [schemes: ~w[ftp], dns_module: TestDNSResolver]
27+
assert SafeURL.allowed?("ftp://includesecurity.com", opts)
28+
refute SafeURL.allowed?("http://includesecurity.com", opts)
2629
end
2730

2831
test "returns false for reserved ranges" do

0 commit comments

Comments
 (0)