Packages

phoenix_kit

2.45.0
2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules storage endpoint.ex
Raw

lib/modules/storage/endpoint.ex

defmodule PhoenixKit.Modules.Storage.Endpoint do
@moduledoc """
The one reading of an S3-compatible endpoint, and the one guard on where the
server may connect to.
Three places name an endpoint: a bucket (`Providers.S3`), an
`object_storage` Integrations connection (`Integrations.Validators`), and the
bucket form. They must all read the same string the same way, or the server
would connect to a different host than the one that was validated.
## Parsing
`parse/1` accepts a bare host (`s3.us-west-002.backblazeb2.com`), host:port,
or an `http`/`https` URL with no path (`http://minio.local:9000`); the scheme
defaults to https. Refused rather than read as plain AWS: any other scheme, a
path (a gateway prefix would be dropped silently), a query, and an IPv6 zone
id (`%eth0`).
## Guarding
The server connects to whatever endpoint it is given, so a user-supplied one
is a request-forgery primitive. `check/3` classifies the addresses behind an
endpoint by policy:
* `:system` — set by an admin. Loopback and private ranges are allowed (a
MinIO on the same network is a normal setup); what no storage endpoint
can be is unspecified, link-local (where cloud metadata lives), multicast
or reserved space, so those are refused.
* `:personal` — set by an ordinary user. https only, and additionally no
loopback, private, carrier-grade-NAT or unique-local address.
A hostname is resolved and **every** address it yields is checked, so a name
with one public and one private record is refused. The check sees DNS as of
the moment it runs; it is not a connect-time pin, so a name that changes its
answer between the check and the request is not caught. Callers run it at
save and test time, and again each time a personal endpoint is used.
"""
import Bitwise
@type parsed :: %{scheme: String.t(), host: String.t(), port: pos_integer()}
@type policy :: :system | :personal
@type reason ::
:invalid_endpoint
| :insecure_scheme
| :blocked_address
| :blocked_host
# Names that mean "the metadata service" without resolving to it from here.
@blocked_hosts ["metadata.google.internal", "metadata", "instance-data"]
@doc """
An endpoint string, parsed: `%{scheme:, host:, port:}`; `nil` when none is
set (plain AWS); `{:error, :invalid_endpoint}` when one is set but cannot be
used.
"""
@spec parse(term()) :: parsed() | nil | {:error, :invalid_endpoint}
def parse(endpoint) when is_binary(endpoint) do
case String.trim(endpoint) do
"" -> nil
trimmed -> parse_trimmed(trimmed)
end
end
def parse(_endpoint), do: nil
defp parse_trimmed(trimmed) do
with_scheme =
if trimmed =~ ~r{\A[a-zA-Z][a-zA-Z0-9+.-]*://}, do: trimmed, else: "https://" <> trimmed
case URI.parse(with_scheme) do
%URI{scheme: scheme, host: host, port: port, path: path, query: nil}
when scheme in ["http", "https"] and is_binary(host) and host != "" and
path in [nil, "", "/"] ->
if String.contains?(trimmed, "%"),
do: {:error, :invalid_endpoint},
else: %{scheme: scheme, host: host, port: port}
_ ->
{:error, :invalid_endpoint}
end
end
@doc """
Whether the server may connect to `endpoint` (a string or the result of
`parse/1`) under `policy`.
`nil` (no endpoint, plain AWS) is always allowed. Options:
* `:resolve` — look a hostname up and check every address it yields
(default `false`; a literal IP is always checked). Off on hot paths,
on wherever an endpoint is saved, tested, or used for a personal bucket.
* `:resolver` — `(charlist, :inet | :inet6 -> {:ok, [ip]} | {:error, _})`,
injectable for tests. Defaults to `:inet.getaddrs/2`.
"""
@spec check(term(), policy(), keyword()) :: :ok | {:error, reason()}
def check(endpoint, policy, opts \\ [])
def check(endpoint, policy, opts) when is_binary(endpoint),
do: check(parse(endpoint), policy, opts)
def check(nil, _policy, _opts), do: :ok
def check({:error, _reason} = error, _policy, _opts), do: error
def check(%{scheme: scheme, host: host}, policy, opts) when policy in [:system, :personal] do
cond do
policy == :personal and scheme != "https" -> {:error, :insecure_scheme}
String.downcase(host) in @blocked_hosts -> {:error, :blocked_host}
true -> check_addresses(host, policy, opts)
end
end
defp check_addresses(host, policy, opts) do
case :inet.parse_address(String.to_charlist(host)) do
{:ok, ip} -> check_ip(ip, policy)
{:error, _} -> check_resolved(host, policy, opts)
end
end
defp check_resolved(host, policy, opts) do
if Keyword.get(opts, :resolve, false) do
resolver = Keyword.get(opts, :resolver, &:inet.getaddrs/2)
name = String.to_charlist(host)
# A name that does not resolve is not refused here: the request that
# follows fails with "could not reach", which is the truthful message.
[:inet, :inet6]
|> Enum.flat_map(fn family ->
case resolver.(name, family) do
{:ok, ips} -> ips
{:error, _reason} -> []
end
end)
|> Enum.find_value(:ok, fn ip ->
case check_ip(ip, policy) do
:ok -> nil
error -> error
end
end)
else
:ok
end
end
defp check_ip(ip, policy) do
case classify(ip) do
class when class in [:unspecified, :link_local, :multicast, :reserved] ->
{:error, :blocked_address}
class when class in [:loopback, :private, :shared, :unique_local] ->
if policy == :personal, do: {:error, :blocked_address}, else: :ok
:public ->
:ok
end
end
@doc """
The class of an address: `:public`, or what makes it not one
(`:unspecified`, `:loopback`, `:private`, `:shared`, `:link_local`,
`:unique_local`, `:multicast`, `:reserved`). An IPv4 address wrapped in IPv6
(`::ffff:a.b.c.d`, NAT64 `64:ff9b::/96`) is classified as the IPv4 address.
"""
@spec classify(:inet.ip_address()) :: atom()
def classify({0, _, _, _}), do: :unspecified
def classify({127, _, _, _}), do: :loopback
def classify({10, _, _, _}), do: :private
def classify({172, b, _, _}) when b in 16..31, do: :private
def classify({192, 168, _, _}), do: :private
def classify({169, 254, _, _}), do: :link_local
def classify({100, b, _, _}) when b in 64..127, do: :shared
def classify({192, 0, 0, _}), do: :reserved
def classify({198, b, _, _}) when b in 18..19, do: :reserved
def classify({a, _, _, _}) when a in 224..239, do: :multicast
def classify({a, _, _, _}) when a >= 240, do: :reserved
def classify({_, _, _, _}), do: :public
def classify({0, 0, 0, 0, 0, 0, 0, 0}), do: :unspecified
def classify({0, 0, 0, 0, 0, 0, 0, 1}), do: :loopback
def classify({0, 0, 0, 0, 0, 0xFFFF, hi, lo}), do: classify(embedded_v4(hi, lo))
def classify({0x64, 0xFF9B, 0, 0, 0, 0, hi, lo}), do: classify(embedded_v4(hi, lo))
def classify({first, _, _, _, _, _, _, _}) when (first &&& 0xFE00) == 0xFC00, do: :unique_local
def classify({first, _, _, _, _, _, _, _}) when (first &&& 0xFFC0) == 0xFE80, do: :link_local
def classify({first, _, _, _, _, _, _, _}) when (first &&& 0xFF00) == 0xFF00, do: :multicast
def classify({_, _, _, _, _, _, _, _}), do: :public
defp embedded_v4(hi, lo), do: {hi >>> 8, hi &&& 0xFF, lo >>> 8, lo &&& 0xFF}
@doc "An operator-facing sentence for a `check/3` or `parse/1` error."
@spec error_message(reason()) :: String.t()
def error_message(:invalid_endpoint),
do: "must be a host, host:port, or an http(s) URL with no path"
def error_message(:insecure_scheme), do: "must use https"
def error_message(reason) when reason in [:blocked_address, :blocked_host],
do: "must not point at a local, private or metadata address"
end