Packages

Server side implementation of RFC3230 Instance Digests as a Plug

Current section

Files

Jump to
plug_body_digest lib plug_body_digest.ex
Raw

lib/plug_body_digest.ex

defmodule PlugBodyDigest do
@moduledoc """
Plug to verify the request body against the digest value sent in the HTTP
'Digest' header, as defined in [RFC3230, section 4.3.2](https://tools.ietf.org/html/rfc3230#section-4.3.2).
Supported digests are "sha-512", "sha-256" and "sha".
## Options
* `:on_success` - an optional callback for updating the `Plug.Conn` state
upon success; possible values include:
* `nil` (the default) - do nothing
* `{m, f, a}` - call the function identified by the atom `f` in module
`m`; the function receives the current `Plug.Conn` struct along with
any additional parameters in the list `a`, and is expected to return
the updated `Plug.Conn` struct; see the example below
* `:on_failure` - an optional callback for updating the `Plug.Conn` state
upon failure; possible values include:
* `{PlugBodyDigest, :failure, []}` (the default) - halt the connection
with an appropriate response; see `failure/3` below
* `{PlugBodyDigest, :optional, []}` - make the 'Digest' header
optional; see `optional/3` below
* `{m, f, a}` - call the function identified by the atom `f` in module
`m`; the function receives the current `Plug.Conn` struct, the error
reason (see `t:error_reason/0`) and the algorithm list (a string,
for possible use in a 'Want-Digest' response header) along with any
additional parameters in the list `a`, and is expected to return the
updated `Plug.Conn` struct
* `nil` - do nothing
## Example
# Update the Plug.Parsers configuration, adding the `:body_reader`
# option:
plug Plug.Parsers,
parsers: [:urlencoded, :json],
body_reader: {PlugBodyDigest, :digest_body_reader, []},
json_decoder: Jason
# Invoke PlugBodyDigest after Plug.Parsers
plug PlugBodyDigest,
on_success: {Plug.Conn, :assign, [:valid_digest, true]},
on_failure: {PlugBodyDigest, :optional, []}
"""
import Plug.Conn
require Logger
alias PlugBodyDigest.Crypto
@behaviour Plug
@algorithms [:sha512, :sha256, :sha]
@default_on_success nil
@default_on_failure {__MODULE__, :failure, []}
@typedoc """
Error reasons, passed to the failure callback.
Server errors:
* `:body_not_read` - the request body was not read, because the request's
'Content-Type' is not handled by `Plug.Parsers`; see
`digest_body_reader/3`
* `:multipart` - the request contained a multipart content-type, which is
not supported by `PlugBodyDigest`; see `digest_body_reader/3`
* `:bad_algorithm` - the digest function invocation failed for the
selected algorithm; verify that the `:crypto` application was started
and that it supports the necessary algorithms
Client errors:
* `:no_digest_header` - no 'Digest' header was included in the request
* `:algorithm_mismatch` - none of the supported digest algorithms was
included in the 'Digest' request header
* `:malformed_digest_value` - the digest value in the 'Digest' request
header could not be decoded
* `:digest_mismatch` - the calculated digest value for the request body
does not match the expected value specified in the 'Digest' request
header
"""
@type error_reason ::
:body_not_read
| :multipart
| :bad_algorithm
| :no_digest_header
| :algorithm_mismatch
| :malformed_digest_value
| :digest_mismatch
@impl true
@spec init(Keyword.t()) :: Keyword.t()
def init(opts), do: opts
@impl true
@spec call(Plug.Conn.t(), Keyword.t()) :: Plug.Conn.t()
def call(conn, opts) do
# The `algorithms` option is currently undocumented: it is a bit awkward to
# use, since the configuration of the Plug needs to match that of the
# `body_reader` function...
algorithms = Keyword.get(opts, :algorithms, @algorithms)
case verify(conn, algorithms) do
:ok ->
opts
|> Keyword.get(:on_success, @default_on_success)
|> on_success(conn)
{:error, reason} ->
want_digest =
algorithms
|> Enum.map(&Crypto.algorithm_name/1)
|> Enum.join(",")
opts
|> Keyword.get(:on_failure, @default_on_failure)
|> on_failure(conn, reason, want_digest)
end
end
defp verify(%{private: %{plug_body_digest: {:error, _reason} = error}}, _algorithms) do
error
end
defp verify(%{private: %{plug_body_digest: state}}, _algorithms) do
Crypto.verify(state)
end
defp verify(%{body_params: %Plug.Conn.Unfetched{aspect: :body_params}}, _algorithms) do
{:error, :body_not_read}
end
defp verify(%{private: %{plug_multipart: :done}}, _algorithms) do
{:error, :multipart}
end
defp verify(%{body_params: empty} = conn, algorithms) when empty == %{} do
conn
|> update_digest("", algorithms: algorithms)
|> verify(algorithms)
end
defp verify(%Plug.Conn{adapter: {Plug.Adapters.Test.Conn, _}}, _algorithms) do
# When testing with Plug.Test the request body is not always read through
# Plug.Parsers, so the custom `body_reader` is not invoked. If realistic
# Digest header handing is required in tests, always pass in the body
# as a binary in `Plug.Test.conn/3`!
:ok
end
defp on_success(nil, conn), do: conn
defp on_success({m, f, a}, conn), do: apply(m, f, [conn | a])
defp on_success(fun, conn) when is_function(fun, 1), do: fun.(conn)
defp on_failure(nil, conn, _reason, _want_digest), do: conn
defp on_failure({m, f, a}, conn, reason, want_digest),
do: apply(m, f, [conn, reason, want_digest | a])
defp on_failure(fun, conn, reason, want_digest) when is_function(fun, 3),
do: fun.(conn, reason, want_digest)
defp on_failure(fun, conn, reason, _want_digest) when is_function(fun, 2),
do: fun.(conn, reason)
@doc """
The default failure function.
It logs an error, returns a 500 'Server Error' response and halts the
connection in the following scenarios:
* If the request body was not read, because the request's 'Content-Type' is
not handled by `Plug.Parsers`; see `digest_body_reader/3`
* If the digest function invocation failed for the selected algorithm
Otherwise logs the failure, returns a 403 'Forbidden' response with a
'Want-Digest' response header listing the supported algorithms, and halts
the connection.
"""
@spec failure(Plug.Conn.t(), error_reason(), String.t()) :: Plug.Conn.t()
def failure(conn, :body_not_read, _want_digest) do
Logger.error("Cannot verify digest: content type not handled by Plug.Parsers")
conn
|> send_resp(500, "")
|> halt()
end
def failure(conn, :multipart, _want_digest) do
Logger.error("Cannot verify digest: multipart content types are not supported")
conn
|> send_resp(500, "")
|> halt()
end
def failure(conn, :bad_algorithm, want_digest) do
Logger.error("Invalid algorithm configuration: #{want_digest}")
conn
|> send_resp(500, "")
|> halt()
end
def failure(conn, reason, want_digest) do
Logger.info("Digest failure: #{reason}")
conn
|> put_resp_header("want-digest", want_digest)
|> send_resp(403, "")
|> halt()
end
@doc """
An alternative failure handler function, allowing requests without a
'Digest' request header.
All other errors are handled as described for `failure/3`.
"""
@spec optional(Plug.Conn.t(), error_reason(), String.t()) :: Plug.Conn.t()
def optional(conn, :no_digest_header, _want_digest), do: conn
def optional(conn, reason, want_digest), do: failure(conn, reason, want_digest)
@doc """
Custom request body reader for `Plug.Parsers`, updating the digest
value(s) while the request body is being read.
Add or update `Plug.Parsers` (e.g. in the application's Phoenix endpoint)
with the `:body_reader` option:
plug Plug.Parsers,
parsers: [:urlencoded, :json],
body_reader: {PlugBodyDigest, :digest_body_reader, []},
json_decoder: Jason
Only works for parsers that respect the `:body_reader` option, including
`Plug.Parsers.URLENCODED` and `Plug.Parsers.JSON`. Not supported are
`Plug.Parsers.MULTIPART` and content types that are ignored by `Plug.Parsers`
through the `:pass` option.
"""
@spec digest_body_reader(Plug.Conn.t(), Keyword.t(), Keyword.t()) ::
{:ok, binary(), Plug.Conn.t()} | {:more, binary(), Plug.Conn.t()} | {:error, term()}
def digest_body_reader(conn, read_opts, digest_opts \\ []) do
case Plug.Conn.read_body(conn, read_opts) do
{status, body, conn} ->
{status, body, update_digest(conn, body, digest_opts)}
error ->
error
end
end
# Error condition, no need to do anything
defp update_digest(%{private: %{plug_body_digest: {:error, _}}} = conn, _data, _opts), do: conn
# This is not the first pass: update with new data
defp update_digest(%{private: %{plug_body_digest: state}} = conn, data, _opts) do
put_private(conn, :plug_body_digest, Crypto.update(state, data))
end
# First pass, look for Digest header and select one algorithm
defp update_digest(conn, data, opts) do
algorithms = Keyword.get(opts, :algorithms, @algorithms)
with {:ok, digest_header} <- get_digest_header(conn),
{:ok, algorithm, expected} <- select_algorithm(digest_header, algorithms),
{:ok, initial} <- Crypto.init(algorithm, expected) do
put_private(conn, :plug_body_digest, Crypto.update(initial, data))
else
error ->
put_private(conn, :plug_body_digest, error)
end
end
defp get_digest_header(conn) do
case get_req_header(conn, "digest") do
[] -> {:error, :no_digest_header}
digest_headers -> {:ok, parse_digest(digest_headers)}
end
end
defp parse_digest(digest_headers) do
digest_headers
|> Enum.flat_map(&:binary.split(&1, ",", [:global]))
|> Enum.map(&String.trim/1)
|> Enum.map(fn instance ->
case :binary.split(instance, "=") do
[algorithm, digest] -> {String.downcase(algorithm), digest}
_otherwise -> nil
end
end)
|> Enum.into(%{})
end
defp select_algorithm(_digests, []), do: {:error, :algorithm_mismatch}
defp select_algorithm(digests, [algorithm | more]) do
case Map.get(digests, Crypto.algorithm_name(algorithm)) do
nil ->
select_algorithm(digests, more)
expected_b64 ->
case Base.decode64(expected_b64) do
:error -> {:error, :malformed_digest_value}
{:ok, expected} -> {:ok, algorithm, expected}
end
end
end
end