11defmodule 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
132284end
0 commit comments