Skip to content

Allow specifying an alternate method of getting a raw body #8

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions lib/ex_twilio_webhook/hash_helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule ExTwilioWebhook.HashHelpers do
data encoded as JSON or `application/x-www-form-urlencoded`.
"""

defguard is_binary_or_list(auth_token) when is_binary(auth_token) or is_list(auth_token)
defguard is_binary_or_list(data) when is_binary(data) or is_list(data)

@spec hmac_sha1_base64(key :: binary(), data :: binary()) :: String.t()
def hmac_sha1_base64(key, data) when is_binary(key) and is_binary(data) do
Expand Down Expand Up @@ -89,12 +89,12 @@ defmodule ExTwilioWebhook.HashHelpers do
auth_token :: String.t() | [String.t()],
signature :: String.t(),
url :: String.t(),
body :: binary()
body :: iodata()
) ::
boolean()
def validate_request_with_body(auth_token, signature, url, body)
when is_binary_or_list(auth_token) and is_binary(signature) and is_binary(url) and
is_binary(body) do
is_binary_or_list(body) do
case get_sha_hash_from_url(url) do
nil ->
# URL encoded body
Expand Down Expand Up @@ -129,16 +129,17 @@ defmodule ExTwilioWebhook.HashHelpers do
end)
end

@spec validate_json_body(body :: binary(), expected_signature :: binary()) :: boolean()
@spec validate_json_body(body :: iodata(), expected_signature :: binary()) :: boolean()
def validate_json_body(body, expected_signature)
when is_binary(body) and is_binary(expected_signature) do
when is_binary_or_list(body) and is_binary(expected_signature) do
digest = :crypto.hash(:sha256, body)
Base.encode16(digest, case: :lower) == expected_signature
end

@spec parse_and_sort_urlencoded_body(body :: binary()) :: [binary()]
def parse_and_sort_urlencoded_body(body) when is_binary(body) do
def parse_and_sort_urlencoded_body(body) when is_binary_or_list(body) do
body
|> IO.iodata_to_binary()
|> URI.decode_query()
|> Enum.map(fn {key, value} -> key <> value end)
end
Expand Down
36 changes: 32 additions & 4 deletions lib/ex_twilio_webhook/plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ defmodule ExTwilioWebhook.Plug do
@type t :: %__MODULE__{
secret: String.t() | [String.t()] | mfa() | function(),
path_pattern: [String.t()],
public_host: String.t() | mfa()
public_host: String.t() | mfa(),
raw_body: function() | mfa() | nil
}

defstruct [:secret, :path_pattern, :public_host]
defstruct [:secret, :path_pattern, :public_host, :raw_body]
end

@doc """
Expand All @@ -41,18 +42,25 @@ defmodule ExTwilioWebhook.Plug do
`https://myapp.com`. Can be provided as string or `{m, f, a}` tuple.
When given a tuple, the tuple will be called at runtime for each request.

- `raw_body`: An optional function for fetching the raw body from a conn.
by default the raw body is cached at conn.private.raw_body if using
`ExTwilioWebhook.BodyReader` if it is stored somewhere else, this can
be used to fetch that.

This function will raise if called with invalid arguments.
"""
@impl true
def init(opts) when is_list(opts) do
path_pattern = opts |> Keyword.get(:at) |> validate_path_pattern()
secret = opts |> Keyword.get(:secret) |> validate_secret()
public_host = opts |> Keyword.get(:public_host) |> validate_public_url()
raw_body = opts |> Keyword.get(:raw_body) |> validate_raw_body()

%Settings{
path_pattern: path_pattern,
secret: secret,
public_host: public_host
public_host: public_host,
raw_body: raw_body
}
end

Expand Down Expand Up @@ -90,7 +98,7 @@ defmodule ExTwilioWebhook.Plug do

# extract signature and raw body from the conn, and validate the signature
with [signature] <- get_req_header(conn, "x-twilio-signature"),
%{raw_body: payload} <- conn.private,
payload = get_raw_body(settings.raw_body, conn),
true <-
HashHelpers.validate_request_with_body(secret, signature, url, payload) do
conn
Expand Down Expand Up @@ -144,6 +152,10 @@ defmodule ExTwilioWebhook.Plug do
when is_binary(token_or_list) or is_list(token_or_list),
do: token_or_list

defp get_raw_body({m, f, a}, conn), do: apply(m, f, [conn | a])
defp get_raw_body(fun, conn) when is_function(fun, 1), do: fun.(conn)
defp get_raw_body(_fun, conn), do: Map.get(conn.private, :raw_body)

# Helper functions for parsing configuration options

defp validate_path_pattern(string) when is_binary(string), do: string
Expand Down Expand Up @@ -186,6 +198,22 @@ defmodule ExTwilioWebhook.Plug do
"""
end

defp validate_raw_body({m, f, a}) when is_atom(m) and is_atom(f) and is_list(a) do
{m, f, a}
end

defp validate_raw_body(fun) when is_function(fun, 1), do: fun

defp validate_raw_body(nil), do: nil

defp validate_raw_body(value) do
raise """
The raw body function given to #{inspect(__MODULE__)} is invalid.
Expected a 1-arity function or an mfa tuple.
Got: #{inspect(value)}
"""
end

defp validate_public_url(value) do
if normalized = normalize_public_url(value) do
normalized
Expand Down
30 changes: 30 additions & 0 deletions test/ex_twilio_webhook/plug_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,23 @@ defmodule ExTwilioWebhook.PlugTest do
end
end

def alt_cache_raw_body(conn, opts) do
with {:ok, body, conn} <- Plug.Conn.read_body(conn, opts) do
conn = update_in(conn.assigns[:raw_body], &[body | &1 || []])

{:ok, body, conn}
end
end

@parser_opts [
parsers: [:json, :urlencoded],
json_decoder: Jason,
body_reader: {ExTwilioWebhook.BodyReader, :read_body, []}
]
@init Plug.Parsers.init(@parser_opts)
@init_with_different_cache @parser_opts
|> Keyword.put(:body_reader, {__MODULE__, :alt_cache_raw_body, []})
|> Plug.Parsers.init()

describe "with urlencoded payload" do
@body "AccountSid=ACe497b94cea336b5d573d9667ffda50bf&AddOns=%7B+%22status%22%3A+%22successful%22%2C+%22message%22%3A+null%2C+%22code%22%3A+null%2C+%22results%22%3A+%7B+%7D+%7D&ApiVersion=2010-04-01&From=%2B15017122661&FromCity=SAN+FRANCISCO&FromCountry=US&FromState=CA&FromZip=94903&To=%2B15558675310&ToCity=SAN+FRANCISCO&ToCountry=US&ToState=CA&ToZip=94105&Body=Ahoy&MessageSid=SMaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&NumMedia=0&NumSegments=1&ReferralNumMedia=0&SmsMessageSid=SMaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&SmsSid=SMaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&SmsStatus=received"
Expand All @@ -90,6 +101,25 @@ defmodule ExTwilioWebhook.PlugTest do
refute conn.halted
end

test "lets request through and handles raw body passed in a closure" do
opts =
WebhookPlug.init(
at: "/twilio/conference_status",
public_host: "https://0447-85-232-252-1.eu.ngrok.io",
secret: "c73504dac708a5cd9f57e80c747bb488",
raw_body: fn conn -> conn.assigns.raw_body end
)

conn =
conn(:post, @path, @body)
|> Plug.Conn.put_req_header("x-twilio-signature", "cN6s/ajWzahiBNHjFpssnkbSQSM=")
|> Plug.Conn.put_req_header("content-type", "application/x-www-form-urlencoded")
|> Plug.Parsers.call(@init_with_different_cache)
|> WebhookPlug.call(opts)

refute conn.halted
end

test "lets request through when signature matches and with a list of auth tokens" do
tokens = [
"bf0a3ff1ce8cdece9a76432e52659ff6",
Expand Down