From 96bc2b50a692679ca4ff8c14aa280254ad38d6c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 28 Sep 2026 14:11:05 +0200 Subject: [PATCH 1/5] Accept transfer codings with parameters in Transfer-Encoding A Transfer-Encoding value with a parameter, such as "custom; level=1, chunked", failed with {:invalid_token_list, value} because the value was parsed as a plain list of tokens. RFC 9110 10.1.4 allows parameters on a transfer coding, as a token or a quoted string. Transfer-Encoding values are now parsed with their parameters and only the coding names are kept, so the final coding of a response still decides between chunked framing and reading until close. The chunked coding defines no parameters and RFC 9112 7.1 says their presence should be treated as an error, so chunked with parameters is still rejected. The same parser checks the Transfer-Encoding headers passed to request/5 with a streamed body and no Content-Length, so a value such as "gzip;q=1" is accepted there. The fields are combined with ", " into one value before parsing (RFC 9110 5.3). If chunked or identity is among the codings the headers are sent unchanged, as before. Otherwise chunked is appended to the last field, so it ends up as the final coding once. --- lib/mint/http1.ex | 32 +++++++++--- lib/mint/http1/parse.ex | 76 ++++++++++++++++++++++++++- test/mint/http1/conn_test.exs | 96 ++++++++++++++++++++++++++++++++++ test/mint/http1/parse_test.exs | 23 ++++++++ 4 files changed, 218 insertions(+), 9 deletions(-) diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 2e5a2325..6fed3027 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -1358,17 +1358,14 @@ defmodule Mint.HTTP1 do Headers.has?(headers, "content-length") -> {:ok, headers, :identity} - found = Headers.find(headers, "transfer-encoding") -> - {raw_name, value} = found + Headers.has?(headers, "transfer-encoding") -> + values = for {_name, "transfer-encoding", value} <- headers, do: value - with {:ok, tokens} <- Parse.transfer_encoding_header(value) do - if "chunked" in tokens or "identity" in tokens do + with {:ok, codings} <- transfer_codings(values) do + if "chunked" in codings or "identity" in codings do {:ok, headers, :identity} else - headers = - Headers.replace(headers, raw_name, "transfer-encoding", value <> ",chunked") - - {:ok, headers, :chunked} + {:ok, append_chunked_coding(headers), :chunked} end end @@ -1393,6 +1390,25 @@ defmodule Mint.HTTP1 do :identity} end + # Adds chunked as the final transfer coding, after the codings of the last + # Transfer-Encoding field. + defp append_chunked_coding(headers) do + headers = Enum.reverse(headers) + {name, "transfer-encoding", value} = List.keyfind(headers, "transfer-encoding", 1) + + headers + |> Headers.replace(name, "transfer-encoding", value <> ",chunked") + |> Enum.reverse() + end + + # RFC 9110 5.3: the field lines are combined with ", " into one field value, + # so a quoted string can span two of them. + defp transfer_codings(values) do + values + |> Enum.join(", ") + |> Parse.transfer_encoding_header() + end + defp wrap_error(reason) do %HTTPError{reason: reason, module: __MODULE__} end diff --git a/lib/mint/http1/parse.ex b/lib/mint/http1/parse.ex index 09ec7b18..27fc590c 100644 --- a/lib/mint/http1/parse.ex +++ b/lib/mint/http1/parse.ex @@ -135,10 +135,84 @@ defmodule Mint.HTTP1.Parse do split_into_downcase_tokens(string) end + # RFC 9110 10.1.4: transfer-coding = token *( OWS ";" OWS transfer-parameter ) + # and transfer-parameter = token BWS "=" BWS ( token / quoted-string ). Only the + # coding names are returned. RFC 9112 7.1: chunked defines no parameters and + # their presence should be treated as an error. def transfer_encoding_header(string) do - split_into_downcase_tokens(string) + case transfer_coding_list(string, []) do + {:ok, []} -> {:error, :empty_token_list} + {:ok, codings} -> {:ok, codings} + :error -> {:error, {:invalid_token_list, string}} + end + end + + defp transfer_coding_list(<<>>, acc), do: {:ok, :lists.reverse(acc)} + + defp transfer_coding_list(<>, acc) + when is_whitespace(char) or is_comma(char), + do: transfer_coding_list(rest, acc) + + defp transfer_coding_list(<> = string, acc) when is_tchar(char) do + {coding, rest} = take_token_downcase(string, <<>>) + transfer_parameters(trim_leading_whitespace(rest), [coding | acc]) end + defp transfer_coding_list(_string, _acc), do: :error + + # Parses the parameters of the last coding in acc, then the list separator. + defp transfer_parameters(<<>>, acc), do: {:ok, :lists.reverse(acc)} + + defp transfer_parameters(<>, acc) when is_comma(char), + do: transfer_coding_list(rest, acc) + + defp transfer_parameters(<>, [coding | _] = acc) when coding != "chunked" do + with {:ok, rest} <- transfer_parameter(trim_leading_whitespace(rest)), + do: transfer_parameters(trim_leading_whitespace(rest), acc) + end + + defp transfer_parameters(_string, _acc), do: :error + + defp transfer_parameter(<> = string) when is_tchar(char) do + rest = skip_token(string) + + case trim_leading_whitespace(rest) do + <> -> transfer_parameter_value(trim_leading_whitespace(rest)) + _other -> :error + end + end + + defp transfer_parameter(_string), do: :error + + defp transfer_parameter_value(<>), do: quoted_string(rest) + + defp transfer_parameter_value(<> = string) when is_tchar(char) do + {:ok, skip_token(string)} + end + + defp transfer_parameter_value(_string), do: :error + + # RFC 9110 5.6.4: qdtext and quoted-pair, after the opening quote. + defp quoted_string(<>), do: {:ok, rest} + + defp quoted_string(<>) + when char == 9 or char in 32..126 or char in 128..255, + do: quoted_string(rest) + + defp quoted_string(<>) + when char in [9, 32, 33] or char in 35..91 or char in 93..126 or char in 128..255, + do: quoted_string(rest) + + defp quoted_string(_string), do: :error + + defp skip_token(<>) when is_tchar(char), do: skip_token(rest) + defp skip_token(rest), do: rest + + defp take_token_downcase(<>, acc) when is_tchar(char), + do: take_token_downcase(rest, <>) + + defp take_token_downcase(rest, acc), do: {acc, rest} + defp split_into_downcase_tokens(string) do case token_list_downcase(string) do {:ok, []} -> {:error, :empty_token_list} diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index a3620eb4..cb1fe45a 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -1207,6 +1207,43 @@ defmodule Mint.HTTP1Test do assert done == {:done, ref} end + test "chunked framing applies after a transfer coding with parameters", %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 200 OK\r\ntransfer-encoding: custom; level=1 ;name=\"a, b\", chunked\r\n\r\n" <> + "5\r\nhello\r\n0\r\n\r\n" <> + "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, _}, + {:data, ^ref1, "hello"}, + {:done, ^ref1}, + {:status, ^ref2, 200}, + {:headers, ^ref2, _}, + {:data, ^ref2, "ok"}, + {:done, ^ref2} + ] = responses + + assert HTTP1.open?(conn) + end + + test "the chunked transfer coding with parameters is an error", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked; level=1\r\n\r\n5\r\nhello\r\n0\r\n\r\n" + + assert {:error, conn, %HTTPError{reason: {:invalid_token_list, "chunked; level=1"}}, + [{:status, ^ref, 200}]} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + refute HTTP1.open?(conn) + end + test "close/1", %{conn: conn} do assert HTTP1.open?(conn) assert {:ok, conn} = HTTP1.close(conn) @@ -1863,6 +1900,65 @@ defmodule Mint.HTTP1Test do assert HTTP1.open?(conn) end + test "chunked is appended to a transfer coding with parameters", + %{conn: conn, server_socket: server_socket, port: port} do + {:ok, conn, ref} = + HTTP1.request(conn, "GET", "/", [{"transfer-encoding", "gzip;q=1"}], :stream) + + assert receive_request_string(server_socket) == + request_string(""" + GET / HTTP/1.1 + host: localhost:#{port} + user-agent: #{mint_user_agent()} + transfer-encoding: gzip;q=1,chunked + + \ + """) + + {:ok, conn} = HTTP1.stream_request_body(conn, ref, "hello") + assert receive_request_string(server_socket) == "5\r\nhello\r\n" + end + + test "chunked is appended to the last of several transfer-encoding headers", + %{conn: conn, server_socket: server_socket, port: port} do + headers = [{"transfer-encoding", "custom;p=1"}, {"transfer-encoding", "gzip"}] + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", headers, :stream) + + assert receive_request_string(server_socket) == + request_string(""" + GET / HTTP/1.1 + host: localhost:#{port} + user-agent: #{mint_user_agent()} + transfer-encoding: custom;p=1 + transfer-encoding: gzip,chunked + + \ + """) + + {:ok, conn} = HTTP1.stream_request_body(conn, ref, "hello") + assert receive_request_string(server_socket) == "5\r\nhello\r\n" + end + + test "transfer-encoding headers are combined before they're parsed", + %{conn: conn, server_socket: server_socket, port: port} do + headers = [{"transfer-encoding", ~s(custom;p="a)}, {"transfer-encoding", ~s(b", chunked)}] + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", headers, :stream) + + assert receive_request_string(server_socket) == + request_string(""" + GET / HTTP/1.1 + host: localhost:#{port} + user-agent: #{mint_user_agent()} + transfer-encoding: custom;p="a + transfer-encoding: b", chunked + + \ + """) + + {:ok, conn} = HTTP1.stream_request_body(conn, ref, "hello") + assert receive_request_string(server_socket) == "hello" + end + test "transfer-encoding is not set to chunked if already set to identity", %{conn: conn, server_socket: server_socket, port: port} do {:ok, conn, ref} = diff --git a/test/mint/http1/parse_test.exs b/test/mint/http1/parse_test.exs index 8cbe0dbd..6986e915 100644 --- a/test/mint/http1/parse_test.exs +++ b/test/mint/http1/parse_test.exs @@ -164,6 +164,29 @@ defmodule Mint.HTTP1.ParseTest do assert transfer_encoding_header("") == {:error, :empty_token_list} end + test "transfer_encoding_header/1 with transfer coding parameters" do + assert transfer_encoding_header("Custom;Level=1") == {:ok, ["custom"]} + + assert transfer_encoding_header("custom ; a=1 ;b = x, chunked") == + {:ok, ["custom", "chunked"]} + + assert transfer_encoding_header(~s(custom; a="x, \\"y\\"; z", gzip)) == + {:ok, ["custom", "gzip"]} + + for value <- [ + "custom;", + "custom; a", + "custom; a=", + "custom; =1", + "custom; a=1 b", + ~s(custom; a="x), + "chunked; a=1", + "Chunked;a=1, gzip" + ] do + assert transfer_encoding_header(value) == {:error, {:invalid_token_list, value}} + end + end + describe "token_list_downcase/1" do property "returns an empty list if there's no token" do no_tokens_generator = string([?\s, ?\t, ?,]) From 33d12943b099acaa8f3327a621a494510fc5b82b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 28 Sep 2026 15:24:19 +0200 Subject: [PATCH 2/5] Treat an empty Transfer-Encoding list as no transfer codings A Transfer-Encoding field with no list elements, such as an empty value or ",", failed the response with :empty_token_list and closed the connection. For a response to HEAD this happened before the rule that such a response has no body, and a comma-only field between two nonempty Transfer-Encoding fields failed the response instead of being ignored. RFC 9110 5.6.1 allows a #transfer-coding list with zero elements, so such a field now adds no transfer coding when the framing is chosen. A response with only an empty Transfer-Encoding and no Content-Length has no framing, so its body is read until the connection closes (RFC 9112 6.3). The field still counts as present: with Content-Length it's an error, and it makes an HTTP/1.0 response, including a 1xx one, close the connection (RFC 9112 6.1). Transfer-Encoding values are parsed only where they decide the framing, combined into one value as on the request side, so a malformed value no longer fails a response to HEAD, a 204 or 304 response, or a 2xx response to CONNECT, whose Transfer-Encoding RFC 9110 9.3.6 says to ignore. Where it does fail, the error comes after the :headers event (with :stream_headers the field itself can be emitted first) and not while the header section is incomplete, other header errors in the same section are reported instead of it, and with Content-Length the error is :transfer_encoding_and_content_length instead of {:invalid_token_list, value}. On the request side, a Transfer-Encoding field with no codings passed to request/5 with a streamed body counts as no codings: when chunked is appended to such a last field it replaces the empty value. --- lib/mint/http1.ex | 40 ++++++---- lib/mint/http1/parse.ex | 10 +-- test/mint/http1/conn_test.exs | 142 ++++++++++++++++++++++++++++++++- test/mint/http1/parse_test.exs | 3 +- 4 files changed, 173 insertions(+), 22 deletions(-) diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 6fed3027..8effc32d 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -865,7 +865,7 @@ defmodule Mint.HTTP1 do # treated as faulty, so the connection is closed after it without processing the # final response, and the current request fails along with the queued ones. defp decode_body(:informational, %{request: request} = conn, _data, _request_ref, responses) - when request.version < {1, 1} and request.transfer_encoding != [] do + when request.version < {1, 1} and request.transfer_encoding != nil do {conn, responses} = close_after_response(conn, responses, conn.transport.wrap_error(:closed)) {:ok, conn, responses} end @@ -884,7 +884,7 @@ defmodule Mint.HTTP1 do data_buffer: [], content_length: nil, connection: [], - transfer_encoding: [], + transfer_encoding: nil, body: nil } @@ -1143,9 +1143,11 @@ defmodule Mint.HTTP1 do do: {:ok, %{request | connection: connection ++ connection_header}} end + # Transfer-Encoding values are kept unparsed, nil meaning there's no such field. + # They're only parsed when they decide the framing of the body, so they don't + # fail responses that have no body. defp store_header(%{transfer_encoding: transfer_encoding} = request, "transfer-encoding", value) do - with {:ok, transfer_encoding_header} <- Parse.transfer_encoding_header(value), - do: {:ok, %{request | transfer_encoding: transfer_encoding ++ transfer_encoding_header}} + {:ok, %{request | transfer_encoding: List.wrap(transfer_encoding) ++ [value]}} end defp store_header(_request, "content-length", _value) do @@ -1179,7 +1181,7 @@ defmodule Mint.HTTP1 do # RFC 9112 6.1: the framing of an HTTP/1.0 message with Transfer-Encoding is # treated as faulty, so the connection is closed after it even if kept alive. - "keep-alive" in request.connection and request.transfer_encoding == [] -> + "keep-alive" in request.connection and request.transfer_encoding == nil -> {conn, responses} true -> @@ -1266,7 +1268,7 @@ defmodule Mint.HTTP1 do method == "CONNECT" and status in 200..299 -> {:ok, :none} - request.transfer_encoding != [] && request.content_length -> + request.transfer_encoding != nil && request.content_length -> {:error, :transfer_encoding_and_content_length} # RFC9112 6.3: @@ -1274,11 +1276,16 @@ defmodule Mint.HTTP1 do # > chunked transfer coding is not the final encoding, the message body # > length is determined by reading the connection until it is closed by # > the server. - "chunked" == List.last(request.transfer_encoding) -> - {:ok, {:chunked, nil}} - - request.transfer_encoding != [] -> - {:ok, :until_closed} + # A Transfer-Encoding field with no codings leaves the response without + # framing, so it's read until close as well. + request.transfer_encoding != nil -> + with {:ok, codings} <- transfer_codings(request.transfer_encoding) do + if List.last(codings) == "chunked" do + {:ok, {:chunked, nil}} + else + {:ok, :until_closed} + end + end request.content_length -> {:ok, {:content_length, request.content_length}} @@ -1331,7 +1338,7 @@ defmodule Mint.HTTP1 do data_buffer: [], content_length: nil, connection: [], - transfer_encoding: [], + transfer_encoding: nil, body: nil } end @@ -1391,13 +1398,18 @@ defmodule Mint.HTTP1 do end # Adds chunked as the final transfer coding, after the codings of the last - # Transfer-Encoding field. + # Transfer-Encoding field. A last field with no codings is replaced. defp append_chunked_coding(headers) do headers = Enum.reverse(headers) {name, "transfer-encoding", value} = List.keyfind(headers, "transfer-encoding", 1) + value = + if Parse.transfer_encoding_header(value) == {:ok, []}, + do: "chunked", + else: value <> ",chunked" + headers - |> Headers.replace(name, "transfer-encoding", value <> ",chunked") + |> Headers.replace(name, "transfer-encoding", value) |> Enum.reverse() end diff --git a/lib/mint/http1/parse.ex b/lib/mint/http1/parse.ex index 27fc590c..f08ca237 100644 --- a/lib/mint/http1/parse.ex +++ b/lib/mint/http1/parse.ex @@ -138,13 +138,11 @@ defmodule Mint.HTTP1.Parse do # RFC 9110 10.1.4: transfer-coding = token *( OWS ";" OWS transfer-parameter ) # and transfer-parameter = token BWS "=" BWS ( token / quoted-string ). Only the # coding names are returned. RFC 9112 7.1: chunked defines no parameters and - # their presence should be treated as an error. + # their presence should be treated as an error. The list may have no elements + # (RFC 9110 5.6.1), which adds no transfer coding. def transfer_encoding_header(string) do - case transfer_coding_list(string, []) do - {:ok, []} -> {:error, :empty_token_list} - {:ok, codings} -> {:ok, codings} - :error -> {:error, {:invalid_token_list, string}} - end + with :error <- transfer_coding_list(string, []), + do: {:error, {:invalid_token_list, string}} end defp transfer_coding_list(<<>>, acc), do: {:ok, :lists.reverse(acc)} diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index cb1fe45a..3d6837d4 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -1239,11 +1239,133 @@ defmodule Mint.HTTP1Test do "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked; level=1\r\n\r\n5\r\nhello\r\n0\r\n\r\n" assert {:error, conn, %HTTPError{reason: {:invalid_token_list, "chunked; level=1"}}, - [{:status, ^ref, 200}]} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + [{:status, ^ref, 200}, {:headers, ^ref, _}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + refute HTTP1.open?(conn) + end + + test "a response with only an empty transfer-encoding is read until close", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + response = "HTTP/1.1 200 OK\r\ntransfer-encoding: , \r\n\r\nhello" + + assert {:ok, conn, [{:status, ^ref, 200}, {:headers, ^ref, _}, {:data, ^ref, "hello"}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert {:ok, conn, [{:data, ^ref, "more"}]} = HTTP1.stream(conn, {:tcp, conn.socket, "more"}) + assert {:ok, conn, [{:done, ^ref}]} = HTTP1.stream(conn, {:tcp_closed, conn.socket}) + refute HTTP1.open?(conn) + end + + test "an empty transfer-encoding field adds no transfer coding", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 200 OK\r\ntransfer-encoding: gzip\r\ntransfer-encoding: ,\r\n" <> + "transfer-encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [{:status, ^ref, 200}, {:headers, ^ref, _}, {:data, ^ref, "hello"}, {:done, ^ref}] = + responses + + assert HTTP1.open?(conn) + end + + test "an HTTP/1.0 response with an empty transfer-encoding closes the connection", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "HEAD", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.0 200 OK\r\nconnection: keep-alive\r\ntransfer-encoding:\r\n\r\n" <> + "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, _}, + {:done, ^ref1}, + {:error, ^ref2, %TransportError{reason: :closed}} + ] = responses + + refute HTTP1.open?(conn) + end + + test "an HTTP/1.0 1xx response with an empty transfer-encoding closes the connection", + %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.0 103 Early Hints\r\ntransfer-encoding:\r\n\r\n" <> + "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref, 103}, + {:headers, ^ref, _}, + {:error, ^ref, %TransportError{reason: :closed}} + ] = responses refute HTTP1.open?(conn) end + for {method, status} <- [{"CONNECT", 200}, {"HEAD", 200}, {"GET", 204}, {"GET", 304}] do + test "an invalid transfer-encoding is ignored in a #{status} response to #{method}", + %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, unquote(method), "example.com:443", [], nil) + response = "HTTP/1.1 #{unquote(status)} Status\r\ntransfer-encoding: chunked;a=1\r\n\r\n" + + assert {:ok, conn, [{:status, ^ref, unquote(status)}, {:headers, ^ref, _}, {:done, ^ref}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert HTTP1.open?(conn) + end + end + + for value <- ["", "chunked;a=1"] do + test "transfer-encoding #{inspect(value)} with content-length is an error", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 200 OK\r\ntransfer-encoding: #{unquote(value)}\r\ncontent-length: 2\r\n\r\nok" + + assert {:error, conn, %HTTPError{reason: :transfer_encoding_and_content_length}, + [{:status, ^ref, 200}, {:headers, ^ref, _}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + refute HTTP1.open?(conn) + end + end + + test "transfer-encoding fields are combined before they're parsed", %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 200 OK\r\ntransfer-encoding: custom;p=\"a\r\n" <> + "transfer-encoding: b\", chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n" <> + "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, _}, + {:data, ^ref1, "hello"}, + {:done, ^ref1}, + {:status, ^ref2, 200}, + {:headers, ^ref2, _}, + {:data, ^ref2, "ok"}, + {:done, ^ref2} + ] = responses + + assert HTTP1.open?(conn) + end + test "close/1", %{conn: conn} do assert HTTP1.open?(conn) assert {:ok, conn} = HTTP1.close(conn) @@ -1900,6 +2022,24 @@ defmodule Mint.HTTP1Test do assert HTTP1.open?(conn) end + test "transfer-encoding is set to chunked if present but empty", + %{conn: conn, server_socket: server_socket, port: port} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [{"transfer-encoding", ""}], :stream) + + assert receive_request_string(server_socket) == + request_string(""" + GET / HTTP/1.1 + host: localhost:#{port} + user-agent: #{mint_user_agent()} + transfer-encoding: chunked + + \ + """) + + {:ok, conn} = HTTP1.stream_request_body(conn, ref, "hello") + assert receive_request_string(server_socket) == "5\r\nhello\r\n" + end + test "chunked is appended to a transfer coding with parameters", %{conn: conn, server_socket: server_socket, port: port} do {:ok, conn, ref} = diff --git a/test/mint/http1/parse_test.exs b/test/mint/http1/parse_test.exs index 6986e915..50a6541a 100644 --- a/test/mint/http1/parse_test.exs +++ b/test/mint/http1/parse_test.exs @@ -161,7 +161,8 @@ defmodule Mint.HTTP1.ParseTest do assert transfer_encoding_header("gzip, Chunked ") == {:ok, ["gzip", "chunked"]} assert transfer_encoding_header("\n") == {:error, {:invalid_token_list, "\n"}} - assert transfer_encoding_header("") == {:error, :empty_token_list} + assert transfer_encoding_header("") == {:ok, []} + assert transfer_encoding_header(" , ,") == {:ok, []} end test "transfer_encoding_header/1 with transfer coding parameters" do From 205e96bfb0ca6b5231d7c68859a970a98b6d2d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 28 Sep 2026 15:08:26 +0200 Subject: [PATCH 3/5] Close the connection after a 1xx response with Connection: close The response fields of a request were reset after an informational response so the final response could be parsed, and that dropped the Connection options of the 1xx response. A "close" option in a 1xx response was ignored, and the connection stayed open for the requests pipelined behind it. RFC 9112 9.6 has a client that receives "close" close the connection after reading the response containing it. A 1xx isn't the complete response, so Mint now keeps the option and closes the connection after the final response, handling it as if the final response carried it: the connection is closed after the final response, including a 101 or an HTTP/1.0 keep-alive one, and queued requests fail with :unprocessed. A 2xx response to CONNECT still keeps the connection open for the tunnel. Other Connection options of a 1xx response, such as keep-alive, don't apply to the final response. --- lib/mint/http1.ex | 4 +- test/mint/http1/conn_test.exs | 72 +++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 8effc32d..81a9baa9 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -873,6 +873,8 @@ defmodule Mint.HTTP1 do # Informational (1xx) responses have no body and must not finalize the # request; the final response follows on the same request ref. Reset the # request's response-side fields and continue parsing without popping it. + # A "close" option is kept, so the connection is closed after the final + # response (RFC 9112 9.6). defp decode_body(:informational, conn, data, _request_ref, responses) do request = %{ conn.request @@ -883,7 +885,7 @@ defmodule Mint.HTTP1 do headers_size: 0, data_buffer: [], content_length: nil, - connection: [], + connection: if("close" in conn.request.connection, do: ["close"], else: []), transfer_encoding: nil, body: nil } diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index 3d6837d4..3b3414dd 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -1148,6 +1148,78 @@ defmodule Mint.HTTP1Test do ) end + for {name, final_response} <- [ + {"a final", "HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n"}, + {"a 101", "HTTP/1.1 101 Switching Protocols\r\nupgrade: foo\r\n\r\n"}, + {"an HTTP/1.0 keep-alive", "HTTP/1.0 200 OK\r\nconnection: keep-alive\r\n\r\n"} + ] do + test "connection: close in an informational response closes after #{name} response", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "HEAD", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 103 Early Hints\r\nconnection: close\r\n\r\n" <> unquote(final_response) + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 103}, + {:headers, ^ref1, [{"connection", "close"}]}, + {:status, ^ref1, _}, + {:headers, ^ref1, _}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :unprocessed}} + ] = responses + + refute HTTP1.open?(conn) + end + end + + test "connection: close in an informational response is ignored after a 2xx response to CONNECT", + %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "CONNECT", "example.com:443", [], nil) + + response = + "HTTP/1.1 103 Early Hints\r\nconnection: close\r\n\r\n" <> + "HTTP/1.1 200 Connection established\r\n\r\n" + + assert {:ok, conn, + [ + {:status, ^ref, 103}, + {:headers, ^ref, _}, + {:status, ^ref, 200}, + {:headers, ^ref, []}, + {:done, ^ref} + ]} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert HTTP1.open?(conn) + end + + test "connection: keep-alive in an informational response doesn't apply to the final response", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 103 Early Hints\r\nconnection: keep-alive\r\n\r\n" <> + "HTTP/1.0 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 103}, + {:headers, ^ref1, _}, + {:status, ^ref1, 200}, + {:headers, ^ref1, _}, + {:data, ^ref1, "ok"}, + {:done, ^ref1}, + {:error, ^ref2, %TransportError{reason: :closed}} + ] = responses + + refute HTTP1.open?(conn) + end + test "body following a 101 switching-protocols", %{conn: conn} do {:ok, conn, ref} = HTTP1.request(conn, "GET", "/socket/websocket", [], nil) From 433eb8671e4cf87ad31b3070e1fe946da65bd20d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 28 Sep 2026 15:09:02 +0200 Subject: [PATCH 4/5] Cite RFC 9112 for the response body length rules The comment on message_body/1 quoted RFC 7230 3.3.3, which RFC 9112 6.3 replaces. It now summarises the RFC 9112 6.3 rules the function applies, starting with the responses that have no body, and the comment on the Transfer-Encoding clause uses the same section's wording. --- lib/mint/http1.ex | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 81a9baa9..f83727ee 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -1243,14 +1243,12 @@ defmodule Mint.HTTP1 do %{conn | state: :closed} end - # RFC7230 3.3.3: - # > If a message is received with both a Transfer-Encoding and a - # > Content-Length header field, the Transfer-Encoding overrides the - # > Content-Length. Such a message might indicate an attempt to - # > perform request smuggling (Section 9.5) or response splitting - # > (Section 9.4) and ought to be handled as an error. A sender MUST - # > remove the received Content-Length field prior to forwarding such - # > a message downstream. + # Determines the length of a response body following RFC 9112 6.3. A response + # to HEAD, a 1xx, 204 or 304 response and a 2xx response to CONNECT have no + # body, and the bytes after a 101 response belong to the new protocol. For + # other responses, Transfer-Encoding together with Content-Length ought to be + # handled as an error, and a response with neither is read until the + # connection closes. defp message_body(%{body: nil, method: method, status: status} = request) do cond do status == 101 -> @@ -1273,13 +1271,9 @@ defmodule Mint.HTTP1 do request.transfer_encoding != nil && request.content_length -> {:error, :transfer_encoding_and_content_length} - # RFC9112 6.3: - # > If a Transfer-Encoding header field is present in a response and the - # > chunked transfer coding is not the final encoding, the message body - # > length is determined by reading the connection until it is closed by - # > the server. - # A Transfer-Encoding field with no codings leaves the response without - # framing, so it's read until close as well. + # RFC 9112 6.3: with chunked as the final transfer coding the body is + # chunked. With another final coding, or a Transfer-Encoding field with no + # codings, it's read until the server closes the connection. request.transfer_encoding != nil -> with {:ok, codings} <- transfer_codings(request.transfer_encoding) do if List.last(codings) == "chunked" do From defd0a8c5ab9efdecd637dfafca94338a135d505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 28 Sep 2026 14:19:06 +0200 Subject: [PATCH 5/5] Fail requests pipelined behind a 101 response After a 101 response the connection stayed open with the queued requests in place, so bytes arriving later, which belong to the protocol the server switched to (RFC 9110 15.2.2), were parsed as HTTP/1 responses to those requests. Queued requests now fail with the new :connection_upgraded error when the 101 response completes. Bytes arriving later are unexpected data as long as no new request is made on the connection. A failed request that was streaming its body can't stream the rest of it. :unprocessed isn't used because the pipelined request bytes reached the server as part of the new protocol, so the request might have been processed. The bytes after the 101 response in the same packet are still delivered as data for the upgrade request. --- lib/mint/http1.ex | 29 +++++++++++++++- test/mint/http1/conn_test.exs | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index f83727ee..2fa3b28f 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -56,6 +56,11 @@ defmodule Mint.HTTP1 do `Mint.TransportError` with reason `:closed` instead, since the server might have processed them. + * `:connection_upgraded` - when a pipelined request gets no response because the + server answered a previous request with a `101` response. The connection then + uses another protocol, and the server might have received the request as part + of it. + * `{:unexpected_data, data}` - when unexpected data is received from the server. * `:invalid_status_line` - when the HTTP/1 status line is invalid. @@ -894,9 +899,12 @@ defmodule Mint.HTTP1 do decode(:status, conn, data, responses) end + # A 101 response switches the connection to another protocol (RFC 9110 + # 15.2.2), so the bytes after it belong to that protocol. defp decode_body(:single, conn, data, _request_ref, responses) do {conn, responses} = add_body(conn, data, responses) {conn, responses} = request_done(conn, responses) + {conn, responses} = fail_requests(conn, responses, wrap_error(:connection_upgraded)) {:ok, conn, responses} end @@ -1205,6 +1213,15 @@ defmodule Mint.HTTP1 do # Requests pipelined behind a response that closes the connection never get a # response of their own. defp close_after_response(conn, responses, error) do + {conn, responses} = fail_requests(conn, responses, error) + {internal_close(conn), responses} + end + + # Returns an error response with the given error for the current request and + # every queued one, and removes them from the connection. If one of them is + # streaming its body, the streaming state is cleared so the rest of the body + # can't be sent. + defp fail_requests(conn, responses, error) do requests = if conn.request, do: [conn.request | :queue.to_list(conn.requests)], else: [] responses = @@ -1212,7 +1229,12 @@ defmodule Mint.HTTP1 do [{:error, request.ref, error} | responses] end) - {internal_close(%{conn | request: nil, requests: :queue.new()}), responses} + # No request can be made while one is streaming its body, so a streaming + # request is the last one and it's among the failed ones unless it's done. + streaming_request = if requests == [], do: conn.streaming_request + + conn = %{conn | request: nil, requests: :queue.new(), streaming_request: streaming_request} + {conn, responses} end defp pop_request(conn) do @@ -1433,6 +1455,11 @@ defmodule Mint.HTTP1 do "\"connection: close\", so it's safe to retry on a new connection" end + def format_error(:connection_upgraded) do + "request got no response because the server switched the connection to another " <> + "protocol in response to a previous request" + end + def format_error(:request_body_is_streaming) do "a request body is currently streaming, so no new requests can be issued" end diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index 3b3414dd..d3d66d6f 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -1240,6 +1240,71 @@ defmodule Mint.HTTP1Test do assert conn.buffer == <<>> end + test "a request pipelined behind a successful CONNECT gets its response from the tunnel", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "CONNECT", "example.com:80", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + assert {:ok, conn, [{:status, ^ref1, 200}, {:headers, ^ref1, []}, {:done, ^ref1}]} = + HTTP1.stream( + conn, + {:tcp, conn.socket, "HTTP/1.1 200 Connection Established\r\n\r\n"} + ) + + response = "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, conn, + [{:status, ^ref2, 200}, {:headers, ^ref2, _}, {:data, ^ref2, "ok"}, {:done, ^ref2}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert HTTP1.open?(conn) + end + + test "requests pipelined behind a 101 response get no response", %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = "HTTP/1.1 101 Switching Protocols\r\nupgrade: foo\r\nconnection: upgrade\r\n\r\n" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 101}, + {:headers, ^ref1, _}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :connection_upgraded}} + ] = responses + + assert HTTP1.open?(conn) + assert HTTP1.open_request_count(conn) == 0 + + data = "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:error, _conn, %HTTPError{reason: {:unexpected_data, ^data}}, []} = + HTTP1.stream(conn, {:tcp, conn.socket, data}) + end + + test "a streaming request pipelined behind a 101 response can't stream its body", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "POST", "/", [], :stream) + + response = "HTTP/1.1 101 Switching Protocols\r\nupgrade: foo\r\nconnection: upgrade\r\n\r\n" + + assert {:ok, conn, + [ + {:status, ^ref1, 101}, + {:headers, ^ref1, _}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :connection_upgraded}} + ]} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert {:error, _conn, %HTTPError{reason: :unknown_request_to_stream}} = + HTTP1.stream_request_body(conn, ref2, "hello") + + assert_raise ArgumentError, fn -> HTTP1.request_body_window(conn, ref2) end + end + test "unallowed trailer headers are removed from the trailer headers", %{conn: conn} do {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil)