Skip to main content

lib/npm/security/registry_policy.ex

defmodule NPM.Security.RegistryPolicy do
  @moduledoc """
  Enforces registry origin policy for packuments and tarballs.

  Registry and mirror confusion can move package metadata or tarballs to an
  unexpected host. The default policy allows the configured registry and mirror
  origins, blocks cross-origin redirects, and rejects tarball URLs outside the
  allowlist.
  """

  defmodule Error do
    @moduledoc "Raised when a package URL points at an untrusted registry origin."

    defexception [:url, :allowed]

    @impl true
    def message(%__MODULE__{url: url, allowed: allowed}) do
      "untrusted npm registry URL #{url}. Allowed registry origins: #{Enum.join(allowed, ", ")}."
    end
  end

  @doc "Validate that a URL belongs to an allowed registry origin."
  @spec validate_url!(String.t()) :: :ok
  def validate_url!(url) when is_binary(url) do
    allowed = allowed_origins()

    if origin(url) in allowed do
      :ok
    else
      raise Error, url: url, allowed: allowed
    end
  end

  def validate_url!(_), do: :ok

  @doc "Return normalized allowed registry origins."
  @spec allowed_origins :: [String.t()]
  def allowed_origins do
    NPM.Config.allowed_registries()
    |> Enum.map(&origin/1)
    |> Enum.reject(&is_nil/1)
    |> Enum.uniq()
  end

  @doc "Return the normalized `scheme://host[:port]` origin for a URL."
  @spec origin(String.t()) :: String.t() | nil
  def origin(url) when is_binary(url) do
    uri = URI.parse(url)

    with scheme when scheme in ["http", "https"] <- uri.scheme,
         host when is_binary(host) <- uri.host do
      port = explicit_port(uri)
      "#{scheme}://#{String.downcase(host)}#{port}"
    else
      _ -> nil
    end
  end

  def origin(_), do: nil

  defp explicit_port(%URI{scheme: "http", port: port}) when port in [nil, 80], do: ""
  defp explicit_port(%URI{scheme: "https", port: port}) when port in [nil, 443], do: ""
  defp explicit_port(%URI{port: nil}), do: ""
  defp explicit_port(%URI{port: port}), do: ":#{port}"
end