VYPR
Low severityNVD Advisory· Published Jun 2, 2026

CVE-2026-48596

CVE-2026-48596

Description

Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Request/Response Splitting') vulnerability in elixir-tesla tesla allows HTTP header injection via Tesla.Multipart.add_content_type_param/2.

Tesla.Multipart.add_content_type_param/2 appends caller-supplied strings to the multipart content_type_params list without validating for CR (\r) or LF (\n) characters. Tesla.Multipart.headers/1 then joins these params verbatim with "; " to construct the outgoing Content-Type header value. A param containing \r\n splits the header line, allowing arbitrary headers to be injected into the outbound HTTP request. Any application that forwards untrusted input (such as a user-supplied charset or parameter string) into add_content_type_param/2 is affected.

This issue affects tesla: from 0.8.0 before 1.18.3.

Affected products

1

Patches

1
23601edac5d2

Merge commit from fork

https://github.com/elixir-tesla/teslaYordis PrietoJun 2, 2026via body-scan
2 files changed · +261 0
  • lib/tesla/multipart.ex+120 0 modified
    @@ -35,6 +35,19 @@ defmodule Tesla.Multipart do
       @type part_stream :: Enum.t()
       @type part_value :: iodata | part_stream | function()
     
    +  @token_specials ~c"!#$%&'*+-.^_`|~"
    +
    +  defguardp is_tchar(c)
    +            when c in ?A..?Z or
    +                   c in ?a..?z or
    +                   c in ?0..?9 or
    +                   c in @token_specials
    +
    +  defguardp is_field_vchar(c) when c == ?\t or (c >= 32 and c != 127)
    +
    +  defguardp is_qdtext(c)
    +            when (c == ?\t or (c >= 32 and c != 127)) and c != ?" and c != ?\\
    +
       defstruct parts: [],
                 boundary: nil,
                 content_type_params: []
    @@ -55,9 +68,14 @@ defmodule Tesla.Multipart do
     
       @doc """
       Add a parameter to the multipart content-type.
    +
    +  Raises `ArgumentError` if `param` contains characters that are not
    +  allowed in an HTTP `Content-Type` parameter per RFC 7231 §3.1.1.1,
    +  preventing header injection into the outgoing `Content-Type` header.
       """
       @spec add_content_type_param(t, String.t()) :: t
       def add_content_type_param(%__MODULE__{} = mp, param) do
    +    :ok = assert_content_type_param!(param)
         %{mp | content_type_params: mp.content_type_params ++ [param]}
       end
     
    @@ -67,7 +85,10 @@ defmodule Tesla.Multipart do
       @spec add_field(t, String.t(), part_value, Keyword.t()) :: t | no_return
       def add_field(%__MODULE__{} = mp, name, value, opts \\ []) do
         :ok = assert_part_value!(value)
    +    :ok = assert_quoted_string_safe!("field name", name)
         {headers, opts} = Keyword.pop_first(opts, :headers, [])
    +    :ok = assert_part_headers!(headers)
    +    :ok = assert_dispositions!(opts)
     
         part = %Part{
           body: value,
    @@ -216,6 +237,105 @@ defmodule Tesla.Multipart do
         raise(ArgumentError, "#{inspect(val)} is not a supported multipart value.")
       end
     
    +  @spec assert_part_headers!(Tesla.Env.headers()) :: :ok | no_return
    +  defp assert_part_headers!(headers) when is_list(headers) do
    +    Enum.each(headers, &assert_part_header!/1)
    +  end
    +
    +  defp assert_part_header!({name, value}) do
    +    :ok = assert_token!("header name", to_string(name))
    +    :ok = assert_field_value!("header value", to_string(value))
    +  end
    +
    +  @spec assert_dispositions!(Keyword.t()) :: :ok | no_return
    +  defp assert_dispositions!(dispositions) when is_list(dispositions) do
    +    Enum.each(dispositions, &assert_disposition!/1)
    +  end
    +
    +  defp assert_disposition!({_key, value}) do
    +    assert_quoted_string_safe!("disposition value", to_string(value))
    +  end
    +
    +  @spec assert_token!(String.t(), any) :: :ok | no_return
    +  defp assert_token!(label, <<c, _::binary>> = value) when is_tchar(c) do
    +    do_assert_token!(label, value, value)
    +  end
    +
    +  defp assert_token!(label, value) do
    +    raise ArgumentError,
    +          "#{label} must be a non-empty RFC 7230 token, got: #{inspect(value)}"
    +  end
    +
    +  defp do_assert_token!(_label, _orig, <<>>), do: :ok
    +
    +  defp do_assert_token!(label, orig, <<c, rest::binary>>) when is_tchar(c) do
    +    do_assert_token!(label, orig, rest)
    +  end
    +
    +  defp do_assert_token!(label, orig, <<c, _::binary>>) do
    +    raise ArgumentError,
    +          "#{label} must be an RFC 7230 token, got: #{inspect(orig)} " <>
    +            "(invalid character: #{inspect(<<c>>)})"
    +  end
    +
    +  @spec assert_field_value!(String.t(), any) :: :ok | no_return
    +  defp assert_field_value!(label, value) when is_binary(value) do
    +    do_assert_field_value!(label, value, value)
    +  end
    +
    +  defp do_assert_field_value!(_label, _orig, <<>>), do: :ok
    +
    +  defp do_assert_field_value!(label, orig, <<c, rest::binary>>) when is_field_vchar(c) do
    +    do_assert_field_value!(label, orig, rest)
    +  end
    +
    +  defp do_assert_field_value!(label, orig, <<c, _::binary>>) do
    +    raise ArgumentError,
    +          "#{label} must contain only printable characters per RFC 7230 " <>
    +            "(no CTLs other than HTAB, no DEL), got: #{inspect(orig)} " <>
    +            "(invalid character: #{inspect(<<c>>)})"
    +  end
    +
    +  @spec assert_quoted_string_safe!(String.t(), any) :: :ok | no_return
    +  defp assert_quoted_string_safe!(label, value) when is_binary(value) do
    +    do_assert_quoted_string_safe!(label, value, value)
    +  end
    +
    +  defp do_assert_quoted_string_safe!(_label, _orig, <<>>), do: :ok
    +
    +  defp do_assert_quoted_string_safe!(label, orig, <<c, rest::binary>>) when is_qdtext(c) do
    +    do_assert_quoted_string_safe!(label, orig, rest)
    +  end
    +
    +  defp do_assert_quoted_string_safe!(label, orig, <<c, _::binary>>) do
    +    raise ArgumentError,
    +          "#{label} must be safe for an HTTP quoted-string per RFC 7230 " <>
    +            "(no CTLs other than HTAB, no DEL, no `\"`, no `\\`), got: " <>
    +            "#{inspect(orig)} (invalid character: #{inspect(<<c>>)})"
    +  end
    +
    +  @spec assert_content_type_param!(any) :: :ok | no_return
    +  defp assert_content_type_param!(value) when is_binary(value) and byte_size(value) > 0 do
    +    do_assert_ctp!(value, value)
    +  end
    +
    +  defp assert_content_type_param!(value) do
    +    raise ArgumentError,
    +          "content-type param must be a non-empty string, got: #{inspect(value)}"
    +  end
    +
    +  defp do_assert_ctp!(_orig, <<>>), do: :ok
    +
    +  defp do_assert_ctp!(orig, <<c, rest::binary>>) when is_field_vchar(c) and c != ?; do
    +    do_assert_ctp!(orig, rest)
    +  end
    +
    +  defp do_assert_ctp!(orig, <<c, _::binary>>) do
    +    raise ArgumentError,
    +          "content-type param must not contain CTLs, DEL, or `;` per RFC 7231, " <>
    +            "got: #{inspect(orig)} (invalid character: #{inspect(<<c>>)})"
    +  end
    +
       if Version.compare(System.version(), "1.16.0") in [:gt, :eq] do
         defp stream_file!(path, bytes), do: File.stream!(path, bytes)
       else
    
  • test/tesla/multipart_test.exs+141 0 modified
    @@ -37,6 +37,147 @@ defmodule Tesla.MultipartTest do
                ]
       end
     
    +  describe "RFC 7230 / 7231 input validation" do
    +    test "add_content_type_param rejects CR" do
    +      assert_raise ArgumentError, ~r/content-type param/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_content_type_param("charset=utf-8\rX-Injected: pwned")
    +      end
    +    end
    +
    +    test "add_content_type_param rejects LF" do
    +      assert_raise ArgumentError, ~r/content-type param/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_content_type_param("charset=utf-8\nX-Injected: pwned")
    +      end
    +    end
    +
    +    test "add_content_type_param rejects CRLF" do
    +      assert_raise ArgumentError, ~r/content-type param/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_content_type_param(
    +          "charset=utf-8\r\nX-Injected: pwned\r\nX-Smuggled: yes"
    +        )
    +      end
    +    end
    +
    +    test "add_content_type_param rejects NUL" do
    +      assert_raise ArgumentError, ~r/content-type param/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_content_type_param("charset=utf-8\0")
    +      end
    +    end
    +
    +    test "add_content_type_param rejects DEL" do
    +      assert_raise ArgumentError, ~r/content-type param/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_content_type_param("charset=utf-8\x7f")
    +      end
    +    end
    +
    +    test "add_content_type_param rejects `;` (param smuggling)" do
    +      assert_raise ArgumentError, ~r/content-type param/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_content_type_param("charset=utf-8; boundary=evil")
    +      end
    +    end
    +
    +    test "add_content_type_param rejects empty string" do
    +      assert_raise ArgumentError, ~r/non-empty/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_content_type_param("")
    +      end
    +    end
    +
    +    test "add_field rejects CRLF in name" do
    +      assert_raise ArgumentError, ~r/field name/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_field("foo\r\nX-Injected: pwned", "value")
    +      end
    +    end
    +
    +    test "add_field rejects `\"` in name (quoted-string break)" do
    +      assert_raise ArgumentError, ~r/field name/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_field(~s(foo"; injected="bar), "value")
    +      end
    +    end
    +
    +    test "add_field rejects non-token header name" do
    +      assert_raise ArgumentError, ~r/header name/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_field("foo", "value", headers: [{"content id", "1"}])
    +      end
    +
    +      assert_raise ArgumentError, ~r/header name/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_field("foo", "value",
    +          headers: [{"content-id\r\nX-Injected", "1"}]
    +        )
    +      end
    +    end
    +
    +    test "add_field rejects CTL in header value" do
    +      assert_raise ArgumentError, ~r/header value/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_field("foo", "value",
    +          headers: [{"content-id", "1\r\nX-Injected: pwned"}]
    +        )
    +      end
    +
    +      assert_raise ArgumentError, ~r/header value/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_field("foo", "value", headers: [{"content-id", "1\0"}])
    +      end
    +    end
    +
    +    test "add_field allows HTAB and obs-text in header value" do
    +      mp =
    +        Multipart.new()
    +        |> Multipart.add_field("foo", "value",
    +          headers: [{"x-tab", "a\tb"}, {"x-obs", <<0xC3, 0xA9>>}]
    +        )
    +
    +      assert %Multipart{} = mp
    +    end
    +
    +    test "add_field rejects CRLF in disposition value" do
    +      assert_raise ArgumentError, ~r/disposition value/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_field("foo", "value", filename: "evil\r\nX-Injected: pwned")
    +      end
    +    end
    +
    +    test "add_field rejects `\"` in disposition value" do
    +      assert_raise ArgumentError, ~r/disposition value/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_field("foo", "value", filename: ~s(evil"; x="y))
    +      end
    +    end
    +
    +    test "add_field rejects `\\` in disposition value" do
    +      assert_raise ArgumentError, ~r/disposition value/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_field("foo", "value", filename: "evil\\path")
    +      end
    +    end
    +
    +    test "add_field allows filenames with spaces and unicode" do
    +      mp =
    +        Multipart.new()
    +        |> Multipart.add_field("foo", "value", filename: "My Photo é.png")
    +
    +      assert %Multipart{} = mp
    +    end
    +
    +    test "add_file_content rejects CRLF in filename" do
    +      assert_raise ArgumentError, ~r/disposition value/, fn ->
    +        Multipart.new()
    +        |> Multipart.add_file_content("data", "evil\r\nX-Injected: pwned")
    +      end
    +    end
    +  end
    +
       test "add_field" do
         mp =
           Multipart.new()
    

Vulnerability mechanics

Root cause

"The `Tesla.Multipart.add_content_type_param/2` function did not validate input for CRLF characters, allowing HTTP header injection."

Attack vector

An attacker can control a content-type parameter, such as a charset or boundary parameter, and inject CRLF sequences (`\r\n`) into it. When this parameter is later processed by `Tesla.Multipart.headers/1`, the CRLF sequence causes the HTTP header to split. This allows arbitrary headers to be injected into the outbound HTTP request, potentially leading to request smuggling or forging headers against an upstream server [ref_id=2].

Affected code

The vulnerability lies within the `lib/tesla/multipart.ex` file, specifically in the `add_content_type_param/2` function. This function appends caller-supplied strings to the `content_type_params` list without validation. The `headers/1` function then joins these parameters, which can lead to header injection if CRLF characters are present [ref_id=2].

What the fix does

The patch introduces input validation for content-type parameters within the `Tesla.Multipart.add_content_type_param/2` function [patch_id=4524233]. New private functions like `assert_content_type_param!/1` are added to enforce RFC 7231 §3.1.1.1, rejecting parameters containing control characters, DEL, or semicolons. This prevents CRLF sequences from being added to the `content_type_params` list, thus closing the vulnerability.

Preconditions

  • inputUntrusted input must reach the `Tesla.Multipart.add_content_type_param/2` function.

Generated on Jun 2, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.