diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f28f24..20ff58b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,18 +9,18 @@ on: jobs: build: name: CI - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: include: - elixir: '1.14' - otp: '23' + otp: '24' - elixir: '1.17' otp: '27' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Elixir uses: erlef/setup-beam@v1 with: @@ -28,7 +28,7 @@ jobs: elixir-version: ${{matrix.elixir}} - name: Restore dependencies cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: deps key: ${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}-mix-${{ hashFiles('**/mix.lock') }} diff --git a/README.md b/README.md index 0c17b12..0cd79a5 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,10 @@ To use SafeURL with your favorite HTTP Client, see the [HTTP Clients][readme-htt CIDR ranges to the blocklist, or alternatively allow specific CIDR ranges to which the application is allowed to make requests. -You can use `allowed?/2` or `validate/2` to check if a URL is safe to call. If you have the -[`HTTPoison`][lib-httpoison] application available, you can also call `get/4` which will +You can use `allowed?/2` or `validate/2` to check if a URL is safe to call, and +[`pin/2`][docs-pin] to get a URL that connects to the very address that passed. If you have the +[`HTTPoison`][lib-httpoison] application available, you can also call +[`SafeURL.HTTPoison.get/3`][docs-get] which will validate the host automatically before making a web request, and return an error otherwise. ```elixir @@ -84,7 +86,7 @@ following options: - `:schemes` - List of allowed URL schemes. Defaults to `["http, "https"]`. - `:dns_module` - Any module that implements the `SafeURL.DNSResolver` behaviour. - Defaults to `DNS` from the [`:dns`][lib-dns] package. + Defaults to `SafeURL.DNS`, which looks up A and AAAA records with the [`:dns`][lib-dns] package. - `: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`. @@ -109,37 +111,52 @@ While SafeURL already provides a convenient [`SafeURL.HTTPoison.get/3`][docs-get before making GET HTTP requests, you can also write your own wrappers, helpers or middleware to work with the HTTP Client of your choice. +Validating a URL and then handing the hostname to an HTTP client resolves it twice, and the +second lookup can return a different address than the one that was checked. Use +[`pin/2`][docs-pin] so your client connects to the address that passed, and keep the hostname +for the `Host` header, the server name and the certificate check. See +[Pinning the validated address](#pinning-the-validated-address). + ### HTTPoison -For [HTTPoison][lib-httpoison], you can create a wrapper module that validates hosts -before making HTTP requests: +[`SafeURL.HTTPoison.get/3`][docs-get] already pins the request, sends the hostname as the +`host` header and rebuilds hackney's TLS options from that hostname, so the certificate is +still verified against the name. For the other verbs, wrap it the same way: ```elixir defmodule CustomClient do def request(method, url, body, headers \\ [], opts \\ []) do {safeurl_opts, opts} = Keyword.pop(opts, :safeurl, []) - with :ok <- SafeURL.validate(url, safeurl_opts) do - HTTPoison.request(method, url, body, headers, opts) + with {:ok, pinned} <- SafeURL.pin(url, safeurl_opts) do + HTTPoison.request(method, pinned.url, body, host_header(headers, pinned), opts) end end - def get(url, headers \\ [], opts \\ []), do: request(:get, url, "", headers, opts) - def post(url, body, headers \\ [], opts \\ []), do: request(:post, url, body, headers, opts) - # ... + defp host_header(headers, pinned) do + [{"host", pinned.hostname} | Enum.reject(headers, fn {name, _} -> name == "host" end)] + end end ``` +Passing your own `:ssl_options` to hackney replaces its TLS defaults rather than adding to +them, so a wrapper that needs to pin an `https` request should build the full set from the +hostname the way `SafeURL.HTTPoison.get/3` does, or leave `https` to it. + And you can use it as: ```elixir -iex> CustomClient.get("http://230.10.10.10/data.json", [], safeurl: [block_reserved: false], recv_timeout: 500) +iex> CustomClient.request(:get, "http://230.10.10.10/data.json", "", [], safeurl: [block_reserved: false], recv_timeout: 500) {:ok, %HTTPoison.Response{...}} ``` ### Tesla -For [Tesla][lib-tesla], `SafeURL` provides a helper middleware out-of-the-box, which you can plug anywhere you're using `Tesla`: +For [Tesla][lib-tesla], `SafeURL` provides a helper middleware out-of-the-box, which you can plug anywhere you're using `Tesla`. +It pins the request where it can do so without weakening TLS, which means any request without TLS, and +`https` with `Tesla.Adapter.Hackney`. With another adapter an `https` request is validated but not pinned, +so call [`pin/2`][docs-pin] yourself and pass the hostname through that adapter's connect options. +Place it after `Tesla.Middleware.FollowRedirects` if you follow redirects, so that every hop is validated: ```elixir defmodule DocumentService do @@ -157,6 +174,40 @@ end
+## Pinning the validated address + +Validating a hostname and then handing that hostname to an HTTP client resolves it twice, +and the second lookup can return a different address than the one that was checked (DNS +rebinding). `pin/2` returns the URL with the host replaced by the validated address, together +with the original hostname for the `Host` header, SNI and certificate verification: + +```elixir +iex> SafeURL.pin("https://includesecurity.com/robots.txt") +{:ok, + %{ + url: "https://192.0.78.24/robots.txt", + hostname: "includesecurity.com", + address: {192, 0, 78, 24}, + scheme: "https", + port: 443 + }} +``` + +Only the first validated address is returned; the others were validated too. + +With [Req][lib-req], for example: + +```elixir +with {:ok, %{url: url, hostname: hostname}} <- SafeURL.pin(url) do + Req.get(url, connect_options: [hostname: hostname]) +end +``` + +Every address the host resolves to, IPv4 and IPv6, has to pass validation, and a host without +any address is rejected with `:unresolved_host`. + +
+ ## Custom DNS Resolver In some cases you might want to use a custom strategy for DNS resolution. You can do so by @@ -218,8 +269,10 @@ SafeURL is officially maintained by the team at [Slab][slab]. It was originally [includesecurity]: https://github.com/IncludeSecurity [readme-http]: #http-clients [docs]: https://hexdocs.pm/safeurl -[docs-get]: https://hexdocs.pm/safeurl/SafeURL.html#get/4 +[docs-get]: https://hexdocs.pm/safeurl/SafeURL.HTTPoison.html#get/3 [docs-dns]: https://hexdocs.pm/safeurl/SafeURL.DNSResolver.html +[docs-pin]: https://hexdocs.pm/safeurl/SafeURL.html#pin/2 +[lib-req]: https://hexdocs.pm/req [lib-dns]: https://github.com/tungd/elixir-dns [lib-tesla]: https://github.com/elixir-tesla/tesla [lib-httpoison]: https://github.com/edgurgel/httpoison diff --git a/guides/migrating_to_1.1.md b/guides/migrating_to_1.1.md new file mode 100644 index 0000000..79c88fb --- /dev/null +++ b/guides/migrating_to_1.1.md @@ -0,0 +1,92 @@ +# Migrating to v1.1 + +`v1.1.0` fixes two advisories against every earlier release, +[CVE-2026-77866](https://cna.erlef.org/cves/CVE-2026-77866.html) and +[CVE-2026-77972](https://cna.erlef.org/cves/CVE-2026-77972.html). It stays on +the `1.x` line so that `{:safeurl, "~> 1.0"}` picks the fix up, but it does +reject some URLs that `v1.0.0` accepted. All of those were the holes. + +## Every resolved address is checked + +`v1.0.0` looked at the first IPv4 address of a host and treated everything else +as matching no range. An address written in IPv6 form passed even when its IPv4 +form was blocked, IPv6 entries in a blocklist never matched, and a host with no +A record was accepted whatever it pointed at. + +Now the host is resolved once and every address it has, in both families, has +to pass. IPv4-mapped (`::ffff:10.0.0.1`) and IPv4-compatible (`::10.0.0.1`) +addresses are checked as the IPv4 address they carry, and the reserved list +covers the IPv6 blocks IANA marks as not globally reachable. + +**What changes for you:** a host whose AAAA record points into a reserved range +is now rejected, where before only its A record was judged. + +## A host that resolves to nothing is rejected + +`v1.0.0` passed `nil` into the range check for such a host, which matched +nothing and so allowed the request. These now fail with the new error +`{:error, :unresolved_host}`, which is part of `t:SafeURL.error/0`. Handle it +wherever you match on specific errors, or set `detailed_error: false` and match +on `{:error, :restricted}`. + +This also catches hosts your resolver cannot answer for, such as names that +only exist in `/etc/hosts` or internationalised names that were not converted +to their ASCII form first. + +## The default resolver queries A and AAAA + +`:dns_module` now defaults to `SafeURL.DNS` rather than `DNS`. A resolver has +to return every address of every family for the guarantee above to hold, so if +you pass your own, make sure it does. `SafeURL.DNS` also bounds each lookup, +five seconds by default: + +```elixir +config :safeurl, SafeURL.DNS, timeout: :timer.seconds(2) +``` + +The `c:SafeURL.DNSResolver.resolve/1` callback's typespec widened from +`{:error, :inet_res.res_error()}` to `{:error, term()}`. Existing +implementations keep working. + +## Requests go to the address that passed + +`validate/2` only ever returned a verdict, so the hostname was handed to an +HTTP client which resolved it a second time. A name that answered with a public +address for the check could answer with an internal one for the request. + +`SafeURL.pin/2` returns the URL with the validated address in place of the +host, plus the hostname for the `Host` header, the server name and the +certificate check: + +```elixir +with {:ok, %{url: url, hostname: hostname}} <- SafeURL.pin("https://example.com/data") do + Req.get(url, connect_options: [hostname: hostname]) +end +``` + +`SafeURL.HTTPoison.get/3` does this for you. `SafeURL.TeslaMiddleware` does it +for requests without TLS and for `https` with `Tesla.Adapter.Hackney`; with any +other adapter it validates but cannot pin, because keeping certificate +verification pointed at the hostname is spelled differently by each one. + +**What changes for you:** nothing in the call, but the destination is reached +at the checked address. If you pass your own `:ssl_options` or `:insecure` to +`SafeURL.HTTPoison.get/3`, securing the connection stays yours. + +## Redirects are not followed for you + +`SafeURL.HTTPoison.get/3` used to pass `:follow_redirect` straight to hackney, +which then requested whatever destination the server named, without validating +it. That option now raises, whether it is given directly or under `:hackney`. +Follow the redirect yourself by validating the location that came back and +requesting it. + +`SafeURL.TeslaMiddleware` raises the same way when the adapter is configured to +follow redirects itself, because it does that after the middleware has run. Put +the middleware after `Tesla.Middleware.FollowRedirects` instead, so that every +hop passes through it. The other order validates the first request only. + +## The reserved list widened + +`192.0.0.0/29` became `192.0.0.0/24`, which is what IANA reserves. If you route +traffic in `192.0.0.8` through `192.0.0.255`, allow it explicitly. diff --git a/lib/safeurl/client.ex b/lib/safeurl/client.ex new file mode 100644 index 0000000..2a70131 --- /dev/null +++ b/lib/safeurl/client.ex @@ -0,0 +1,64 @@ +defmodule SafeURL.Client do + @moduledoc false + + # Shared by the HTTP clients this library ships. They turn a pinned + # destination into what a client needs so that the request reaches the + # validated address while the hostname still governs what the destination + # sees and what its certificate is checked against. + + # hackney derives its TLS options from the host it is given, so once a request + # is pinned to an address it would offer that address as the server name and + # verify the certificate against it. These are hackney's own defaults, rebuilt + # from the hostname instead. + def hackney_ssl_options(hostname) do + hostname + |> to_charlist() + |> :hackney_ssl.check_hostname_opts() + |> Kernel.++(:hackney_ssl.cipher_opts()) + end + + # The caller has said how the connection should be secured, so nothing is + # rebuilt for them. + def tls_configured?(options) do + Keyword.has_key?(options, :ssl_options) or Keyword.has_key?(options, :insecure) + end + + # A redirect names a destination the server chose, which this library never + # got to validate, so a client that would follow one on its own is refused. + def check_no_redirects!(options, where) do + if Keyword.get(options, :follow_redirect, false) do + raise ArgumentError, """ + :follow_redirect cannot be used with #{where}, because the destination of \ + a redirect is chosen by the server and would be requested without being \ + validated. Follow the redirect yourself, by validating the location that \ + came back and requesting it.\ + """ + end + + :ok + end + + # The destination's own authority, which is the hostname plus the port when + # it is not the scheme's default. + def put_host_header(headers, pinned) when is_map(headers) do + headers + |> Map.reject(&host_header?/1) + |> Map.put("host", authority(pinned)) + end + + def put_host_header(headers, pinned) do + [{"host", authority(pinned)} | Enum.reject(headers, &host_header?/1)] + end + + defp authority(pinned) do + if pinned.port == URI.default_port(pinned.scheme) do + pinned.hostname + else + "#{pinned.hostname}:#{pinned.port}" + end + end + + defp host_header?({name, _value}) do + String.downcase(to_string(name)) == "host" + end +end diff --git a/lib/safeurl/dns.ex b/lib/safeurl/dns.ex new file mode 100644 index 0000000..65ef39c --- /dev/null +++ b/lib/safeurl/dns.ex @@ -0,0 +1,42 @@ +defmodule SafeURL.DNS do + @moduledoc since: "1.1.0" + @moduledoc """ + The default `SafeURL.DNSResolver`: looks up both the A and the AAAA + records of a host with the `:dns` package, IPv4 addresses first. + + A host without records in either family resolves to `{:error, :nxdomain}`. + + Both lookups are bounded by `:timeout` (5 seconds by default), which can + be configured with + + config :safeurl, SafeURL.DNS, timeout: :timer.seconds(2) + + """ + + @behaviour SafeURL.DNSResolver + + @default_timeout :timer.seconds(5) + + @impl SafeURL.DNSResolver + def resolve(host) do + case records(host, :a) ++ records(host, :aaaa) do + [] -> {:error, :nxdomain} + ips -> {:ok, ips} + end + end + + # DNS.resolve/4 waits forever by default, which would hang the caller on a + # server that accepts the query and never answers. + defp records(host, type) do + case DNS.resolve(host, type, [], timeout()) do + {:ok, ips} -> ips + {:error, _reason} -> [] + end + end + + defp timeout do + :safeurl + |> Application.get_env(__MODULE__, []) + |> Keyword.get(:timeout, @default_timeout) + end +end diff --git a/lib/safeurl/dns_resolver.ex b/lib/safeurl/dns_resolver.ex index 14648f1..7b714a4 100644 --- a/lib/safeurl/dns_resolver.ex +++ b/lib/safeurl/dns_resolver.ex @@ -5,10 +5,14 @@ defmodule SafeURL.DNSResolver do implementation of `SafeURL.DNSResolver` in the global or local config. - By default, the `DNS` package is used for resolution, - but you can replace it with a wrapper that uses - different configuration or a completely different - implementation altogether. + By default, `SafeURL.DNS` looks up the A and AAAA records + with the `DNS` package, but you can replace it with a + wrapper that uses different configuration or a completely + different implementation altogether. + + A resolver has to return every address the host resolves + to: `SafeURL` validates all of them and rejects the URL if + any one is not allowed. ## Use-cases @@ -25,6 +29,11 @@ defmodule SafeURL.DNSResolver do only one `c:resolve/1` callback that takes a host and returns a list of resolved IPs. + A resolver must return every address of every family the + host has. Returning only the A records would let a host + whose AAAA record points at an internal address through, + which is what `SafeURL.DNS` avoids by querying both. + As an example, suppose you wanted to use [Cloudflare's DNS](https://1.1.1.1/dns/), you can do that by wrapping `DNS` with your own settings in a new @@ -35,7 +44,17 @@ defmodule SafeURL.DNSResolver do @impl true def resolve(domain) do - DNS.resolve(domain, :a, {"1.1.1.1", 53}, :udp) + with {:ok, ipv4} <- query(domain, :a), + {:ok, ipv6} <- query(domain, :aaaa) do + {:ok, ipv4 ++ ipv6} + end + end + + defp query(domain, type) do + case DNS.resolve(domain, type, {"1.1.1.1", 53}, :udp) do + {:ok, ips} -> {:ok, ips} + {:error, _reason} -> {:ok, []} + end end end @@ -67,7 +86,10 @@ defmodule SafeURL.DNSResolver do def resolve(_domain), do: {:ok, [{192, 168, 1, 99}]} end + A resolver that returns an empty list, or an error, makes + the URL fail validation with `{:error, :unresolved_host}`. + """ - @callback resolve(host :: String.t()) :: {:ok, list()} | {:error, :inet_res.res_error()} + @callback resolve(host :: String.t()) :: {:ok, [:inet.ip_address()]} | {:error, term()} end diff --git a/lib/safeurl/httpoison.ex b/lib/safeurl/httpoison.ex index 88fe3dd..82667ae 100644 --- a/lib/safeurl/httpoison.ex +++ b/lib/safeurl/httpoison.ex @@ -6,6 +6,10 @@ if Code.ensure_loaded?(HTTPoison) do supports `get/3`. """ + alias SafeURL.Client + + @where "SafeURL.HTTPoison.get/3" + @doc since: "1.0.0" @doc """ Validate a URL and execute a GET request using `HTTPoison`. @@ -14,8 +18,23 @@ if Code.ensure_loaded?(HTTPoison) do `HTTPoison`, returning the result directly. Otherwise, it will return error. - `headers` and `options` will be passed directly to - `HTTPoison` when the request is executed. + The request is sent to the address that passed validation rather than to + the hostname, so the name cannot resolve to a different address between the + check and the request (see [`Pinning`](SafeURL.html#module-pinning)). The + hostname is still what the destination sees in the `host` header, and what + hackney offers as the server name and verifies the certificate against. + Supplying your own `:ssl_options` or `:insecure` leaves that setup to you. + + Validation options go under `:safeurl`, the same ones + [`SafeURL.pin/2`](SafeURL.html#pin/2) takes: + + SafeURL.HTTPoison.get(url, [], safeurl: [allowlist: ~w[10.0.0.0/8]]) + + Everything else in `headers` and `options` is passed directly to + `HTTPoison` when the request is executed, except for `:follow_redirect`: a redirect names a + destination that was never validated, so following it would step around + the check. Passing it, directly or under `:hackney`, raises + `ArgumentError`. ## Examples @@ -32,8 +51,32 @@ if Code.ensure_loaded?(HTTPoison) do | {:error, SafeURL.error()} | {:error, :restricted} def get(url, headers \\ [], options \\ []) do - with :ok <- SafeURL.validate(url) do - HTTPoison.get(url, headers, options) + {safeurl_options, options} = Keyword.pop(options, :safeurl, []) + check_no_redirects!(options) + + with {:ok, pinned} <- SafeURL.pin(url, safeurl_options) do + HTTPoison.get( + pinned.url, + Client.put_host_header(headers, pinned), + put_tls_options(options, pinned) + ) + end + end + + # hackney honours the option under :hackney as well as at the top level. + defp check_no_redirects!(options) do + Client.check_no_redirects!(options, @where) + Client.check_no_redirects!(Keyword.get(options, :hackney, []), @where) + end + + defp put_tls_options(options, pinned) do + hackney = Keyword.get(options, :hackney, []) + + if pinned.scheme == "https" and not Client.tls_configured?(hackney) do + ssl_options = Client.hackney_ssl_options(pinned.hostname) + Keyword.put(options, :hackney, Keyword.put(hackney, :ssl_options, ssl_options)) + else + options end end end diff --git a/lib/safeurl/safeurl.ex b/lib/safeurl/safeurl.ex index 14f7ff4..467c813 100644 --- a/lib/safeurl/safeurl.ex +++ b/lib/safeurl/safeurl.ex @@ -8,7 +8,8 @@ defmodule SafeURL do allowed to make requests. You can use `allowed?/2` or `validate/2` to check if a - URL is safe to call. + URL is safe to call, and `pin/2` to get a URL that connects + to the very address that passed validation. ## Examples @@ -25,6 +26,16 @@ defmodule SafeURL do iex> SafeURL.validate("http://230.10.10.10/", block_reserved: false) :ok + iex> SafeURL.pin("https://includesecurity.com/robots.txt") + {:ok, + %{ + url: "https://192.0.78.24/robots.txt", + hostname: "includesecurity.com", + address: {192, 0, 78, 24}, + scheme: "https", + port: 443 + }} + # If HTTPoison is available: iex> SafeURL.HTTPoison.get("https://10.0.0.1/ssrf.txt") @@ -34,6 +45,31 @@ defmodule SafeURL do {:ok, %HTTPoison.Response{...}} + ## Validation + + The host of the URL is resolved once, and every address it + resolves to (IPv4 and IPv6) has to pass the allowlist or the + blocklist. A host that does not resolve to any address is + rejected with `:unresolved_host` rather than let through. + + IPv4-mapped (`::ffff:10.0.0.1`) and IPv4-compatible (`::10.0.0.1`) + addresses are checked as the IPv4 address they carry. + + + ## Pinning + + Validating a hostname and then handing that hostname to an HTTP + client resolves it twice, and the second lookup can return a + different address than the one that was checked (DNS rebinding). + `pin/2` returns the URL with the host replaced by the validated + address, together with the original hostname, so the client + connects to the checked address and still sends the right `Host` + header, SNI and certificate hostname: + + {:ok, %{url: url, hostname: hostname}} = SafeURL.pin("https://example.com/data") + Req.get!(url, connect_options: [hostname: hostname]) + + ## Options `SafeURL` can be configured to customize and override @@ -52,8 +88,8 @@ defmodule SafeURL do `["http, "https"]`. * `:dns_module` - Any module that implements the - `SafeURL.DNSResolver` behaviour. Defaults to `DNS` from - the `:dns` package. + `SafeURL.DNSResolver` behaviour. Defaults to `SafeURL.DNS`, + which looks up A and AAAA records with the `:dns` package. * `:detailed_error` - Return specific error if validation fails. If set to `false`, `validate/2` will return `{:error, :restricted}` regardless of @@ -89,25 +125,63 @@ defmodule SafeURL do """ - @reserved_ranges [ - "0.0.0.0/8", - "10.0.0.0/8", - "100.64.0.0/10", - "127.0.0.0/8", - "169.254.0.0/16", - "172.16.0.0/12", - "192.0.0.0/29", - "192.0.2.0/24", - "192.88.99.0/24", - "192.168.0.0/16", - "198.18.0.0/15", - "198.51.100.0/24", - "203.0.113.0/24", - "224.0.0.0/4", - "240.0.0.0/4" - ] - - @type error() :: :unsafe_scheme | :unsafe_allowlist | :unsafe_blocklist | :unsafe_reserved + import Bitwise + + # Every block IANA marks as not globally reachable, in both families. + # IPv4-mapped and IPv4-compatible addresses are not listed: normalize/1 + # rewrites them to the IPv4 address they carry before they are matched. + @reserved_ranges Enum.map( + ~w[ + 0.0.0.0/8 + 10.0.0.0/8 + 100.64.0.0/10 + 127.0.0.0/8 + 169.254.0.0/16 + 172.16.0.0/12 + 192.0.0.0/24 + 192.0.2.0/24 + 192.88.99.0/24 + 192.168.0.0/16 + 198.18.0.0/15 + 198.51.100.0/24 + 203.0.113.0/24 + 224.0.0.0/4 + 240.0.0.0/4 + ::/128 + ::1/128 + ::ffff:0:0:0/96 + 64:ff9b::/96 + 64:ff9b:1::/48 + 100::/64 + 100:0:0:1::/64 + 2001::/23 + 2001:db8::/32 + 2002::/16 + 2620:4f:8000::/48 + 3fff::/20 + 5f00::/16 + fc00::/7 + fe80::/10 + fec0::/10 + ff00::/8 + ], + &InetCidr.parse_cidr!/1 + ) + + @type error() :: + :unsafe_scheme + | :unsafe_allowlist + | :unsafe_blocklist + | :unsafe_reserved + | :unresolved_host + + @type pinned() :: %{ + url: binary(), + hostname: binary(), + address: :inet.ip_address(), + scheme: binary(), + port: :inet.port_number() + } # Public API # ---------- @@ -116,7 +190,7 @@ defmodule SafeURL do Validate a string URL against a blocklist or allowlist. This method checks if a URL is safe to be called by looking at - its scheme and resolved IP address, and matching it against + its scheme and resolved IP addresses, and matching them against reserved CIDR ranges, and any provided allowlist/blocklist. Returns `true` if the URL meets the requirements, @@ -150,11 +224,13 @@ defmodule SafeURL do Alternative method of validating a URL, returning result tuple instead of booleans. - This calls `allowed?/2` underneath to check if a URL is safe to - be called. If it is, it returns `:ok`, otherwise an error tuple with a + If the URL is safe, it returns `:ok`, otherwise an error tuple with a specific reason. If `:detailed_error` is set to `false`, the error is always `{:error, :restricted}`. + Prefer `pin/2` when you are about to make the request yourself, see + [`Pinning`](#module-pinning). + ## Examples iex> SafeURL.validate("https://includesecurity.com") @@ -173,41 +249,96 @@ defmodule SafeURL do """ @spec validate(binary(), Keyword.t()) :: :ok | {:error, error()} | {:error, :restricted} def validate(url, opts \\ []) do - uri = URI.parse(url) - opts = build_options(opts) - address = resolve_address(uri.host, opts.dns_module) + with {:ok, _pinned} <- pin(url, opts) do + :ok + end + end - result = - cond do - uri.scheme not in opts.schemes -> - {:error, :unsafe_scheme} + @doc """ + Validate a URL and return it pinned to the address that passed. - opts.allowlist != [] -> - if ip_in_ranges?(address, opts.allowlist), do: :ok, else: {:error, :unsafe_allowlist} + The returned `:url` has the host replaced by the first validated + address (the others are validated too, but not returned), `:hostname` + is the host as it was in the URL, for the `Host` header, SNI and + certificate verification, and `:address`, `:scheme` and `:port` are the + parts a client needs to open the connection itself. Connecting to `:url` instead + of the original one makes sure the request reaches the address that + was checked, see [`Pinning`](#module-pinning). - opts.blocklist != [] and ip_in_ranges?(address, opts.blocklist) -> - {:error, :unsafe_blocklist} + Errors are the same as for `validate/2`. - opts.block_reserved and ip_in_ranges?(address, @reserved_ranges) -> - {:error, :unsafe_reserved} + ## Examples - true -> - :ok - end + iex> SafeURL.pin("https://includesecurity.com/robots.txt") + {:ok, + %{ + url: "https://192.0.78.24/robots.txt", + hostname: "includesecurity.com", + address: {192, 0, 78, 24}, + scheme: "https", + port: 443 + }} + + iex> SafeURL.pin("https://[::1]/") + {:error, :unsafe_reserved} - with {:error, _} <- result do - if opts.detailed_error, do: result, else: {:error, :restricted} - end + ## Options + + See [`Options`](#module-options) section above. + + """ + @doc since: "1.1.0" + @spec pin(binary(), Keyword.t()) :: {:ok, pinned()} | {:error, error()} | {:error, :restricted} + def pin(url, opts \\ []) do + options = build_options(opts) + + url + |> URI.parse() + |> validate_and_pin(options) + |> obscure_reason(options) end # Private Helpers # --------------- + defp validate_and_pin(uri, opts) do + with :ok <- validate_scheme(uri.scheme, opts), + {:ok, addresses} <- resolve_addresses(uri.host, opts.dns_module), + :ok <- validate_addresses(addresses, opts) do + {:ok, pinned(uri, hd(addresses))} + end + end + + # Only the first address is handed back, but every one of them was checked, + # so a caller that reaches for another is not reaching past validation. + defp pinned(uri, address) do + host = + address + |> :inet.ntoa() + |> List.to_string() + + %{ + url: URI.to_string(%{uri | host: host}), + hostname: uri.host, + address: address, + scheme: uri.scheme, + port: uri.port + } + end + + # With :detailed_error off, a caller learns that the URL was refused and not + # what about it was refused. + defp obscure_reason(result, opts) do + with {:error, _reason} <- result do + if opts.detailed_error, do: result, else: {:error, :restricted} + end + end + # Return a map of calculated options defp build_options(opts) do schemes = get_option(opts, :schemes) - allowlist = get_option(opts, :allowlist) - blocklist = get_option(opts, :blocklist) + allowlist = parse_ranges(get_option(opts, :allowlist)) + blocklist = parse_ranges(get_option(opts, :blocklist)) dns_module = get_option(opts, :dns_module) block_reserved = get_option(opts, :block_reserved) detailed_error = get_option(opts, :detailed_error) @@ -227,31 +358,92 @@ defmodule SafeURL do defp get_option(opts, key), do: Keyword.get_lazy(opts, key, fn -> Application.get_env(:safeurl, key) end) - # Resolve hostname in DNS to an IP address (if not already an IP) - defp resolve_address(hostname, dns_module) do - hostname - |> to_charlist() - |> :inet.parse_address() - |> case do + # Parsed once per call rather than once per address, like @reserved_ranges. + defp parse_ranges(ranges), do: Enum.map(ranges, &InetCidr.parse_cidr!/1) + + defp validate_scheme(scheme, opts) do + if scheme in opts.schemes, do: :ok, else: {:error, :unsafe_scheme} + end + + # Resolve hostname in DNS to its IP addresses (if not already an IP). + # A host without any address is an error: letting it through would + # let the HTTP client connect to whatever it resolves to later. + defp resolve_addresses(hostname, _dns_module) when hostname in [nil, ""] do + {:error, :unresolved_host} + end + + defp resolve_addresses(hostname, dns_module) do + case :inet.parse_address(to_charlist(hostname)) do {:ok, ip} -> - ip + {:ok, [normalize(ip)]} {:error, :einval} -> - # TODO: safely handle multiple IPs/round-robin DNS - case dns_module.resolve(hostname) do - {:ok, [ip | _]} -> ip - {:error, _reason} -> nil - end + resolve_in_dns(hostname, dns_module) + end + end + + # A resolver following a CNAME can hand back the target name next to the + # addresses, so only the addresses are kept. + defp resolve_in_dns(hostname, dns_module) do + case dns_module.resolve(hostname) do + {:ok, records} when is_list(records) -> only_addresses(records) + _no_address -> {:error, :unresolved_host} + end + end + + defp only_addresses(records) do + case Enum.filter(records, &is_tuple/1) do + [] -> {:error, :unresolved_host} + ips -> {:ok, Enum.map(ips, &normalize/1)} end end - defp ip_in_ranges?({_, _, _, _} = addr, ranges) when is_list(ranges) do - Enum.any?(ranges, fn range -> - range - |> InetCidr.parse_cidr!() - |> InetCidr.contains?(addr) + # An IPv4-mapped (::ffff:a.b.c.d) or IPv4-compatible (::a.b.c.d) address + # reaches the IPv4 host it carries, so it is checked as that IPv4 address. + # The guard leaves out the unspecified and loopback addresses, which share + # the compatible shape without carrying an IPv4 host. + defp normalize({0, 0, 0, 0, 0, 0, high, low}) when high > 0 or low > 1 do + embedded_ipv4(high, low) + end + + defp normalize({0, 0, 0, 0, 0, 0xFFFF, high, low}) do + embedded_ipv4(high, low) + end + + defp normalize(ip), do: ip + + defp embedded_ipv4(high, low) do + {high >>> 8, high &&& 0xFF, low >>> 8, low &&& 0xFF} + end + + # Every address the host resolves to has to pass, otherwise a host with one + # public and one internal address gets through on the public one. + defp validate_addresses(addresses, opts) do + Enum.reduce_while(addresses, :ok, fn address, :ok -> + case validate_address(address, opts) do + :ok -> {:cont, :ok} + error -> {:halt, error} + end end) end - defp ip_in_ranges?(_addr, _ranges), do: false + defp validate_address(address, opts) do + cond do + opts.allowlist != [] -> + if ip_in_ranges?(address, opts.allowlist), do: :ok, else: {:error, :unsafe_allowlist} + + opts.blocklist != [] and ip_in_ranges?(address, opts.blocklist) -> + {:error, :unsafe_blocklist} + + opts.block_reserved and ip_in_ranges?(address, @reserved_ranges) -> + {:error, :unsafe_reserved} + + true -> + :ok + end + end + + defp ip_in_ranges?(address, ranges) do + Enum.any?(ranges, &InetCidr.contains?(&1, address)) + end end diff --git a/lib/safeurl/tesla_middleware.ex b/lib/safeurl/tesla_middleware.ex index 5d5ef1f..88492d7 100644 --- a/lib/safeurl/tesla_middleware.ex +++ b/lib/safeurl/tesla_middleware.ex @@ -8,12 +8,107 @@ if Code.ensure_loaded?(Tesla) do iex> Tesla.client([SafeURL.TeslaMiddleware]) |> Tesla.get("http://localhost/") {:error, :unsafe_reserved} + + ## Pinning + + Where it can, the middleware also sends the request to the address that + passed validation instead of to the hostname, so the name cannot resolve to + a different address between the check and the request (see + [`Pinning`](SafeURL.html#module-pinning)). The destination still sees the + hostname in the `Host` header. + + Doing that over TLS means telling the adapter to keep verifying the + certificate against the hostname, which every adapter spells differently, + so pinning applies to: + + * any request without TLS, whatever the adapter, and + * `Tesla.Adapter.Hackney` over TLS. + + With any other adapter an `https` request is validated but not pinned, and + the rebinding window stays open. To close it there, call `SafeURL.pin/2` + yourself and pass the hostname through your adapter's connect options. + + ## Redirects + + A redirect names a destination the server chose, so it has to be validated + like any other. Place this middleware *after* `Tesla.Middleware.FollowRedirects` + and every hop passes through it: + + plug Tesla.Middleware.FollowRedirects + plug SafeURL.TeslaMiddleware + + The other order validates the first request only. """ @behaviour Tesla.Middleware + alias SafeURL.Client + + @where "SafeURL.TeslaMiddleware" + def call(%Tesla.Env{url: url} = env, next, options) do - with :ok <- SafeURL.validate(url, options) do - Tesla.run(env, next) + check_no_redirects!(env) + + with {:ok, pinned} <- SafeURL.pin(url, options) do + env + |> pin(pinned) + |> Tesla.run(next) + end + end + + # An adapter that follows redirects itself does so after this middleware + # has run, so the destination it picks is never validated. It can be asked + # to on the client or on the single request. + defp check_no_redirects!(env) do + {_module, options} = adapter(env) + + Client.check_no_redirects!(options, @where) + Client.check_no_redirects!(Keyword.get(env.opts, :adapter, []), @where) + end + + defp pin(env, pinned) do + {module, adapter_options} = adapter(env) + + cond do + pinned.scheme != "https" -> repoint(env, pinned, []) + module != Tesla.Adapter.Hackney -> env + Client.tls_configured?(adapter_options) -> repoint(env, pinned, []) + true -> repoint(env, pinned, ssl_options: Client.hackney_ssl_options(pinned.hostname)) + end + end + + # A caller who configured TLS themselves, on the client or on the request, + # keeps it, the same way supplying :ssl_options to + # SafeURL.HTTPoison.get/3 leaves that setup to them. + defp repoint(env, pinned, tls_options) do + %{ + env + | url: pinned.url, + headers: Client.put_host_header(env.headers, pinned), + opts: put_adapter_options(env.opts, tls_options) + } + end + + defp put_adapter_options(opts, []) do + opts + end + + defp put_adapter_options(opts, tls_options) do + Keyword.update(opts, :adapter, tls_options, &Keyword.merge(tls_options, &1)) + end + + # Tesla resolves the adapter before the middleware stack runs, so the + # client carries the one this request will actually use, along with the + # options it was configured with. + defp adapter(env) do + case env.__client__ do + %Tesla.Client{adapter: {module, _fun, [options]}} when is_list(options) -> + {module, options} + + %Tesla.Client{adapter: {module, _fun, _args}} -> + {module, []} + + _other -> + {nil, []} end end end diff --git a/mix.exs b/mix.exs index 3793174..4b0c7e6 100644 --- a/mix.exs +++ b/mix.exs @@ -3,7 +3,7 @@ defmodule SafeURL.MixProject do @app :safeurl @name "SafeURL" - @version "1.0.0" + @version "1.1.0" @github "https://github.com/slab/safeurl-elixir" def project do @@ -24,7 +24,11 @@ defmodule SafeURL.MixProject do source_url: @github, homepage_url: @github, canonical: "https://hexdocs.pm/#{@app}", - extras: ["README.md", "guides/migrating_to_1.0.md"] + extras: [ + "README.md", + "guides/migrating_to_1.1.md", + "guides/migrating_to_1.0.md" + ] ] ] end @@ -44,7 +48,7 @@ defmodule SafeURL.MixProject do block_reserved: true, blocklist: [], allowlist: [], - dns_module: DNS, + dns_module: SafeURL.DNS, detailed_error: true ] end @@ -53,6 +57,9 @@ defmodule SafeURL.MixProject do defp deps do [ {:httpoison, "~> 1.0 or ~> 2.0", optional: true}, + # SafeURL.Client rebuilds hackney's TLS defaults from the hostname, which + # needs :hackney_ssl.check_hostname_opts/1, exported since 1.16.0. + {:hackney, "~> 1.16", optional: true}, {:inet_cidr, "~> 1.0 and >= 1.0.6"}, {:dns, "~> 2.4"}, {:tesla, "~> 1.0", optional: true}, @@ -70,7 +77,7 @@ defmodule SafeURL.MixProject do name: @app, maintainers: ["Slab"], licenses: ["BSD-3-Clause"], - files: ~w(mix.exs lib README.md), + files: ~w(mix.exs lib guides README.md), links: %{ "Github" => @github, "Slab" => "https://slab.com/" diff --git a/test/safeurl/client_test.exs b/test/safeurl/client_test.exs new file mode 100644 index 0000000..a07805c --- /dev/null +++ b/test/safeurl/client_test.exs @@ -0,0 +1,55 @@ +defmodule SafeURL.ClientTest do + use ExUnit.Case, async: true + + alias SafeURL.Client + + @pinned %{ + url: "https://93.184.216.34/data", + hostname: "public.example", + address: {93, 184, 216, 34}, + scheme: "https", + port: 443 + } + + describe "put_host_header/2" do + test "sets the hostname and drops one the caller sent" do + headers = [{"Host", "spoofed"}, {"accept", "application/json"}] + + assert Client.put_host_header(headers, @pinned) == + [{"host", "public.example"}, {"accept", "application/json"}] + end + + test "adds the port when it is not the scheme default" do + assert [{"host", "public.example:8443"}] = + Client.put_host_header([], %{@pinned | port: 8443}) + end + + test "works on the map form" do + assert %{"host" => "public.example"} = + Client.put_host_header(%{"HOST" => "spoofed"}, @pinned) + end + end + + describe "hackney_ssl_options/1" do + test "verifies the certificate against the hostname, not the pinned address" do + options = Client.hackney_ssl_options("public.example") + + assert Keyword.get(options, :verify) == :verify_peer + assert Keyword.get(options, :server_name_indication) == ~c"public.example" + assert Keyword.has_key?(options, :cacerts) + end + end + + describe "check_no_redirects!/2" do + test "passes when the caller did not ask for redirects" do + assert :ok = Client.check_no_redirects!([], "somewhere") + assert :ok = Client.check_no_redirects!([follow_redirect: false], "somewhere") + end + + test "raises when it did" do + assert_raise ArgumentError, ~r/:follow_redirect cannot be used with somewhere/, fn -> + Client.check_no_redirects!([follow_redirect: true], "somewhere") + end + end + end +end diff --git a/test/safeurl/httpoison_test.exs b/test/safeurl/httpoison_test.exs new file mode 100644 index 0000000..b844b1d --- /dev/null +++ b/test/safeurl/httpoison_test.exs @@ -0,0 +1,31 @@ +defmodule SafeURL.HTTPoisonTest do + use ExUnit.Case, async: true + + test "refuses to validate a request whose redirects it cannot follow" do + assert_raise ArgumentError, ~r/:follow_redirect cannot be used/, fn -> + SafeURL.HTTPoison.get("https://includesecurity.com/", [], follow_redirect: true) + end + end + + test "refuses redirects asked for through the hackney options" do + assert_raise ArgumentError, ~r/:follow_redirect cannot be used/, fn -> + SafeURL.HTTPoison.get("https://includesecurity.com/", [], hackney: [follow_redirect: true]) + end + end + + test "rejects an unsafe URL before any request is made" do + assert {:error, :unsafe_reserved} = SafeURL.HTTPoison.get("https://10.0.0.1/ssrf.txt") + end + + test "validates with the options the caller passes" do + options = [safeurl: [dns_module: TestDNSResolver, allowlist: ["127.0.0.0/16"]]] + + assert {:error, %HTTPoison.Error{}} = + SafeURL.HTTPoison.get("http://allowlisted:9/", [], options) + + assert {:error, :unsafe_reserved} = + SafeURL.HTTPoison.get("http://allowlisted:9/", [], + safeurl: [dns_module: TestDNSResolver] + ) + end +end diff --git a/test/safeurl/tesla_middleware_test.exs b/test/safeurl/tesla_middleware_test.exs index 04d132f..0a47967 100644 --- a/test/safeurl/tesla_middleware_test.exs +++ b/test/safeurl/tesla_middleware_test.exs @@ -5,6 +5,14 @@ defmodule SafeURL.TeslaMiddlewareTest do alias SafeURL.TeslaMiddleware + defmodule RedirectDNSResolver do + @behaviour SafeURL.DNSResolver + + @impl true + def resolve("public.example"), do: {:ok, [{93, 184, 216, 34}]} + def resolve(_host), do: {:ok, [{10, 0, 0, 1}]} + end + test "can pass options" do Tesla.Mock.mock(fn _ -> %Tesla.Env{status: 200} end) @@ -17,6 +25,120 @@ defmodule SafeURL.TeslaMiddlewareTest do assert {:ok, %{status: 200}} = Tesla.get(client, "http://blocked_but_allowlisted") end + test "sends the request to the validated address and keeps the hostname" do + Tesla.Mock.mock(fn env -> + assert env.url == "http://127.0.0.1/data" + assert Tesla.get_header(env, "host") == "blocked_but_allowlisted" + %Tesla.Env{status: 200} + end) + + client = allowlisted_client() + + assert {:ok, %{status: 200}} = Tesla.get(client, "http://blocked_but_allowlisted/data") + end + + test "replaces a host header the caller set" do + Tesla.Mock.mock(fn env -> + assert Tesla.get_headers(env, "host") == ["blocked_but_allowlisted"] + %Tesla.Env{status: 200} + end) + + client = allowlisted_client() + + assert {:ok, %{status: 200}} = + Tesla.get(client, "http://blocked_but_allowlisted/data", + headers: [{"Host", "spoofed"}] + ) + end + + test "leaves an https request alone when the adapter cannot keep verifying the hostname" do + Tesla.Mock.mock(fn env -> + assert env.url == "https://blocked_but_allowlisted/data" + assert Tesla.get_header(env, "host") == nil + %Tesla.Env{status: 200} + end) + + client = allowlisted_client() + + assert {:ok, %{status: 200}} = Tesla.get(client, "https://blocked_but_allowlisted/data") + end + + defp allowlisted_client do + Tesla.client( + [{TeslaMiddleware, [allowlist: ["127.0.0.0/16"], dns_module: TestDNSResolver]}], + Tesla.Mock + ) + end + + test "validates every hop when it runs after the redirect middleware" do + Tesla.Mock.mock(fn + %{url: "http://93.184.216.34/start"} = env -> + assert Tesla.get_header(env, "host") == "public.example" + %Tesla.Env{status: 301, headers: [{"location", "http://internal.example/next"}]} + + %{url: url} -> + flunk("the redirect to #{url} was requested without being validated") + end) + + client = + Tesla.client( + [ + Tesla.Middleware.FollowRedirects, + {TeslaMiddleware, dns_module: RedirectDNSResolver} + ], + Tesla.Mock + ) + + assert {:error, :unsafe_reserved} = Tesla.get(client, "http://public.example/start") + end + + test "refuses an adapter that follows redirects on its own" do + client = + Tesla.client( + [{TeslaMiddleware, dns_module: RedirectDNSResolver}], + {Tesla.Adapter.Hackney, follow_redirect: true} + ) + + assert_raise ArgumentError, ~r/:follow_redirect cannot be used/, fn -> + Tesla.get(client, "http://public.example/start") + end + end + + test "sends the port in the host header when it is not the default" do + Tesla.Mock.mock(fn env -> + assert env.url == "http://93.184.216.34:8080/data" + assert Tesla.get_header(env, "host") == "public.example:8080" + %Tesla.Env{status: 200} + end) + + client = Tesla.client([{TeslaMiddleware, dns_module: RedirectDNSResolver}], Tesla.Mock) + + assert {:ok, %{status: 200}} = Tesla.get(client, "http://public.example:8080/data") + end + + test "rebuilds the TLS options of a hackney client from the hostname" do + env = hackney_env([]) + + assert {:ok, pinned} = TeslaMiddleware.call(env, [], dns_module: RedirectDNSResolver) + assert pinned.url == "https://93.184.216.34/data" + assert pinned.opts[:adapter][:ssl_options] != nil + end + + test "keeps TLS options the client was configured with" do + env = hackney_env(ssl_options: [verify: :verify_none]) + + assert {:ok, pinned} = TeslaMiddleware.call(env, [], dns_module: RedirectDNSResolver) + assert pinned.url == "https://93.184.216.34/data" + assert pinned.opts[:adapter] == nil + end + + defp hackney_env(adapter_options) do + %Tesla.Env{ + url: "https://public.example/data", + __client__: %Tesla.Client{adapter: {Tesla.Adapter.Hackney, :call, [adapter_options]}} + } + end + test "works with other middleware" do client = Tesla.client([Tesla.Middleware.Logger, {TeslaMiddleware, dns_module: TestDNSResolver}]) diff --git a/test/safeurl_test.exs b/test/safeurl_test.exs index 6dacd7d..2946ed3 100644 --- a/test/safeurl_test.exs +++ b/test/safeurl_test.exs @@ -5,6 +5,18 @@ defmodule SafeURLTest do @behaviour SafeURL.DNSResolver @impl true + def resolve("mixed.example"), do: {:ok, [{192, 0, 78, 24}, {10, 0, 0, 1}]} + def resolve("v6-only.example"), do: {:ok, [{0x2606, 0x4700, 0, 0, 0, 0, 0, 0x1111}]} + + def resolve("v6-internal.example"), + do: {:ok, [{192, 0, 78, 24}, {0xFD00, 0, 0, 0, 0, 0, 0, 1}]} + + def resolve("cname.example"), + do: {:ok, [~c"lb.example", {192, 0, 78, 24}, {192, 0, 78, 25}, ~c"lb.example"]} + + def resolve("cname-only.example"), do: {:ok, [~c"lb.example"]} + def resolve("empty.example"), do: {:ok, []} + def resolve("missing.example"), do: {:error, :nxdomain} def resolve(_domain), do: {:ok, [{192, 0, 78, 24}]} end @@ -28,6 +40,83 @@ defmodule SafeURLTest do assert {:error, :unsafe_reserved} = SafeURL.validate("http://192.168.1.1/") end + test "returns false for reserved IPv6 ranges" do + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[::]/") + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[::1]/") + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[fd00::1]/") + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[fe80::1]/") + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[64:ff9b::a00:1]/") + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[2002:a00:1::]/") + end + + test "checks an IPv4-mapped IPv6 address as the IPv4 address it carries" do + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[::ffff:10.0.0.1]/") + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[::ffff:a00:1]/") + + assert {:error, :unsafe_blocklist} = + SafeURL.validate("http://[::ffff:5.5.5.5]/", blocklist: ["5.5.0.0/16"]) + + assert :ok = SafeURL.validate("http://[::ffff:8.8.8.8]/") + end + + test "checks an IPv4-compatible IPv6 address as the IPv4 address it carries" do + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[::10.0.0.1]/") + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[::127.0.0.1]/") + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[::169.254.169.254]/") + + assert {:error, :unsafe_blocklist} = + SafeURL.validate("http://[::5.5.5.5]/", blocklist: ["5.5.0.0/16"]) + + assert :ok = SafeURL.validate("http://[::8.8.8.8]/") + end + + test "keeps the unspecified and loopback addresses out of the IPv4 mapping" do + assert {:ok, %{address: {0, 0, 0, 0, 0, 0, 0, 0}}} = + SafeURL.pin("http://[::]/", block_reserved: false) + + assert {:ok, %{address: {0, 0, 0, 0, 0, 0, 0, 1}}} = + SafeURL.pin("http://[::1]/", block_reserved: false) + + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[::]/") + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[::1]/") + end + + test "blocks the IPv4-translated prefix" do + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[::ffff:0:a00:1]/") + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[::ffff:0:808:808]/") + end + + test "blocks the IPv6 blocks IANA marks as not globally reachable" do + for address <- ~w[ + 100:0:0:1::1 2001::1 2001:1::1 2001:1::2 2001:1::3 2001:2::1 2001:3::1 + 2001:4:112::1 2001:10::1 2001:20::1 2001:30::1 2001:db8::1 2002:a00:1:: + 2620:4f:8000::1 3fff::1 5f00::1 fc00::1 fe80::1 fec0::1 ff00::1 + ] do + assert {:error, :unsafe_reserved} = SafeURL.validate("http://[#{address}]/") + end + end + + test "allows routed public IPv6 addresses" do + assert :ok = SafeURL.validate("http://[2606:4700:4700::1111]/") + assert :ok = SafeURL.validate("http://[2001:200::1]/") + assert :ok = SafeURL.validate("http://[2a00:1450:4001:800::200e]/") + end + + test "every resolved address has to pass" do + opts = [dns_module: TestDNSResolver] + assert {:error, :unsafe_reserved} = SafeURL.validate("http://mixed.example", opts) + assert {:error, :unsafe_reserved} = SafeURL.validate("http://v6-internal.example", opts) + assert :ok = SafeURL.validate("http://v6-only.example", opts) + end + + test "rejects a host without any address" do + opts = [dns_module: TestDNSResolver] + assert {:error, :unresolved_host} = SafeURL.validate("http://empty.example", opts) + assert {:error, :unresolved_host} = SafeURL.validate("http://missing.example", opts) + assert {:error, :unresolved_host} = SafeURL.validate("http:///path", opts) + assert {:error, :unresolved_host} = SafeURL.validate("http://", opts) + end + test "returns true for reserved ranges if overridden" do opts = [block_reserved: false] @@ -62,4 +151,43 @@ defmodule SafeURLTest do assert {:error, :restricted} = SafeURL.validate("http://0.0.0.0/", opts) end end + + describe "pin/2" do + test "replaces the host with the validated address and keeps the hostname" do + assert {:ok, + %{ + url: "https://192.0.78.24:8443/data?x=1", + hostname: "includesecurity.com", + address: {192, 0, 78, 24} + }} = + SafeURL.pin("https://includesecurity.com:8443/data?x=1", + dns_module: TestDNSResolver + ) + end + + test "keeps an address literal" do + assert {:ok, %{url: "http://3.3.3.3/", hostname: "3.3.3.3", address: {3, 3, 3, 3}}} = + SafeURL.pin("http://3.3.3.3/") + end + + test "brackets an IPv6 address" do + assert {:ok, %{url: "https://[2606:4700::1111]/", hostname: "v6-only.example"}} = + SafeURL.pin("https://v6-only.example/", dns_module: TestDNSResolver) + end + + test "pins the first address of a host behind a CNAME" do + assert {:ok, %{url: "https://192.0.78.24/", address: {192, 0, 78, 24}}} = + SafeURL.pin("https://cname.example/", dns_module: TestDNSResolver) + end + + test "refuses a host whose CNAME resolves to no address" do + assert {:error, :unresolved_host} = + SafeURL.pin("https://cname-only.example/", dns_module: TestDNSResolver) + end + + test "returns the validation error" do + assert {:error, :unsafe_reserved} = SafeURL.pin("http://10.0.0.1/") + assert {:error, :restricted} = SafeURL.pin("http://10.0.0.1/", detailed_error: false) + end + end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..156d6ce 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,4 @@ +# Elixir < 1.15 does not start optional dependencies. +{:ok, _apps} = Application.ensure_all_started(:hackney) + ExUnit.start()