wfgilman

wfgilman

How to: Plug which verifies header signature signed using request body

I’m writing up this quick “How to” because what I thought was going to be an easy implementation of a Plug to validate a webhook request turned out to be more complicated than I expected. Hopefully this can save others some time. This is for a JSON API.

Prior discussions here, and here. Recommended approach here by Jose from these discussions.

Goal: Validate a request signature which is a SHA256 HMAC of the request body signed by your secret key.

Challenge: The request body can only be read once from the conn, and it is done by Plug.Parsers before the request even reaches the router.

Solution: Normally, I add a webhook endpoint to my router, and add a controller which does something with the webhook, like store it in a queue for later processing. I can put the webhook route inside a pipeline, or add a Plug to my controller which verifies the header before processing the request.

However, I need the request body (as a string) to sign and compare to the signature in the header. It can only be accessed in this way using Plug.Conn.read_body/2. Unfortunately, that function is called by Plug.Parsers before the request hits the router.

Therefore, I need to create a Plug which is added to the endpoint of my app, before Plug.Parsers, here in endpoint.ex:

...
plug Plug.RequestId
plug Plug.Logger

# --> Add my Plug here <--
plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  pass: ["*/*"],
  json_decoder: Poison

plug Plug.MethodOverride
plug Plug.Head
...

Also, the Plug has to actually handle the request and then halt the conn. Once the request body is read, the subsequent plug, Plug.Parsers will fail. This means I can’t use a controller. It also means every request to the endpoint will go through my Plug; so I need a way of only taking action on request to the endpoint I care about.

I used a module plug for this. I copied the format from the recommended approach linked above (credit to hamiltop (Peter Hamilton) · GitHub).

init/1 and call/2 are simple. init/1 just passed along the options with no modifications. I actually need to do modifications here, but I punt to a private function after I know whether the request is to the endpoint I care about. Otherwise every request will go through those modification - unnecessary overhead.

def init(opts), do: opts

call/2 checks the request path. It just returns the connection to continue one if it doesn’t match, otherwise it performs the verification and ultimately halts it.

def call(conn, opts) do
  mount = Keyword.get(opts, :mount)
  case conn.request_path do
    ^mount ->
      verify_signature(conn, opts)
    _ ->
      conn
  end
end

verify_signature/2 does the work of verifying the header signature. If correct, it will handle the request. If not, it will return a 401 error. handle_request/2 here is the equivalent of create/2 if I were using a Phoenix.Controller. (All my webhooks come in as POST requests.)

defp verify_signature(conn, opts) do
  opts = prepare_options(opts) # Normally my init/1 function.
  with [request_signature|_] <- get_req_header(conn, opts[:header]),
       secret when not is_nil(secret) <- opts[:secret],
       {:ok, body, _} <- Plug.Conn.read_body(conn),
       signature = Plug.Crypto.MessageVerifier.sign(body, secret, :sha256),
       true <- Plug.Crypto.secure_compare(signature, request_signature) do
    handle_webhook(conn, Poison.decode!(body))
  else
    nil ->
      Logger.error(fn -> "Webhook secret is not set" end)
      halt send_resp(conn, 401, "")
    false ->
      Logger.error(fn -> "Received webhook with invalid signature" end)
      halt send_resp(conn, 401, "")
    _ ->
      halt send_resp(conn, 401, "")
  end
end

Important: Note how each response includes halt/1. This ensures the conn won’t proceed to the next plug and foul things up (since the request body has already been read). Whatever you implement for handle_webhook/2, it needs to also included halt/1 with its response.

defp handle_webhook(conn, webhook) do
  event_params = get_params(webhook)
  with {:error, changeset} <- Event.store(event_params),
       true <- is_nil(changeset.errors[:resource_topic]) do
    Logger.warn(fn -> "Failed to store event: #{inspect changeset}" end)
  end
  halt send_resp(conn, 200, "")
end

Finally, my Plug is placed in endpoint.ex at the location noted above.

MyApp.Plug.Webhook, mount: "/v1/webhooks", header: "X-Request-Signature-Sha-256", secret: "s3cret"

Where Next?

Popular in Guides/Tuts Top

egze
I was preparing to deploy a production application to AWS Fargate, and to practice I wanted to play with DNS polling and node discovery o...
New
fmcgeough
pipe into case? I use that fairly frequently…unless I’m misunderstanding what you’re wanting…could be.. its still very early… str = "Hel...
New
9mm
So I’m really loving elixir. BY FAR the most excruciating piece of learning a functional language for me is having to “transform” all my ...
New
f0rest8
Hi, TLDR: form attribute set on the input fields and button submit. I just wanted to share a solution I discovered when making live inl...
New
lukertty
Install web-mode and mmm-mode first and put this in your config file: (require 'mmm-mode) (require 'web-mode) (setq mmm-global-mode 'may...
New
kokolegorille
Hello dear alchemists, There was this question some days ago here about the deployment to a VPS. As I was in the process of deploying t...
New
bitli
In case this is handy for other people, here is how you can run Elixir on Android: Install https://termux.com/ apt update; apt upgrade ...
New
jtormey
Hello! Having written a lot of LiveView code, I’ve made some VS Code snippets to speed up writing callbacks for LiveViews and LiveCompon...
New
GenericJam
Just leaving some breadcrumbs for future me and future others like me. Connect with TCP (not secured) - most servers will reject but use...
New
KoviRobi
Hi, I’ve written the following to debug function calls, not sure if it’s useful for anyone else, and if so should I put it somewhere? i...
New

Other popular topics Top

sen
Hi All, I set a environment variables in dev.exs , like below code. when i start server, how can i set the ${enable} value? thanks. d...
New
vertexbuffer
Hello, can anybody help here..? I have a list of players and I what to delete an element, but every for loop the list is reverting to ori...
New
electic
Hi, I am new to Elixir. I am trying to use the DateTime component to insert a date into MySQL however the there seems to be no way to fo...
New
AngeloChecked
What learn first? Rust or Elixir Hi Elixir community! I’m here because i want learn a new language. I’m a junior developer and mainly i ...
New
SoCreat
i’m a new one to elixir which editor can i use vs code? or atom? Thanks! :smiley:
New
freewebwithme
Using vs code and installed ElixirLS: support and debugger. And I got an error popped up on start up says Failed to run ‘elixir’ comma...
New
sergio_101
I am VERY much an elixir newbie. I have taken one elixir course and one phoenix course on Udemy. During that course, I saw the instructor...
New
bsollish-terakeet
Credo is smart enough to check for (something like) this: assert length(the_list) == 0 with this response: Checking if an enum is empt...
New
dblack
I’ve got an issue with an app and I’ve no idea of how to troubleshoot it. I’m hoping someone here might have seen something similar. I p...
New
jononomo
For some reason my phoenix channels are working for me in my local dev environment, but as soon as I deploy via Docker, I get a 403 error...
New

We're in Beta

About us Mission Statement