Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Verifies npm package tarball authenticity and integrity #64

Merged
merged 13 commits into from
Oct 10, 2023
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