You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by va...@apache.org on 2026/10/05 23:22:05 UTC

(couchdb-gun) 15/16: Reject responses with conflicting content-length

This is an automated email from the ASF dual-hosted git repository.

nickva pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/couchdb-gun.git

commit a3ae11a08cbe0ee44be98acb3146b5fc35a612fa
Author: Loïc Hoguin <es...@ninenines.eu>
AuthorDate: Fri Sep 25 18:06:12 2026 +0200

    Reject responses with conflicting content-length
---
 src/gun_http.erl       | 52 +++++++++++++++++++++++----------
 test/rfc7230_SUITE.erl | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 115 insertions(+), 16 deletions(-)

diff --git a/src/gun_http.erl b/src/gun_http.erl
index fb1cec3..5e0fecd 100644
--- a/src/gun_http.erl
+++ b/src/gun_http.erl
@@ -354,7 +354,8 @@ match_header_section_end(Data, _) ->
 	end.
 
 handle_head(Data, State=#http_state{opts=Opts,
-		streams=[#stream{ref=StreamRef, reply_to=ReplyTo, authority=Authority, path=Path}|_]},
+		streams=[#stream{ref=StreamRef, reply_to=ReplyTo, method=Method,
+			authority=Authority, path=Path}|_]},
 		CookieStore0, EvHandler, EvHandlerState) ->
 	try cow_http:parse_status_line(Data) of
 		{Version, Status, _, Rest0} ->
@@ -368,15 +369,27 @@ handle_head(Data, State=#http_state{opts=Opts,
 							gun:reply(ReplyTo, {gun_error, self(), Reason}),
 							{{error, Reason}, CookieStore0, EvHandlerState};
 						false ->
-							CookieStore = gun_cookies:set_cookie_header(scheme(State),
-								Authority, Path, Status, Headers, CookieStore0, Opts),
 							case StreamRef of
 								{connect, _, _} when Status >= 200, Status < 300 ->
+									CookieStore = gun_cookies:set_cookie_header(scheme(State),
+										Authority, Path, Status, Headers, CookieStore0, Opts),
 									handle_connect(Rest, State, CookieStore, EvHandler, EvHandlerState, Status, Headers);
 								_ when Status >= 100, Status =< 199 ->
+									CookieStore = gun_cookies:set_cookie_header(scheme(State),
+										Authority, Path, Status, Headers, CookieStore0, Opts),
 									handle_inform(Rest, State, CookieStore, EvHandler, EvHandlerState, Version, Status, Headers);
 								_ ->
-									handle_response(Rest, State, CookieStore, EvHandler, EvHandlerState, Version, Status, Headers)
+									case response_io_from_headers(Method, Version, Status, Headers) of
+										{error, Human} ->
+											Reason = {connection_error, protocol_error, Human},
+											gun:reply(ReplyTo, {gun_error, self(), Reason}),
+											{{error, Reason}, CookieStore0, EvHandlerState};
+										In ->
+											CookieStore = gun_cookies:set_cookie_header(scheme(State),
+												Authority, Path, Status, Headers, CookieStore0, Opts),
+											handle_response1(Rest, State, CookieStore, EvHandler,
+												EvHandlerState, Version, Status, Headers, In)
+									end
 							end
 					end
 			catch _:_ ->
@@ -494,10 +507,9 @@ is_expected_upgrade_response(Headers, #stream{upgrade=Requested}) ->
 		false
 	end.
 
-handle_response(Rest, State=#http_state{version=ClientVersion, opts=Opts, connection=Conn,
-		streams=[Stream=#stream{ref=StreamRef, reply_to=ReplyTo, method=Method, is_alive=IsAlive}|Tail]},
-		CookieStore, EvHandler, EvHandlerState0, Version, Status, Headers) ->
-	In = response_io_from_headers(Method, Version, Status, Headers),
+handle_response1(Rest, State=#http_state{version=ClientVersion, opts=Opts, connection=Conn,
+		streams=[Stream=#stream{ref=StreamRef, reply_to=ReplyTo, is_alive=IsAlive}|Tail]},
+		CookieStore, EvHandler, EvHandlerState0, Version, Status, Headers, In) ->
 	IsFin = case In of head -> fin; _ -> nofin end,
 	RealStreamRef = stream_ref(State, StreamRef),
 	%% @todo Figure out whether the event should trigger if the stream was cancelled.
@@ -1039,14 +1051,22 @@ response_io_from_headers(_, Version, _Status, Headers) ->
 				[<<"identity">>] -> body_close
 			end;
 		_ ->
-			case lists:keyfind(<<"content-length">>, 1, Headers) of
-				{_, <<"0">>} ->
-					head;
-				{_, Length} ->
-					{body, cow_http_hd:parse_content_length(Length)};
-				_ ->
-					body_close
-			end
+			response_io_from_content_length(Headers)
+	end.
+
+response_io_from_content_length(Headers) ->
+	case lists:usort([Value || {<<"content-length">>, Value} <- Headers]) of
+		[] ->
+			body_close;
+		[Value] ->
+			try cow_http_hd:parse_content_length(Value) of
+				0 -> head;
+				Len -> {body, Len}
+			catch _:_ ->
+				{error, "The content-length header is invalid."}
+			end;
+		_ ->
+			{error, "The content-length header is invalid."}
 	end.
 
 %% Streams.
diff --git a/test/rfc7230_SUITE.erl b/test/rfc7230_SUITE.erl
index 6679c96..4c36c3c 100644
--- a/test/rfc7230_SUITE.erl
+++ b/test/rfc7230_SUITE.erl
@@ -26,6 +26,85 @@ all() ->
 
 %% Tests.
 
+content_length_duplicate_error(_) ->
+	doc("Multiple differing content-length values must be rejected "
+		"with a protocol error connection error. (RFC9112 6.3)"),
+	{ok, _, OriginPort} = init_origin(tcp, http,
+		fun(_, _, ClientSocket, ClientTransport) ->
+			{ok, _} = ClientTransport:recv(ClientSocket, 0, 1000),
+			ClientTransport:send(ClientSocket,
+				"HTTP/1.1 200 OK\r\n"
+				"content-length: 4\r\n"
+				"content-length: 40\r\n"
+				"\r\n"
+			)
+		end),
+	{ok, ConnPid} = gun:open("localhost", OriginPort),
+	{ok, http} = gun:await_up(ConnPid),
+	StreamRef = gun:get(ConnPid, "/"),
+	{error, {connection_error, {connection_error, protocol_error, _}}} =
+		gun:await(ConnPid, StreamRef),
+	gun:close(ConnPid).
+
+content_length_equivalent_duplicate_error(_) ->
+	doc("Multiple differing content-length values must be rejected "
+		"with a protocol error connection error. (RFC9112 6.3)"),
+	{ok, _, OriginPort} = init_origin(tcp, http,
+		fun(_, _, ClientSocket, ClientTransport) ->
+			{ok, _} = ClientTransport:recv(ClientSocket, 0, 1000),
+			ClientTransport:send(ClientSocket,
+				"HTTP/1.1 200 OK\r\n"
+				"content-length: 0\r\n"
+				"content-length: 00\r\n"
+				"\r\n"
+			)
+		end),
+	{ok, ConnPid} = gun:open("localhost", OriginPort),
+	{ok, http} = gun:await_up(ConnPid),
+	StreamRef = gun:get(ConnPid, "/"),
+	{error, {connection_error, {connection_error, protocol_error, _}}} =
+		gun:await(ConnPid, StreamRef),
+	gun:close(ConnPid).
+
+content_length_duplicate_same_value(_) ->
+	doc("Identical repeated content-length values must be tolerated. (RFC9112 6.3)"),
+	{ok, _, OriginPort} = init_origin(tcp, http,
+		fun(_, _, ClientSocket, ClientTransport) ->
+			{ok, _} = ClientTransport:recv(ClientSocket, 0, 1000),
+			ClientTransport:send(ClientSocket,
+				"HTTP/1.1 200 OK\r\n"
+				"content-length: 4\r\n"
+				"content-length: 4\r\n"
+				"\r\n"
+				"data"
+			)
+		end),
+	{ok, ConnPid} = gun:open("localhost", OriginPort),
+	{ok, http} = gun:await_up(ConnPid),
+	StreamRef = gun:get(ConnPid, "/"),
+	{response, nofin, 200, _} = gun:await(ConnPid, StreamRef),
+	{ok, <<"data">>} = gun:await_body(ConnPid, StreamRef),
+	gun:close(ConnPid).
+
+content_length_unparseable_error(_) ->
+	doc("An unparseable content-length value must be rejected "
+		"with a protocol error connection error. (RFC9112 6.3)"),
+	{ok, _, OriginPort} = init_origin(tcp, http,
+		fun(_, _, ClientSocket, ClientTransport) ->
+			{ok, _} = ClientTransport:recv(ClientSocket, 0, 1000),
+			ClientTransport:send(ClientSocket,
+				"HTTP/1.1 200 OK\r\n"
+				"content-length: abc\r\n"
+				"\r\n"
+			)
+		end),
+	{ok, ConnPid} = gun:open("localhost", OriginPort),
+	{ok, http} = gun:await_up(ConnPid),
+	StreamRef = gun:get(ConnPid, "/"),
+	{error, {connection_error, {connection_error, protocol_error, _}}} =
+		gun:await(ConnPid, StreamRef),
+	gun:close(ConnPid).
+
 host_default_port_http(_) ->
 	doc("The default port for http should not be sent in the host header. (RFC7230 2.7.1)"),
 	do_host_port(tcp, 80, <<>>).