From 0e474d7cc54a4274629a259d0a8ce5dbe6765ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 12 Apr 2026 08:24:51 -0700 Subject: [PATCH] Fix HTTP/1 handling of 1xx informational responses 1xx informational responses (100 Continue, 103 Early Hints, etc.) were treated the same as HEAD and 204/304 responses: Mint emitted `{:done, ref}` and popped the request from its queue. The real final response arriving afterwards had no active request and Mint returned `{:unexpected_data, _}`, closing the connection. This broke two common scenarios: * Requests sent with `Expect: 100-continue`, where the server sends 100 Continue before the final response. * Servers or intermediaries emitting unsolicited 1xx (Plug.Conn.inform/3, HAProxy 102 Processing, CDNs sending 103 Early Hints, etc.). Fix: split 1xx out of the `:none` body branch in `message_body/1` into a new `:informational` body kind. In `decode_body/5`, the `:informational` clause resets the request's response-side fields (`version`, `status`, `headers_buffer`, etc.) back to their initial state and continues parsing from `:status` without popping the request. The `{:status, ref, 1xx}` and `{:headers, ref, _}` responses are still emitted to the caller by the existing `:status`/`:headers` decode stages, so informational responses remain visible; only the premature `{:done, ref}` is suppressed. --- lib/mint/http1.ex | 26 +++++++++++- test/mint/http1/conn_test.exs | 78 +++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index be31db7c..c2154d53 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -742,6 +742,27 @@ defmodule Mint.HTTP1 do {:ok, conn, responses} end + # 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. + defp decode_body(:informational, conn, data, _request_ref, responses) do + request = %{ + conn.request + | state: :status, + version: nil, + status: nil, + headers_buffer: [], + data_buffer: [], + content_length: nil, + connection: [], + transfer_encoding: [], + body: nil + } + + conn = %{conn | request: request, buffer: ""} + decode(:status, conn, data, responses) + end + defp decode_body(:single, conn, data, request_ref, responses) do {conn, responses} = add_body(conn, data, responses) conn = request_done(conn) @@ -981,7 +1002,10 @@ defmodule Mint.HTTP1 do status == 101 -> {:ok, :single} - method == "HEAD" or status in 100..199 or status in [204, 304] -> + status in 100..199 -> + {:ok, :informational} + + method == "HEAD" or status in [204, 304] -> {:ok, :none} # method == "CONNECT" and status in 200..299 -> nil diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index 66f39876..e354be14 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -327,6 +327,84 @@ defmodule Mint.HTTP1Test do assert conn.buffer == "XXX" end + test "100 Continue informational response followed by final response", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "POST", "/", [{"expect", "100-continue"}], "hello") + + response = + "HTTP/1.1 100 Continue\r\n\r\n" <> + "HTTP/1.1 201 Created\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, conn, + [ + {:status, ^ref, 100}, + {:headers, ^ref, []}, + {:status, ^ref, 201}, + {:headers, ^ref, [{"content-length", "2"}]}, + {:data, ^ref, "ok"}, + {:done, ^ref} + ]} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert HTTP1.open?(conn) + end + + test "103 Early Hints informational response followed by final response", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 103 Early Hints\r\nlink: ; rel=preload\r\n\r\n" <> + "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, _conn, + [ + {:status, ^ref, 103}, + {:headers, ^ref, [{"link", "; rel=preload"}]}, + {:status, ^ref, 200}, + {:headers, ^ref, [{"content-length", "2"}]}, + {:data, ^ref, "ok"}, + {:done, ^ref} + ]} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + end + + test "multiple informational responses before final response", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "POST", "/", [{"expect", "100-continue"}], "x") + + response = + "HTTP/1.1 100 Continue\r\n\r\n" <> + "HTTP/1.1 103 Early Hints\r\nlink: ; rel=preload\r\n\r\n" <> + "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, _conn, + [ + {:status, ^ref, 100}, + {:headers, ^ref, []}, + {:status, ^ref, 103}, + {:headers, ^ref, [{"link", "; rel=preload"}]}, + {:status, ^ref, 200}, + {:headers, ^ref, [{"content-length", "2"}]}, + {:data, ^ref, "ok"}, + {:done, ^ref} + ]} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + end + + test "informational response split across multiple TCP messages", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "POST", "/", [{"expect", "100-continue"}], "x") + + assert {:ok, conn, [{:status, ^ref, 100}, {:headers, ^ref, []}]} = + HTTP1.stream(conn, {:tcp, conn.socket, "HTTP/1.1 100 Continue\r\n\r\n"}) + + assert {:ok, _conn, + [ + {:status, ^ref, 201}, + {:headers, ^ref, [{"content-length", "2"}]}, + {:data, ^ref, "ok"}, + {:done, ^ref} + ]} = + HTTP1.stream( + conn, + {:tcp, conn.socket, "HTTP/1.1 201 Created\r\ncontent-length: 2\r\n\r\nok"} + ) + end + test "body following a 101 switching-protocols", %{conn: conn} do {:ok, conn, ref} = HTTP1.request(conn, "GET", "/socket/websocket", [], nil)