Skip to content

Commit

Permalink
Verifies npm package tarball authenticity and integrity (#64)
Browse files Browse the repository at this point in the history
  • Loading branch information
grzuy authored Oct 10, 2023
1 parent bffe098 commit 94d18da
Show file tree
Hide file tree
Showing 5 changed files with 156 additions and 81 deletions.
85 changes: 4 additions & 81 deletions lib/esbuild.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions lib/esbuild/npm-registry.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# source: https://registry.npmjs.org/-/npm/v1/keys
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i
6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==
-----END PUBLIC KEY-----
145 changes: 145 additions & 0 deletions lib/esbuild/npm_registry.ex
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down

0 comments on commit 94d18da

Please sign in to comment.