From 94d18da850927512ac8de7f4a201f3407a225527 Mon Sep 17 00:00:00 2001 From: Gonzalo <456459+grzuy@users.noreply.github.com> Date: Tue, 10 Oct 2023 16:11:20 -0300 Subject: [PATCH] Verifies npm package tarball authenticity and integrity (#64) --- lib/esbuild.ex | 85 +------------------- lib/esbuild/npm-registry.pem | 5 ++ lib/esbuild/npm_registry.ex | 145 +++++++++++++++++++++++++++++++++++ mix.exs | 1 + mix.lock | 1 + 5 files changed, 156 insertions(+), 81 deletions(-) create mode 100644 lib/esbuild/npm-registry.pem create mode 100644 lib/esbuild/npm_registry.ex diff --git a/lib/esbuild.ex b/lib/esbuild.ex index 95bf54e..66a140b 100644 --- a/lib/esbuild.ex +++ b/lib/esbuild.ex @@ -222,17 +222,16 @@ defmodule Esbuild do freshdir_p(Path.join(System.tmp_dir!(), "phx-esbuild")) || raise "could not install esbuild. Set MIX_XGD=1 and then set XDG_CACHE_HOME to the path you want to use as cache" - url = + name = if Version.compare(version, "0.16.0") in [:eq, :gt] do target = target() - "https://registry.npmjs.org/@esbuild/#{target}/-/#{target}-#{version}.tgz" + "@esbuild/#{target}" else # TODO: Remove else clause or raise if esbuild < 0.16.0 don't need to be supported anymore - name = "esbuild-#{target_legacy()}" - "https://registry.npmjs.org/#{name}/-/#{name}-#{version}.tgz" + "esbuild-#{target_legacy()}" end - tar = fetch_body!(url) + tar = Esbuild.NpmRegistry.fetch_package!(name, version) case :erl_tar.extract({:binary, tar}, [:compressed, cwd: to_charlist(tmp_dir)]) do :ok -> :ok @@ -317,80 +316,4 @@ defmodule Esbuild do end end end - - defp fetch_body!(url) do - scheme = URI.parse(url).scheme - url = String.to_charlist(url) - Logger.debug("Downloading esbuild from #{url}") - - {:ok, _} = Application.ensure_all_started(:inets) - {:ok, _} = Application.ensure_all_started(:ssl) - - if proxy = proxy_for_scheme(scheme) do - %{host: host, port: port} = URI.parse(proxy) - Logger.debug("Using #{String.upcase(scheme)}_PROXY: #{proxy}") - set_option = if "https" == scheme, do: :https_proxy, else: :proxy - :httpc.set_options([{set_option, {{String.to_charlist(host), port}, []}}]) - end - - # https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/inets - cacertfile = cacertfile() |> String.to_charlist() - - http_options = - [ - ssl: [ - verify: :verify_peer, - cacertfile: cacertfile, - depth: 2, - customize_hostname_check: [ - match_fun: :public_key.pkix_verify_hostname_match_fun(:https) - ] - ] - ] - |> maybe_add_proxy_auth(scheme) - - options = [body_format: :binary] - - case :httpc.request(:get, {url, []}, http_options, options) do - {:ok, {{_, 200, _}, _headers, body}} -> - body - - other -> - raise """ - couldn't fetch #{url}: #{inspect(other)} - - You may also install the "esbuild" executable manually, \ - see the docs: https://hexdocs.pm/esbuild - """ - end - end - - defp proxy_for_scheme("http") do - System.get_env("HTTP_PROXY") || System.get_env("http_proxy") - end - - defp proxy_for_scheme("https") do - System.get_env("HTTPS_PROXY") || System.get_env("https_proxy") - end - - defp maybe_add_proxy_auth(http_options, scheme) do - case proxy_auth(scheme) do - nil -> http_options - auth -> [{:proxy_auth, auth} | http_options] - end - end - - defp proxy_auth(scheme) do - with proxy when is_binary(proxy) <- proxy_for_scheme(scheme), - %{userinfo: userinfo} when is_binary(userinfo) <- URI.parse(proxy), - [username, password] <- String.split(userinfo, ":") do - {String.to_charlist(username), String.to_charlist(password)} - else - _ -> nil - end - end - - defp cacertfile() do - Application.get_env(:esbuild, :cacerts_path) || CAStore.file_path() - end end diff --git a/lib/esbuild/npm-registry.pem b/lib/esbuild/npm-registry.pem new file mode 100644 index 0000000..d8806f0 --- /dev/null +++ b/lib/esbuild/npm-registry.pem @@ -0,0 +1,5 @@ +# source: https://registry.npmjs.org/-/npm/v1/keys +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i +6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg== +-----END PUBLIC KEY----- diff --git a/lib/esbuild/npm_registry.ex b/lib/esbuild/npm_registry.ex new file mode 100644 index 0000000..37d27f8 --- /dev/null +++ b/lib/esbuild/npm_registry.ex @@ -0,0 +1,145 @@ +defmodule Esbuild.NpmRegistry do + @moduledoc false + require Logger + + @base_url "https://registry.npmjs.org" + @external_resource "lib/esbuild/npm-registry.pem" + @public_key_pem File.read!("lib/esbuild/npm-registry.pem") + @public_key_id "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA" + @public_key_ec_curve :prime256v1 + + def fetch_package!(name, version) do + %{ + "_id" => id, + "dist" => %{ + "integrity" => integrity, + "signatures" => [ + %{ + "keyid" => @public_key_id, + "sig" => signature + } + ], + "tarball" => tarball + } + } = + fetch_file!("#{@base_url}/#{name}/#{version}") + |> Jason.decode!() + + verify_signature!("#{id}:#{integrity}", signature) + tar = fetch_file!(tarball) + + [hash_alg, checksum] = + integrity + |> String.split("-") + + verify_integrity!(tar, hash_alg, Base.decode64!(checksum)) + + tar + end + + defp fetch_file!(url) do + scheme = URI.parse(url).scheme + Logger.debug("Downloading esbuild from #{url}") + + {:ok, _} = Application.ensure_all_started(:inets) + {:ok, _} = Application.ensure_all_started(:ssl) + + if proxy = proxy_for_scheme(scheme) do + %{host: host, port: port} = URI.parse(proxy) + Logger.debug("Using #{String.upcase(scheme)}_PROXY: #{proxy}") + set_option = if "https" == scheme, do: :https_proxy, else: :proxy + :httpc.set_options([{set_option, {{String.to_charlist(host), port}, []}}]) + end + + case do_fetch(url) do + {:ok, {{_, 200, _}, _headers, body}} -> + body + + other -> + raise """ + couldn't fetch #{url}: #{inspect(other)} + + You may also install the "esbuild" executable manually, \ + see the docs: https://hexdocs.pm/esbuild + """ + end + end + + defp do_fetch(url) do + scheme = URI.parse(url).scheme + url = String.to_charlist(url) + + :httpc.request( + :get, + {url, []}, + [ + ssl: [ + verify: :verify_peer, + # https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/inets + cacertfile: cacertfile() |> String.to_charlist(), + depth: 2, + customize_hostname_check: [ + match_fun: :public_key.pkix_verify_hostname_match_fun(:https) + ] + ] + ] + |> maybe_add_proxy_auth(scheme), + body_format: :binary + ) + end + + defp proxy_for_scheme("http") do + System.get_env("HTTP_PROXY") || System.get_env("http_proxy") + end + + defp proxy_for_scheme("https") do + System.get_env("HTTPS_PROXY") || System.get_env("https_proxy") + end + + defp maybe_add_proxy_auth(http_options, scheme) do + case proxy_auth(scheme) do + nil -> http_options + auth -> [{:proxy_auth, auth} | http_options] + end + end + + defp proxy_auth(scheme) do + with proxy when is_binary(proxy) <- proxy_for_scheme(scheme), + %{userinfo: userinfo} when is_binary(userinfo) <- URI.parse(proxy), + [username, password] <- String.split(userinfo, ":") do + {String.to_charlist(username), String.to_charlist(password)} + else + _ -> nil + end + end + + defp cacertfile() do + Application.get_env(:esbuild, :cacerts_path) || CAStore.file_path() + end + + defp verify_signature!(message, signature) do + :crypto.verify( + :ecdsa, + :sha256, + message, + Base.decode64!(signature), + [public_key(), @public_key_ec_curve] + ) || raise "invalid signature" + end + + defp verify_integrity!(binary, hash_alg, checksum) do + hash_alg + |> hash_alg_to_erlang() + |> :crypto.hash(binary) + |> :crypto.hash_equals(checksum) || raise "invalid checksum" + end + + defp public_key do + [entry] = :public_key.pem_decode(@public_key_pem) + {{:ECPoint, ec_point}, _} = :public_key.pem_entry_decode(entry) + + ec_point + end + + defp hash_alg_to_erlang("sha512"), do: :sha512 +end diff --git a/mix.exs b/mix.exs index 2be1314..89b3d04 100644 --- a/mix.exs +++ b/mix.exs @@ -39,6 +39,7 @@ defmodule Esbuild.MixProject do defp deps do [ {:castore, ">= 0.0.0"}, + {:jason, "~> 1.4"}, {:ex_doc, ">= 0.0.0", only: :docs} ] end diff --git a/mix.lock b/mix.lock index 8592d73..5665820 100644 --- a/mix.lock +++ b/mix.lock @@ -2,6 +2,7 @@ "castore": {:hex, :castore, "0.1.11", "c0665858e0e1c3e8c27178e73dffea699a5b28eb72239a3b2642d208e8594914", [:mix], [], "hexpm", "91b009ba61973b532b84f7c09ce441cba7aa15cb8b006cf06c6f4bba18220081"}, "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"},