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