lib/github/webhook.ex

defmodule GitHub.Webhook do
  @moduledoc """
  Helpers for validating and handling webhooks dispatched by GitHub

  This module is made for Plug / Phoenix applications that wish to accept webhook requests that
  are sent from the GitHub API. Receiving these requests requires setting up a webhook or GitHub
  App, which is out of scope for this documentation.

  ## Configuration

  In order to validate that incoming webhooks are, in fact, from GitHub, it is **strongly
  advised** to set a webhook secret. This is a secret value that you generate and supply to GitHub
  in the webhook or GitHub App settings. If you are using Phoenix, an easy way to generate this
  value is by running `mix phx.gen.secret`.

  Then, supply this value to the library at runtime using the following configuration:

      config :oapi_github,
        webhook_secret: "my secret"

  It is common to use `System.fetch_env!/1` or a similar function to load this type of secret
  from environment variables. Alternatively, the webhook secret can be supplied directly to
  `verify_github_signature/2` at compile time.

  ## Usage

  If the optional dependencies `Plug` and `Plug.Crypto` are installed, this module provides
  several helpers to simplify the process of handling webhook requests. A typical webhook
  controller might look like this:

      defmodule MyAppWeb.GitHubController do
        use MyAppWeb, :controller
        import GitHub.Webhook

        plug :verify_github_event
        plug :verify_github_signature

        def webhook(conn, params) do
          # Handle the webhook...
        end
      end

  In order to perform verification of the signature, it is necessary to store the original request
  body in an assign `:raw_body` or `:body`. This usually requires implementing a custom
  `Plug.Parser` for webhook requests.

  See `verify_github_event/2` and `verify_github_signature/2` for more information.
  """

  @doc """
  Construct the value of the `:body_reader` option for `Plug.Parsers` for caching request bodies

  This function, called at compile time, creates a valid value for the `:body_reader` option in
  a call to the `Plug.Parsers` plug. Usually called in a Phoenix Endpoint, the code looks like:

      plug Plug.Parsers,
        parsers: [:json, ...],
        json_decoder: Jason,
        # ...
        body_reader: GitHub.Webhook.body_reader()

  It sets up `cache_request_body/2` as the body reader, which will in turn cache the raw request
  body as an assign `:raw_body` for use during signature verification.

  ## Options

    * `:fallback`: If using the `:routes` option below, which body reader function to call for
      requests that do not match one of the specified routes. Defaults to
      `{Plug.Conn, :read_body, []}`.

    * `:routes`: Optionally restrict the caching of request bodies to specific routes, given as
      a list of strings (ex. `["/hook/github"]`). Defaults to caching request bodies for all
      requests, which can cause a significant performance impact.

  """
  @spec body_reader(keyword) :: tuple
  def body_reader(opts \\ []) do
    {GitHub.Webhook, :cache_request_body, [[fallback: opts[:fallback], routes: opts[:routes]]]}
  end

  @doc """
  Cache request body as `:raw_body` assign for chosen connections

  This function adheres to the needs of the `:body_reader` option for the `Plug.Parsers` plug. It
  saves the raw request body as a binary to the `:raw_body` assign on the `Plug.Conn`, so that it
  can be used for signature verification later.

  This function is not usually called directly. Instead, use `body_reader/1`.
  """
  @spec cache_request_body(Plug.Conn.t(), keyword, keyword) ::
          {:ok, binary, Plug.Conn.t()} | {:error, term}
  def cache_request_body(conn, parser_opts, cache_opts)

  if Code.ensure_loaded?(Plug) do
    def cache_request_body(conn, opts, cache_opts) do
      %Plug.Conn{path_info: path_info} = conn

      fallback =
        case cache_opts[:fallback] do
          {module, function, args} when is_atom(module) and is_atom(function) and is_list(args) ->
            {module, function, args}

          nil ->
            {Plug.Conn, :read_body, []}

          _else ->
            raise ArgumentError, "Invalid value for option `:fallback`"
        end

      routes =
        case cache_opts[:routes] do
          route when is_binary(route) -> [route]
          routes when is_list(routes) -> routes
          nil -> nil
          _else -> raise ArgumentError, "Invalid value for option `:routes`"
        end

      if is_list(routes) and path_info not in Enum.map(routes, &String.split(&1, "/", trim: true)) do
        {fallback_module, fallback_function, fallback_opts} = fallback
        apply(fallback_module, fallback_function, [conn, fallback_opts])
      else
        case Plug.Conn.read_body(conn, Keyword.put(opts, :length, 25_000_000)) do
          {:ok, body, conn} ->
            conn = update_in(conn.assigns[:raw_body], &[body | &1 || []])
            {:ok, body, conn}

          {:more, _, _} ->
            {:error, "Webhook payload is too large (over 25MB)"}

          {:error, _} = err ->
            err
        end
      end
    end
  else
    def cache_request_body(conn, _opts, _cache_opts) do
      raise GitHub.Error.new(
              source: conn,
              message: """
              Plug Not Installed

              Optional dependency Plug was not installed at the
              time `GitHub.Webhook` was compiled. Please ensure
              it is installed and run:

              `mix deps.compile --force oapi_github`
              """
            )
    end
  end

  @doc """
  Get and store the GitHub webhook event type

  This function looks at the `X-GitHub-Event` header to ensure the incoming request is a webhook
  event and stores the event as a `:github_event` assign on the connection. If the header is
  missing, the connection is immediately halted with a simple error message that will appear in
  GitHub's UI.
  """
  @spec verify_github_event(Plug.Conn.t(), keyword) :: Plug.Conn.t()
  def verify_github_event(conn, opts)

  if Code.ensure_loaded?(Plug) do
    def verify_github_event(conn, _opts) do
      case Plug.Conn.get_req_header(conn, "x-github-event") do
        [event | _] ->
          Plug.Conn.assign(conn, :github_event, event)

        [] ->
          conn
          |> Plug.Conn.send_resp(:bad_Request, "Missing event header")
          |> Plug.Conn.halt()
      end
    end
  else
    def verify_github_event(conn, _opts) do
      raise GitHub.Error.new(
              source: conn,
              message: """
              Plug Not Installed

              Optional dependency Plug was not installed at the
              time `GitHub.Webhook` was compiled. Please ensure
              it is installed and run:

              `mix deps.compile --force oapi_github`
              """
            )
    end
  end

  @doc """
  Check the validity of a GitHub webhook request

  This function uses the configured `:webhook_secret` or an option `:secret` to verify the
  signature of an incoming GitHub webhook. If the signature is missing or invalid, the connection
  is immediately halted with a simple error message that will appear in GitHub's UI.

  ## Configuration

    * `:webhook_secret`: Secret given to GitHub to use when signing webhook requests. If not
      supplied via configuration nor the `:secret` option, an error is raised.

  ## Options

    * `:secret`: Compile-time secret to use as the webhook secret. If not supplied at compile time
      nor via the `:webhook_secret` configuration, an error is raised.

  """
  @spec verify_github_signature(Plug.Conn.t(), keyword) :: Plug.Conn.t()
  def verify_github_signature(conn, opts)

  if Code.ensure_loaded?(Plug) do
    def verify_github_signature(conn, opts) do
      request_body = conn.assigns[:raw_body] || conn.assigns[:body]
      secret = opts[:secret] || get_secret()
      signature = Plug.Conn.get_req_header(conn, "x-hub-signature-256") |> List.first()

      unless is_binary(request_body) or is_list(request_body) do
        raise GitHub.Error.new(
                source: conn,
                message: """
                Request Body Not Present

                In order to verify the signature of a GitHub
                webhook request, a special parser must be used.
                This plug expects the raw request body to be read
                and present on the connection as a binary
                `:raw_body` or `:body` assign.

                For more information, see the documentation for
                `GitHub.Webhook`.
                """
              )
      end

      unless is_binary(secret) do
        raise GitHub.Error.new(
                source: conn,
                message: """
                Webhook Secret Not Configured

                In order to verify the signature of a GitHub
                webhook request, a webhook secret must be
                configured or passed in as an option `:secret`
                to this plug.

                For more information, see the documentation for
                `GitHub.Webhook`.
                """
              )
      end

      hash =
        "sha256=" <>
          Base.encode16(:crypto.mac(:hmac, :sha256, secret, request_body), case: :lower)

      cond do
        is_nil(signature) ->
          conn
          |> Plug.Conn.send_resp(:bad_request, "Missing signature")
          |> Plug.Conn.halt()

        Plug.Crypto.secure_compare(hash, signature) ->
          conn

        :else ->
          conn
          |> Plug.Conn.send_resp(:unauthorized, "Invalid signature")
          |> Plug.Conn.halt()
      end
    end

    @spec get_secret :: String.t() | nil
    defp get_secret, do: Application.get_env(:oapi_github, :webhook_secret)
  else
    def verify_github_signature(conn, _opts) do
      raise GitHub.Error.new(
              source: conn,
              message: """
              Plug Not Installed

              Optional dependency Plug was not installed at the
              time `GitHub.Webhook` was compiled. Please ensure
              it is installed and run:

              `mix deps.compile --force oapi_github`
              """
            )
    end
  end
end