Skip to content
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
121 changes: 121 additions & 0 deletions lib/waffle/http_client/content_disposition.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
defmodule Waffle.HTTPClient.ContentDisposition do
@moduledoc """
Parses filenames out of `Content-Disposition` header values.

Supports both the RFC 5987 extended notation (`filename*=...`), which
takes priority per RFC 6266 when both are present, and the plain
`filename=` parameter (quoted or unquoted).
"""

@doc """
Extracts the filename from a `Content-Disposition` header value.

Returns `nil` if no filename parameter is present.

## Examples

iex> Waffle.HTTPClient.ContentDisposition.filename(~s(attachment; filename="photo.jpg"))
"photo.jpg"

iex> Waffle.HTTPClient.ContentDisposition.filename("attachment; filename*=UTF-8''my%20photo.jpg")
"my photo.jpg"

iex> Waffle.HTTPClient.ContentDisposition.filename("inline")
nil

"""
@spec filename(String.t()) :: String.t() | nil
def filename(value) when is_binary(value) do
params = parse_params(value)

case Map.fetch(params, "filename*") do
{:ok, extended_value} ->
decode_extended_value(extended_value)

:error ->
Map.get(params, "filename")
end
end

# Splits "type; key=value; key2="value 2"" into a map of downcased
# parameter names to their unquoted/unescaped values. The leading
# disposition-type token (e.g. "attachment"/"inline") is discarded.
defp parse_params(value) do
value
|> split_semicolons()
|> Enum.drop(1)
|> Enum.reduce(%{}, fn part, acc ->
case parse_param(String.trim(part)) do
{key, val} -> Map.put_new(acc, key, val)
:error -> acc
end
end)
end

# Splits on `;`, while treating the contents of a double-quoted string as
# opaque so that a `;` inside a quoted filename doesn't split it.
defp split_semicolons(value), do: split_semicolons(value, <<>>, [], false)

defp split_semicolons(<<?\\, char, rest::binary>>, acc, parts, true) do
split_semicolons(rest, <<acc::binary, ?\\, char>>, parts, true)
end

defp split_semicolons(<<?", rest::binary>>, acc, parts, quoted?) do
split_semicolons(rest, <<acc::binary, ?">>, parts, not quoted?)
end

defp split_semicolons(<<?;, rest::binary>>, acc, parts, false) do
split_semicolons(rest, <<>>, [acc | parts], false)
end

defp split_semicolons(<<char, rest::binary>>, acc, parts, quoted?) do
split_semicolons(rest, <<acc::binary, char>>, parts, quoted?)
end

defp split_semicolons(<<>>, acc, parts, _quoted?) do
Enum.reverse([acc | parts])
end

# Parses a single "key=value" parameter, downcasing the key and
# unquoting/unescaping the value if it's a quoted string. Returns
# `:error` for anything that isn't a well-formed, non-empty parameter.
defp parse_param(param) do
with [key, value] <- :binary.split(param, "="),
value <- value |> String.trim() |> unquote_value(),
false <- value == "" do
{key |> String.trim() |> String.downcase(), value}
else
_ -> :error
end
end

defp unquote_value(<<?", rest::binary>>), do: parse_quoted(rest, <<>>)
defp unquote_value(value), do: unquoted_token(value, <<>>)

defp parse_quoted(<<?\\, char, rest::binary>>, acc), do: parse_quoted(rest, <<acc::binary, char>>)
defp parse_quoted(<<?", _rest::binary>>, acc), do: acc
defp parse_quoted(<<char, rest::binary>>, acc), do: parse_quoted(rest, <<acc::binary, char>>)
defp parse_quoted(<<>>, acc), do: acc

defp unquoted_token(<<>>, acc), do: acc
defp unquoted_token(<<char, _rest::binary>>, acc) when char in [?\s, ?\t], do: acc
defp unquoted_token(<<char, rest::binary>>, acc), do: unquoted_token(rest, <<acc::binary, char>>)

# RFC 5987: charset'language'percent-encoded-value,
# e.g. UTF-8''my%20photo.jpg or UTF-8'en'my%20photo.jpg
defp decode_extended_value(value) do
case String.split(value, "'", parts: 3) do
[_charset, _language, encoded] -> safe_uri_decode(encoded)
_ -> safe_uri_decode(value)
end
end

# A malformed percent-encoding (e.g. a trailing "%" or "%zz") makes
# `URI.decode/1` raise. Fall back to the raw (un-decoded) value rather than
# crashing the caller over a malformed header from a remote server.
defp safe_uri_decode(value) do
URI.decode(value)
rescue
ArgumentError -> value
end
end
213 changes: 185 additions & 28 deletions lib/waffle/http_client/hackney.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Waffle.HTTPClient.Hackney do

Add `:hackney` to your dependencies:

{:hackney, "~> 1.9"}
{:hackney, "~> 4.5.2"}

## Configuration

Expand All @@ -18,54 +18,211 @@ defmodule Waffle.HTTPClient.Hackney do
| `:connect_timeout` | `10_000` | Timeout for establishing a connection, in milliseconds |
| `:max_body_length` | `:infinity` | Maximum response body size, in bytes |
| `:follow_redirect` | `true` | Whether to follow HTTP redirects automatically |
| `:max_redirect` | `5` | Maximum number of redirects to follow |
"""

@behaviour Waffle.HTTPClient

@impl Waffle.HTTPClient
def get(url, headers, options) do
hackney_options = [
follow_redirect: Keyword.get(options, :follow_redirect, true),
recv_timeout: Keyword.get(options, :recv_timeout, 5_000),
connect_timeout: Keyword.get(options, :connect_timeout, 10_000)
]
do_get(url, headers, options, 0)
end

defp do_get(url, headers, options, redirect_count) do
connect_timeout = Keyword.get(options, :connect_timeout, 10_000)
recv_timeout = Keyword.get(options, :recv_timeout, 5_000)
follow_redirect = Keyword.get(options, :follow_redirect, true)
max_redirect = Keyword.get(options, :max_redirect, 5)
max_body_length = Keyword.get(options, :max_body_length, :infinity)

hackney_options = [
{:async, :once},
follow_redirect: follow_redirect,
recv_timeout: recv_timeout,
connect_timeout: connect_timeout,
# Force HTTP/1.1: hackney 4.5.2's HTTP/2 async response dispatch
# (hackney_conn:h2_on_response/4, h2_on_data/4) never delivers
# `hackney_response` messages to the caller.
# See https://github.com/benoitc/hackney/issues/909
protocols: [:http1]
]

case :hackney.get(url, headers, "", hackney_options) do
{:ok, 200, response_headers, client_ref} ->
read_body(client_ref, response_headers, max_body_length)
{:ok, ref} ->
receive_status(
ref,
max_body_length,
recv_timeout,
url,
headers,
options,
max_redirect,
redirect_count
)

{:error, reason} ->
normalize_error(reason)
end
end

{:ok, 503, _headers, client_ref} ->
:hackney.close(client_ref)
defp receive_status(
ref,
max_body_length,
recv_timeout,
url,
headers,
options,
max_redirect,
redirect_count
) do
receive do
{:hackney_response, ^ref, {:status, 200, _reason}} ->
receive_headers(ref, max_body_length, recv_timeout)

{:hackney_response, ^ref, {:status, 503, _reason}} ->
close_and_flush(ref)
{:error, :service_unavailable}

{:ok, status, _headers, client_ref} ->
:hackney.close(client_ref)
{:hackney_response, ^ref, {:redirect, location, _response_headers}} ->
follow_redirect(ref, url, location, headers, options, max_redirect, redirect_count)

{:hackney_response, ^ref, {:see_other, location, _response_headers}} ->
follow_redirect(ref, url, location, headers, options, max_redirect, redirect_count)

{:hackney_response, ^ref, {:status, status, _reason}} ->
close_and_flush(ref)
{:error, {:http_error, status}}

{:error, reason} ->
{:hackney_response, ^ref, {:error, reason}} ->
close_and_flush(ref)
normalize_error(reason)
after
recv_timeout ->
close_and_flush(ref)
{:error, :recv_timeout}
end
end

defp read_body(client_ref, response_headers, max_body_length) do
case :hackney.body(client_ref, max_body_length) do
{:ok, body} ->
filename =
:hackney_headers.new(response_headers)
|> get_content_disposition_filename()
defp follow_redirect(ref, url, location, headers, options, max_redirect, redirect_count) do
close_and_flush(ref)

if filename, do: {:ok, body, filename}, else: {:ok, body}
if redirect_count >= max_redirect do
{:error, {:too_many_redirects, redirect_count}}
else
new_url = resolve_redirect_url(url, location)
new_headers = strip_sensitive_headers_on_cross_origin(url, new_url, headers)
do_get(new_url, new_headers, options, redirect_count + 1)
end
end

{:error, reason} ->
:hackney.close(client_ref)
defp resolve_redirect_url(url, location) do
location = to_string(location)

url
|> URI.merge(location)
|> URI.to_string()
end

# Mirrors hackney's own CVE-2018-1000007 mitigation for its sync client:
# don't forward credentials to a different host on redirect.
defp strip_sensitive_headers_on_cross_origin(url, new_url, headers) do
if same_origin?(url, new_url) do
headers
else
Enum.reject(headers, fn {key, _value} ->
String.downcase(to_string(key)) in ["authorization", "cookie"]
end)
end
end

defp same_origin?(url, new_url) do
a = URI.parse(url)
b = URI.parse(new_url)
{a.scheme, a.host, a.port} == {b.scheme, b.host, b.port}
end

defp receive_headers(ref, max_body_length, recv_timeout) do
receive do
{:hackney_response, ^ref, {:headers, response_headers}} ->
:hackney.stream_next(ref)
receive_body(ref, response_headers, [], 0, max_body_length, recv_timeout)

{:hackney_response, ^ref, {:error, reason}} ->
close_and_flush(ref)
normalize_error(reason)
after
recv_timeout ->
close_and_flush(ref)
{:error, :recv_timeout}
end
end

# connect timeout: hackney returns %{reason: :timeout}, not a bare atom
defp receive_body(ref, response_headers, acc, size, max_body_length, recv_timeout) do
receive do
{:hackney_response, ^ref, :done} ->
body = acc |> Enum.reverse() |> IO.iodata_to_binary()
build_response(body, response_headers)

{:hackney_response, ^ref, chunk} when is_binary(chunk) ->
new_size = size + byte_size(chunk)

if max_body_length != :infinity and new_size > max_body_length do
close_and_flush(ref)
{:error, {:http_error, :body_too_large}}
else
:hackney.stream_next(ref)

receive_body(
ref,
response_headers,
[chunk | acc],
new_size,
max_body_length,
recv_timeout
)
end

{:hackney_response, ^ref, {:error, reason}} ->
close_and_flush(ref)
normalize_error(reason)
after
recv_timeout ->
close_and_flush(ref)
{:error, :recv_timeout}
end
end

defp build_response(body, response_headers) do
filename =
:hackney_headers.new(response_headers)
|> get_content_disposition_filename()

if filename, do: {:ok, body, filename}, else: {:ok, body}
end

# Closes the connection and drains any `{:hackney_response, ^ref, _}`
# messages already sitting in our mailbox. Hackney can push multiple
# messages (e.g. status + headers, or a trailing chunk) before we act on
# an error/abort, and without this a long-lived process (e.g. a GenServer
# calling into Waffle) would accumulate stray `handle_info` messages.
defp close_and_flush(ref) do
:hackney.close(ref)
flush_messages(ref)
end

defp flush_messages(ref) do
receive do
{:hackney_response, ^ref, _msg} -> flush_messages(ref)
after
0 -> :ok
end
end

# connect/checkout timeout: hackney returns %{reason: :timeout} (older
# shape) or the bare atoms :connect_timeout / :checkout_timeout (4.5.2)
defp normalize_error(%{reason: :timeout}), do: {:error, :timeout}
defp normalize_error(:connect_timeout), do: {:error, :timeout}
defp normalize_error(:checkout_timeout), do: {:error, :timeout}
# recv timeout: hackney returns a bare :timeout atom
defp normalize_error(:timeout), do: {:error, :recv_timeout}
defp normalize_error(reason), do: {:error, {:http_error, reason}}
Expand All @@ -75,11 +232,11 @@ defmodule Waffle.HTTPClient.Hackney do
:undefined ->
nil

value ->
case :hackney_headers.content_disposition(value) do
{_, [{"filename", filename} | _]} -> filename
_ -> nil
end
value when is_binary(value) ->
Waffle.HTTPClient.ContentDisposition.filename(value)

_ ->
nil
end
end
end
Loading
Loading