Packages

phoenix_kit

2.56.0
2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.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

  @doc """
  A URL's audit representation, with credentials, query and fragment withheld.
  Absolute local paths stay paths unless `local_path: false` (for CDN URLs).
  """
  @spec audit_value(String.t() | nil, keyword()) :: String.t() | nil
  def audit_value(value, opts \\ [])

  def audit_value(value, opts) when is_binary(value) do
    value = String.trim(value)
    uri = URI.parse(value)

    cond do
      uri.host ->
        URI.to_string(%{uri | userinfo: nil, query: nil, fragment: nil})

      String.starts_with?(value, "/") and Keyword.get(opts, :local_path, true) ->
        value

      String.starts_with?(value, "/") ->
        URI.to_string(%{uri | userinfo: nil, query: nil, fragment: nil})

      true ->
        # Scheme-less endpoints are supported, too; keep their representation.
        uri = URI.parse("https://" <> value)

        URI.to_string(%{uri | userinfo: nil, query: nil, fragment: nil})
        |> String.trim_leading("https://")
    end
  end

  def audit_value(value, _opts), do: value

  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