Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,26 @@ 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:
otp-version: ${{matrix.otp}}
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') }}
Expand Down
79 changes: 66 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.

Expand All @@ -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
Expand All @@ -157,6 +174,40 @@ end

<br>

## 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`.

<br>

## Custom DNS Resolver

In some cases you might want to use a custom strategy for DNS resolution. You can do so by
Expand Down Expand Up @@ -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
92 changes: 92 additions & 0 deletions guides/migrating_to_1.1.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 64 additions & 0 deletions lib/safeurl/client.ex
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions lib/safeurl/dns.ex
Original file line number Diff line number Diff line change
@@ -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
Loading