Skip to content
Merged
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
142 changes: 142 additions & 0 deletions lib/waffle/content_disposition.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
defmodule Waffle.ContentDisposition do
@moduledoc false

@extended_value ~r/\A(?:[A-Za-z0-9!#$&+\-.^_`|~]|%[0-9A-Fa-f]{2})*\z/
@unsafe_filename ~r/[\x{0000}-\x{001F}\x{007F}-\x{009F}\/\\]/u

# Preserve Hackney's recovery behavior for non-ASCII bytes in unquoted tokens.
defguardp token_byte?(byte)
when byte >= 33 and byte != 127 and
byte not in ~c"()<>@,;:\\\"/[]?={} \t"

defguardp quoted_byte?(byte)
when byte == ?\t or (byte >= 32 and byte != 127)

@spec filename(term()) :: binary() | nil
def filename(value) when is_binary(value) do
case parse(value) do
{:ok, parameters} -> select_filename(parameters)
_ -> nil
end
end

def filename(_value), do: nil

defp parse(value) do
case value |> skip_ows() |> token() do
{:ok, rest, disposition} when disposition != "" -> parameters(rest, %{})
_ -> :error
end
end

defp parameters(value, acc) do
case skip_ows(value) do
"" -> {:ok, acc}
<<";", rest::binary>> -> parameter_or_end(skip_ows(rest), acc)
_ -> :error
end
end

defp parameter_or_end("", acc), do: {:ok, acc}
defp parameter_or_end(value, acc), do: parameter(value, acc)

defp parameter(data, acc) do
with {:ok, rest, name} when name != "" <- token(data),
<<"=", rest::binary>> <- skip_ows(rest),
{:ok, rest, parameter} <- rest |> skip_ows() |> word(),
name = String.downcase(name, :ascii),
false <- Map.has_key?(acc, name) do
parameters(rest, Map.put(acc, name, parameter))
else
_ -> :error
end
end

defp word(<<"\"", _rest::binary>> = value) do
with {:ok, rest, word} <- quoted_string(value), do: {:ok, rest, {:quoted, word}}
end

defp word(value) do
case token(value) do
{:ok, _rest, ""} -> :error
{:ok, rest, word} -> {:ok, rest, {:token, word}}
end
end

defp quoted_string(<<"\"", rest::binary>>), do: quoted_string(rest, "")

defp quoted_string(<<"\"", rest::binary>>, acc), do: {:ok, rest, acc}

defp quoted_string(<<"\\", character, rest::binary>>, acc)
when quoted_byte?(character) do
quoted_string(rest, <<acc::binary, character>>)
end

defp quoted_string(<<character, rest::binary>>, acc)
when quoted_byte?(character) do
quoted_string(rest, <<acc::binary, character>>)
end

defp quoted_string(_value, _acc), do: :error

defp token(value), do: token(value, "")

defp token(<<character, rest::binary>>, acc) when token_byte?(character) do
token(rest, <<acc::binary, character>>)
end

defp token(rest, acc), do: {:ok, rest, acc}

defp skip_ows(<<character, rest::binary>>) when character in [?\s, ?\t] do
skip_ows(rest)
end

defp skip_ows(value), do: value

defp select_filename(parameters) do
[
decode_extended_filename(parameters["filename*"]),
parameter_value(parameters["filename"])
]
|> Enum.find_value(&accept_filename/1)
end

defp parameter_value({_type, value}), do: value
defp parameter_value(_parameter), do: nil

defp decode_extended_filename({:token, value}) do
with [charset, _language, encoded_filename] <- String.split(value, "'", parts: 3),
true <- Regex.match?(@extended_value, encoded_filename) do
decode_charset(String.downcase(charset, :ascii), URI.decode(encoded_filename))
else
_ -> nil
end
end

defp decode_extended_filename(_parameter), do: nil

defp decode_charset("utf-8", value), do: if(String.valid?(value), do: value)

defp decode_charset("iso-8859-1", value),
do: :unicode.characters_to_binary(value, :latin1, :utf8)

defp decode_charset(_charset, _value), do: nil

defp accept_filename(value) when is_binary(value) and value != "",
do: if(safe_filename?(value), do: value)

defp accept_filename(_value), do: nil

defp safe_filename?(value) do
if String.valid?(value), do: not (value =~ @unsafe_filename), else: safe_raw_filename?(value)
end

defp safe_raw_filename?(<<>>), do: true

defp safe_raw_filename?(<<byte, rest::binary>>)
when byte >= 32 and (byte < 127 or byte > 159) and byte not in [?/, ?\\] do
safe_raw_filename?(rest)
end

defp safe_raw_filename?(_value), do: false
end
230 changes: 230 additions & 0 deletions test/content_disposition_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
defmodule WaffleTest.ContentDisposition do
use ExUnit.Case, async: true

alias Waffle.ContentDisposition

describe "filename/1" do
test "supports the RFC 6266 section 5 examples" do
examples = [
{"Attachment; filename=example.html", "example.html"},
{~s(INLINE; FILENAME= "an example.html"), "an example.html"},
{"attachment; filename*= UTF-8''%e2%82%ac%20rates", "€ rates"},
{~s(attachment; filename="EURO rates"; filename*=utf-8''%e2%82%ac%20rates), "€ rates"}
]

for {header, expected_filename} <- examples do
assert ContentDisposition.filename(header) == expected_filename
end
end

test "parses quoted and token filenames from any disposition type" do
assert ContentDisposition.filename(~s(attachment; filename="photo.jpg")) == "photo.jpg"
assert ContentDisposition.filename("inline; filename=photo.jpg") == "photo.jpg"

assert ContentDisposition.filename("form-data; name=file; filename=photo.jpg") ==
"photo.jpg"
end

test "preserves Hackney compatibility for unquoted non-ASCII filenames" do
assert ContentDisposition.filename("attachment; filename=føø.jpg") == "føø.jpg"

latin1_filename = <<"caf", 0xE9, ".txt">>

assert ContentDisposition.filename("attachment; filename=" <> latin1_filename) ==
latin1_filename
end

test "handles case-insensitive names and optional whitespace" do
assert ContentDisposition.filename(~s( ATTACHMENT ; FILENAME = "photo.jpg" )) ==
"photo.jpg"
end

test "finds filename after other parameters" do
assert ContentDisposition.filename(
~s(attachment; creation-date="today"; filename="photo.jpg")
) == "photo.jpg"
end

test "handles semicolons and escaped characters inside quoted filenames" do
assert ContentDisposition.filename(~s(attachment; filename="a;b.jpg")) == "a;b.jpg"
assert ContentDisposition.filename(~s(attachment; filename="a\\\"b.jpg")) == ~s(a"b.jpg)
assert ContentDisposition.filename(~s(attachment; filename=" photo.jpg ")) == " photo.jpg "
end

test "tolerates a trailing semicolon like Hackney" do
assert ContentDisposition.filename(~s(attachment; filename="photo.jpg";)) == "photo.jpg"

assert ContentDisposition.filename(~s(attachment; filename="photo.jpg"; \t )) ==
"photo.jpg"
end

test "supports representative Greenbytes quoted filename cases" do
assert ContentDisposition.filename(~s(inline; filename="foo.html")) == "foo.html"
assert ContentDisposition.filename(~s(attachment; filename="f\\oo.html")) == "foo.html"

assert ContentDisposition.filename(~s(attachment; filename="\\\"quoting\\\" tested.html")) ==
~s("quoting" tested.html)

assert ContentDisposition.filename(~s(attachment; filename="foo;bar.html")) ==
"foo;bar.html"
end

test "prefers UTF-8 filename* over filename" do
assert ContentDisposition.filename(
~s(attachment; filename="fallback.jpg"; filename*=UTF-8''f%C3%B8%C3%B8.jpg)
) == "føø.jpg"

assert ContentDisposition.filename(
~s(attachment; filename*=utf-8''preferred.jpg; filename="fallback.jpg")
) == "preferred.jpg"
end

test "decodes spaces and preserves plus signs in filename*" do
assert ContentDisposition.filename(
"attachment; filename*=UTF-8''quarterly%20report+notes.pdf"
) == "quarterly report+notes.pdf"
end

test "decodes characters that must be percent-encoded in filename*" do
assert ContentDisposition.filename("attachment; filename*=UTF-8''foo%2Abar%27baz.jpg") ==
"foo*bar'baz.jpg"
end

test "supports and ignores a language component in filename*" do
assert ContentDisposition.filename("attachment; filename*=UTF-8'en'%E2%82%ACrates.pdf") ==
"€rates.pdf"
end

test "supports case-insensitive UTF-8 and ISO-8859-1 charsets" do
assert ContentDisposition.filename("attachment; filename*=uTf-8''%E2%82%ACrates.pdf") ==
"€rates.pdf"

assert ContentDisposition.filename("attachment; filename*=ISO-8859-1''caf%E9.txt") ==
"café.txt"
end

test "falls back to filename when filename* cannot be decoded" do
for extended_value <- [
"UTF-8''bad%ZZ.jpg",
"UTF-8''bad%.jpg",
"UTF-8''bad%A.jpg",
"UTF-8''bad%FF.jpg",
"UTF-16''photo.jpg",
"UTF-8'photo.jpg",
"UTF-8''",
"photo.jpg"
] do
header =
~s(attachment; filename="fallback.jpg"; filename*=#{extended_value})

assert ContentDisposition.filename(header) == "fallback.jpg"
end
end

test "rejects unsafe regular filenames" do
unsafe_headers = [
~s(attachment; filename=""),
~s(attachment; filename="../secret.txt"),
~s(attachment; filename="..\\\\secret.txt"),
~s(attachment; filename="folder/photo.jpg"),
"attachment; filename=\"photo\u0085.jpg\""
]

for header <- unsafe_headers do
assert ContentDisposition.filename(header) == nil
end
end

test "falls back to filename when filename* decodes to unsafe characters" do
for extended_value <- [
"UTF-8''photo%00.jpg",
"UTF-8''photo%09.jpg",
"UTF-8''photo%0D%0A.jpg",
"UTF-8''photo%1B.jpg",
"UTF-8''photo%7F.jpg",
"UTF-8''photo%C2%85.jpg",
"ISO-8859-1''photo%85.jpg",
"UTF-8''..%2Fsecret.txt",
"UTF-8''..%5Csecret.txt"
] do
header =
~s(attachment; filename="fallback.jpg"; filename*=#{extended_value})

assert ContentDisposition.filename(header) == "fallback.jpg"
end
end

test "rejects duplicate parameter names case-insensitively" do
assert ContentDisposition.filename("attachment; filename=first.jpg; filename=second.jpg") ==
nil

assert ContentDisposition.filename("attachment; filename=first.jpg; FILENAME=second.jpg") ==
nil

assert ContentDisposition.filename(
"attachment; filename*=UTF-8''first.jpg; filename*=UTF-8''second.jpg"
) == nil

assert ContentDisposition.filename(
"attachment; name=first; NAME=second; filename=photo.jpg"
) == nil
end

test "rejects invalid filename* syntax" do
invalid_values = [
~s("UTF-8''photo.jpg"),
"UTF-8''foo*bar.jpg",
"UTF-8''foo'bar.jpg",
"UTF-8''føø.jpg"
]

for value <- invalid_values do
assert ContentDisposition.filename("attachment; filename*=#{value}") == nil
end
end

test "falls back to filename when filename* uses invalid syntax" do
assert ContentDisposition.filename(
~s(attachment; filename="fallback.jpg"; filename*="UTF-8''photo.jpg")
) == "fallback.jpg"
end

test "returns nil when filename* is invalid and no filename fallback exists" do
assert ContentDisposition.filename("attachment; filename*=UTF-8''bad%ZZ.jpg") == nil
assert ContentDisposition.filename("attachment; filename*=UTF-8''bad%FF.jpg") == nil
assert ContentDisposition.filename("attachment; filename*=UTF-16''photo.jpg") == nil
assert ContentDisposition.filename("attachment; filename*=UTF-8''") == nil
assert ContentDisposition.filename("attachment; filename*=UTF-8''photo%00.jpg") == nil
assert ContentDisposition.filename("attachment; filename*=UTF-8''photo%09.jpg") == nil
assert ContentDisposition.filename("attachment; filename*=UTF-8''photo%0D%0A.jpg") == nil
assert ContentDisposition.filename("attachment; filename*=UTF-8''photo%7F.jpg") == nil
assert ContentDisposition.filename("attachment; filename*=UTF-8''photo%C2%85.jpg") == nil
assert ContentDisposition.filename("attachment; filename*=ISO-8859-1''photo%85.jpg") == nil
assert ContentDisposition.filename("attachment; filename*=UTF-8''..%2Fsecret.txt") == nil
assert ContentDisposition.filename("attachment; filename*=UTF-8''..%5Csecret.txt") == nil
end

test "rejects malformed headers instead of partially matching them" do
assert ContentDisposition.filename(~s(attachment; filename="unterminated.jpg)) == nil
assert ContentDisposition.filename(~s(attachment; filename="photo.jpg"; invalid)) == nil
assert ContentDisposition.filename("attachment; filename=") == nil
assert ContentDisposition.filename("filename=photo.jpg") == nil
assert ContentDisposition.filename(~s(attachment filename="photo.jpg")) == nil
assert ContentDisposition.filename(~s(attachment; = "photo.jpg")) == nil
end

test "rejects control characters and header injection" do
assert ContentDisposition.filename("attachment; filename=\"photo\r\n.jpg\"") == nil
assert ContentDisposition.filename("attachment; filename=\"photo\0.jpg\"") == nil
assert ContentDisposition.filename("attachment\r\n; filename=photo.jpg") == nil
assert ContentDisposition.filename("attachment; file\r\nname=photo.jpg") == nil
end

test "returns nil without a filename parameter" do
assert ContentDisposition.filename("attachment") == nil
assert ContentDisposition.filename("inline; name=file") == nil
assert ContentDisposition.filename(nil) == nil
assert ContentDisposition.filename(%{}) == nil
end
end
end
Loading