From f707415a1199ec76812b001925841af938bb7478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 28 Sep 2026 15:11:40 +0200 Subject: [PATCH] Treat a 204 response to CONNECT as an established tunnel The check for 204 and 304 responses, which must not have content, came before the check for 2xx responses to CONNECT. A CONNECT request answered with 204 was treated as a response without content, so the first DATA frame on the tunnel failed the request with a protocol error. RFC 9110 9.3.6 and RFC 9113 8.5 make any 2xx response to CONNECT establish a tunnel, so the CONNECT case is checked first and DATA frames after a 204 are delivered as tunnel data. --- lib/mint/http2.ex | 5 +++-- test/mint/http2/conn_test.exs | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index b3aaea74..670a16a1 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -2340,12 +2340,13 @@ defmodule Mint.HTTP2 do # RFC 9113 8.1.1: a response with content is malformed if the sum of the DATA # frame payload lengths doesn't equal the content-length header value. Responses # to HEAD and 204 and 304 responses must not have content, whatever their - # content-length header says, and 2xx responses to CONNECT carry tunnel data. + # content-length header says. Any 2xx response to CONNECT, including 204, + # establishes a tunnel (RFC 9110 9.3.6, RFC 9113 8.5) and carries tunnel data. defp response_content_length(%{method: method}, status, headers) do cond do method == "HEAD" -> {:ok, 0} - status in [204, 304] -> {:ok, 0} method == "CONNECT" and status in 200..299 -> {:ok, nil} + status in [204, 304] -> {:ok, 0} true -> content_length(headers) end end diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 60ae7542..60495c24 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -4306,6 +4306,33 @@ defmodule Mint.HTTP2Test do assert HTTP2.open?(conn) end + test "a 204 response establishes the tunnel", %{conn: conn} do + assert {:ok, conn, ref} = HTTP2.request(conn, "CONNECT", "example.com:443", [], :stream) + + assert_recv_frames [headers(stream_id: stream_id)] + + hbf = server_encode_headers([{":status", "204"}]) + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + headers( + stream_id: stream_id, + hbf: hbf, + flags: set_flags(:headers, [:end_headers]) + ), + data(stream_id: stream_id, data: "hello", flags: set_flags(:data, [:end_stream])) + ]) + + assert responses == [ + {:status, ref, 204}, + {:headers, ref, []}, + {:data, ref, "hello"}, + {:done, ref} + ] + + assert HTTP2.open?(conn) + end + test "an END_STREAM from the server closes the whole tunnel", %{conn: conn} do assert {:ok, conn, ref} = HTTP2.request(conn, "CONNECT", "example.com:443", [], :stream)