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