CVE-2026-43966
Description
Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Request/Response Splitting') vulnerability in ninenines cowlib allows HTTP response splitting via non-VCHAR bytes in structured-fields string values.
cow_http_struct_hd:escape_string/2 in cowlib only escapes \ and ", passing all other bytes through verbatim. This creates an encoder/decoder asymmetry: the matching parser accepts only printable ASCII (0x20–0x7E, excluding " and \), but the encoder emits any byte including CR and LF. An application that builds a structured HTTP header via cow_http_struct_hd:item/1 (or a higher-level wrapper such as cow_http_hd:wt_protocol/1) from attacker-controlled input can have \r\n injected into the serialized header value. Once on the wire, the injected CRLF terminates the current header and any following bytes are interpreted as a new header, enabling HTTP response splitting.
This issue affects cowlib from 2.9.0.
Affected products
3Patches
24f35609eb371Add invalid_request_headers request option
3 files changed · +129 −11
doc/src/manual/gun.asciidoc+20 −0 modified@@ -450,6 +450,7 @@ Request headers. ---- req_opts() :: #{ flow => pos_integer(), + invalid_request_headers => raise | ignore, reply_to => pid() | {module(), atom(), list()} | fun((_) -> _) | {fun(), list()}, tunnel => gun:stream_ref() @@ -465,6 +466,14 @@ flow - see below:: The initial flow control value for the stream. By default flow control is disabled. +invalid_request_headers (raise):: + +Prevent sending HTTP requests with invalid request headers. +The default `raise` will result in an exception being raised +when invalid request headers are detected. When set to +`ignore` the check is not performed at all: use at your +own risk. + reply_to (`self()`):: The pid of the process that will receive the response messages, @@ -549,6 +558,7 @@ ws_opts() :: #{ compress => boolean(), default_protocol => module(), flow => pos_integer(), + invalid_request_headers => raise | ignore, keepalive => timeout(), protocols => [{binary(), module()}], silence_pings => boolean(), @@ -582,6 +592,14 @@ flow - see below:: The initial flow control value for the Websocket connection. By default flow control is disabled. +invalid_request_headers (raise):: + +Prevent sending HTTP requests with invalid request headers. +The default `raise` will result in an exception being raised +when invalid request headers are detected. When set to +`ignore` the check is not performed at all: use at your +own risk. + keepalive (infinity):: Time between pings in milliseconds. @@ -609,6 +627,8 @@ By default no user option is defined. == Changelog +* *2.4*: The `invalid_request_headers` request option was added + (for both normal requests and Websocket upgrades). * *2.2*: The `notify_settings_changed` option for HTTP/2 was documented. * *2.2*: The `reply_to` option now accepts functions.
src/gun.erl+45 −11 modified@@ -211,6 +211,7 @@ -type req_opts() :: #{ flow => pos_integer(), + invalid_request_headers => raise | ignore, reply_to => reply_to(), tunnel => stream_ref() }. @@ -290,6 +291,7 @@ compress => boolean(), default_protocol => module(), flow => pos_integer(), + invalid_request_headers => raise | ignore, keepalive => timeout(), protocols => [{binary(), module()}], reply_to => pid(), @@ -683,12 +685,14 @@ headers(ServerPid, Method, Path, Headers) -> -spec headers(pid(), iodata(), iodata(), req_headers(), req_opts()) -> stream_ref(). headers(ServerPid, Method, Path, Headers0, ReqOpts) -> + Headers = normalize_headers(Headers0), + maybe_invalid_request_headers(Headers, ReqOpts), Tunnel = get_tunnel(ReqOpts), StreamRef = make_stream_ref(Tunnel), InitialFlow = maps:get(flow, ReqOpts, infinity), ReplyTo = maps:get(reply_to, ReqOpts, self()), gen_statem:cast(ServerPid, {headers, ReplyTo, StreamRef, - Method, Path, normalize_headers(Headers0), InitialFlow}), + Method, Path, Headers, InitialFlow}), StreamRef. -spec request(pid(), iodata(), iodata(), req_headers(), iodata()) -> stream_ref(). @@ -697,12 +701,14 @@ request(ServerPid, Method, Path, Headers, Body) -> -spec request(pid(), iodata(), iodata(), req_headers(), iodata(), req_opts()) -> stream_ref(). request(ServerPid, Method, Path, Headers, Body, ReqOpts) -> + NormHeaders = normalize_headers(Headers), + maybe_invalid_request_headers(NormHeaders, ReqOpts), Tunnel = get_tunnel(ReqOpts), StreamRef = make_stream_ref(Tunnel), InitialFlow = maps:get(flow, ReqOpts, infinity), ReplyTo = maps:get(reply_to, ReqOpts, self()), gen_statem:cast(ServerPid, {request, ReplyTo, StreamRef, - Method, Path, normalize_headers(Headers), Body, InitialFlow}), + Method, Path, NormHeaders, Body, InitialFlow}), StreamRef. get_tunnel(#{tunnel := Tunnel}) when is_reference(Tunnel) -> @@ -726,6 +732,28 @@ normalize_headers([{Name, Value}|Tail]) when is_atom(Name) -> normalize_headers(Headers) when is_map(Headers) -> normalize_headers(maps:to_list(Headers)). +maybe_invalid_request_headers(Headers, ReqOpts) -> + case maps:get(invalid_request_headers, ReqOpts, raise) of + raise -> + case maybe_invalid_request_headers(Headers) of + ok -> + ok; + {error, Name} -> + error({invalid_request_header, Name, + "An invalid request header was detected."}) + end; + ignore -> + ok + end. + +maybe_invalid_request_headers([{Name, Value}|Tail]) -> + case binary:match(iolist_to_binary(Value), [<<$\r>>, <<$\n>>]) of + nomatch -> maybe_invalid_request_headers(Tail); + _ -> {error, Name} + end; +maybe_invalid_request_headers([]) -> + ok. + %% Streaming data. -spec data(pid(), stream_ref(), fin | nofin, iodata()) -> ok. @@ -762,7 +790,9 @@ connect(ServerPid, Destination, Headers) -> connect(ServerPid, Destination, Headers, #{}). -spec connect(pid(), connect_destination(), req_headers(), req_opts()) -> stream_ref(). -connect(ServerPid, Destination, Headers, ReqOpts) -> +connect(ServerPid, Destination, Headers0, ReqOpts) -> + Headers = normalize_headers(Headers0), + maybe_invalid_request_headers(Headers, ReqOpts), Tunnel = get_tunnel(ReqOpts), StreamRef = make_stream_ref(Tunnel), InitialFlow = maps:get(flow, ReqOpts, infinity), @@ -987,19 +1017,23 @@ ws_upgrade(ServerPid, Path) -> ws_upgrade(ServerPid, Path, []). -spec ws_upgrade(pid(), iodata(), req_headers()) -> stream_ref(). -ws_upgrade(ServerPid, Path, Headers) -> +ws_upgrade(ServerPid, Path, Headers0) -> + Headers = normalize_headers(Headers0), + maybe_invalid_request_headers(Headers, #{invalid_request_headers => raise}), StreamRef = make_ref(), - gen_statem:cast(ServerPid, {ws_upgrade, self(), StreamRef, Path, normalize_headers(Headers)}), + gen_statem:cast(ServerPid, {ws_upgrade, self(), StreamRef, Path, Headers}), StreamRef. -spec ws_upgrade(pid(), iodata(), req_headers(), ws_opts()) -> stream_ref(). -ws_upgrade(ServerPid, Path, Headers, Opts0) -> - Tunnel = get_tunnel(Opts0), - Opts = maps:without([tunnel], Opts0), - ok = gun_ws:check_options(Opts), +ws_upgrade(ServerPid, Path, Headers0, WsOpts0) -> + Headers = normalize_headers(Headers0), + maybe_invalid_request_headers(Headers, WsOpts0), + Tunnel = get_tunnel(WsOpts0), + WsOpts = maps:without([invalid_request_headers, tunnel], WsOpts0), + ok = gun_ws:check_options(WsOpts), StreamRef = make_stream_ref(Tunnel), - ReplyTo = maps:get(reply_to, Opts, self()), - gen_statem:cast(ServerPid, {ws_upgrade, ReplyTo, StreamRef, Path, normalize_headers(Headers), Opts}), + ReplyTo = maps:get(reply_to, WsOpts, self()), + gen_statem:cast(ServerPid, {ws_upgrade, ReplyTo, StreamRef, Path, Headers, WsOpts}), StreamRef. -spec ws_send(pid(), stream_ref(), ws_frame() | [ws_frame()]) -> ok.
test/gun_SUITE.erl+64 −0 modified@@ -196,6 +196,70 @@ info(_) -> #{sock_ip := _, sock_port := _, state_name := connected} = gun:info(Pid), gun:close(Pid). +invalid_request_headers_ignore(_) -> + doc("Ensure invalid request headers are sent when allowed by configuration."), + {ok, ListenSocket} = gen_tcp:listen(0, [binary, {active, false}]), + {ok, {_, Port}} = inet:sockname(ListenSocket), + {ok, Pid} = gun:open("localhost", Port, #{protocols => [http]}), + {ok, ClientSocket} = gen_tcp:accept(ListenSocket, 5000), + {ok, http} = gun:await_up(Pid), + _ = gun:get(Pid, "/", [{<<"x-test">>, <<"bad\r\nvalue">>}], #{ + invalid_request_headers => ignore + }), + {ok, Data} = gen_tcp:recv(ClientSocket, 0, 5000), + true = binary:match(Data, <<"bad\r\nvalue">>) =/= nomatch, + gun:close(Pid). + +invalid_request_headers_raise_connect(_) -> + doc("Ensure invalid request headers raise an exception for CONNECT."), + {ok, Pid} = gun:open("localhost", 12345, #{protocols => [http]}), + %% The connection will not succeed, but we don't need it to. + try + gun:connect(Pid, #{host => "localhost", port => 1234}, + [{<<"x-test">>, <<"bad\r\nvalue">>}]), + ct:fail("expected exception") + catch + error:{invalid_request_header, _, _} -> ok + end, + gun:close(Pid). + +invalid_request_headers_raise_headers(_) -> + doc("Ensure invalid request headers raise an exception."), + {ok, Pid} = gun:open("localhost", 12345, #{protocols => [http]}), + %% The connection will not succeed, but we don't need it to. + try + gun:post(Pid, "/", [{<<"x-test">>, <<"bad\r\nvalue">>}]), + ct:fail("expected exception") + catch + error:{invalid_request_header, _, _} -> ok + end, + gun:close(Pid). + +invalid_request_headers_raise_request(_) -> + doc("Ensure invalid request headers raise an exception."), + {ok, Pid} = gun:open("localhost", 12345, #{protocols => [http]}), + %% The connection will not succeed, but we don't need it to. + try + gun:get(Pid, "/", [{<<"x-test">>, <<"bad\r\nvalue">>}], + #{invalid_request_headers => raise}), + ct:fail("expected exception") + catch + error:{invalid_request_header, _, _} -> ok + end, + gun:close(Pid). + +invalid_request_headers_raise_ws_upgrade(_) -> + doc("Ensure invalid request headers raise an exception for Websocket upgrades."), + {ok, Pid} = gun:open("localhost", 12345, #{protocols => [http]}), + %% The connection will not succeed, but we don't need it to. + try + gun:ws_upgrade(Pid, "/ws", [{<<"x-test">>, <<"bad\r\nvalue">>}]), + ct:fail("expected exception") + catch + error:{invalid_request_header, _, _} -> ok + end, + gun:close(Pid). + keepalive_infinity(_) -> doc("Ensure infinity for keepalive is accepted by all protocols."), {ok, ConnPid} = gun:open("localhost", 12345, #{
f77cb9b5e730Add invalid_response_headers HTTP/1 option
5 files changed · +348 −1
AGENTS.md+2 −1 modified@@ -75,7 +75,8 @@ Always write one or more tests before modifying the code. There must be at least one test that fails before and succeeds after. If you are not able to write a failing test, abort and tell -the user about it. +the user about it. This does not apply to refactoring as in +the refactoring case existing tests are enough. ### When writing tests We want to test both success and failure conditions.
doc/src/manual/cowboy_http.asciidoc+11 −0 modified@@ -27,6 +27,7 @@ opts() :: #{ idle_timeout => timeout(), inactivity_timeout => timeout(), initial_stream_flow_size => non_neg_integer(), + invalid_response_headers => error_terminate | ignore, linger_timeout => timeout(), logger => module(), max_authorization_header_value_length => non_neg_integer(), @@ -117,6 +118,15 @@ inactivity_timeout (300000):: **DEPRECATED** Time in ms with nothing received at all before Cowboy closes the connection. +invalid_response_headers (error_terminate):: + +Prevent sending HTTP responses with invalid response headers. +The default `error_terminate` will result in an HTTP 500 +response sent when invalid response headers are detected, +except when the invalid headers are trailer headers: in that +case the connection is dropped. When set to `ignore` the +check is not performed at all: use at your own risk. + initial_stream_flow_size (65535):: Amount of data in bytes Cowboy will read from the socket @@ -210,6 +220,7 @@ Ordered list of stream handlers that will handle all stream events. == Changelog +* *2.16*: The `invalid_response_headers` option was added. * *2.15*: The `max_authorization_header_value_length` and `max_cookie_header_value_length` options were added. * *2.13*: The `inactivity_timeout` option was deprecated. * *2.13*: The `active_n` default value was changed to `1`.
src/cowboy_http.erl+66 −0 modified@@ -39,6 +39,7 @@ idle_timeout => timeout(), inactivity_timeout => timeout(), initial_stream_flow_size => non_neg_integer(), + invalid_response_headers => error_terminate | ignore, linger_timeout => timeout(), logger => module(), max_authority_length => non_neg_integer(), @@ -1100,6 +1101,14 @@ commands(State, StreamID, [{error_response, _, _, _}|Tail]) -> %% Send an informational response. commands(State0=#state{socket=Socket, transport=Transport, out_state=wait, streams=Streams}, StreamID, [{inform, StatusCode, Headers}|Tail]) -> + case maybe_invalid_response_headers(Headers, State0) of + error_terminate -> + Reason = {internal_error, invalid_response_header, + 'An invalid response header was detected in an informational response.'}, + terminate(stream_terminate(State0, StreamID, Reason), Reason); + ok -> + ok + end, %% @todo I'm pretty sure the last stream in the list is the one we want %% considering all others are queued. #stream{version=Version} = lists:keyfind(StreamID, #stream.id, Streams), @@ -1120,6 +1129,14 @@ commands(State0=#state{socket=Socket, transport=Transport, out_state=wait, strea %% @todo Same two things above apply to DATA, possibly promise too. commands(State0=#state{socket=Socket, transport=Transport, out_state=wait, streams=Streams}, StreamID, [{response, StatusCode, Headers0, Body}|Tail]) -> + case maybe_invalid_response_headers(Headers0, State0) of + error_terminate -> + Reason = {internal_error, invalid_response_header, + 'An invalid response header was detected.'}, + terminate(stream_terminate(State0, StreamID, Reason), Reason); + ok -> + ok + end, %% @todo I'm pretty sure the last stream in the list is the one we want %% considering all others are queued. #stream{version=Version} = lists:keyfind(StreamID, #stream.id, Streams), @@ -1141,6 +1158,14 @@ commands(State0=#state{socket=Socket, transport=Transport, out_state=wait, strea commands(State0=#state{socket=Socket, transport=Transport, opts=Opts, overriden_opts=Override, streams=Streams0, out_state=OutState}, StreamID, [{headers, StatusCode, Headers0}|Tail]) -> + case maybe_invalid_response_headers(Headers0, State0) of + error_terminate -> + Reason = {internal_error, invalid_response_header, + 'An invalid response header was detected.'}, + terminate(stream_terminate(State0, StreamID, Reason), Reason); + ok -> + ok + end, %% @todo Same as above (about the last stream in the list). Stream = #stream{version=Version} = lists:keyfind(StreamID, #stream.id, Streams0), Status = cow_http:status_to_integer(StatusCode), @@ -1245,6 +1270,16 @@ commands(State0=#state{socket=Socket, transport=Transport, streams=Streams0, out commands(State#state{streams=Streams}, StreamID, Tail); commands(State0=#state{socket=Socket, transport=Transport, streams=Streams, out_state=OutState}, StreamID, [{trailers, Trailers}|Tail]) -> + case maybe_invalid_response_headers(Trailers, State0) of + error_terminate -> + %% When there are invalid trailer headers the only thing + %% we can do is drop the connection. + Reason = {internal_error, invalid_response_header, + 'An invalid response header was detected in trailers.'}, + terminate(State0, Reason); + ok -> + ok + end, case stream_te(OutState, lists:keyfind(StreamID, #stream.id, Streams)) of trailers -> ok = maybe_socket_error(State0, @@ -1266,6 +1301,14 @@ commands(State0=#state{socket=Socket, transport=Transport, streams=Streams, out_ commands(State0=#state{ref=Ref, parent=Parent, socket=Socket, transport=Transport, out_state=OutState, buffer=Buffer, children=Children}, StreamID, [{switch_protocol, Headers, Protocol, InitialState}|_Tail]) -> + case maybe_invalid_response_headers(Headers, State0) of + error_terminate -> + Reason = {internal_error, invalid_response_header, + 'An invalid response header was detected when switching protocol.'}, + terminate(stream_terminate(State0, StreamID, Reason), Reason); + ok -> + ok + end, %% @todo If there's streams opened after this one, fail instead of 101. State1 = cancel_timeout(State0), %% Before we send the 101 response we need to stop receiving data @@ -1320,6 +1363,29 @@ commands(State=#state{opts=Opts}, StreamID, [Log={log, _, _, _}|Tail]) -> commands(State, StreamID, [{push, _, _, _, _, _, _, _}|Tail]) -> commands(State, StreamID, Tail). +maybe_invalid_response_headers(Headers, #state{opts=Opts}) -> + case maps:get(invalid_response_headers, Opts, error_terminate) of + error_terminate -> + It = maps:iterator(Headers), + maybe_invalid_response_headers(maps:next(It)); + ignore -> + ok + end. + +maybe_invalid_response_headers({_, V, It}) when is_binary(V) -> + case binary:match(V, [<<$\r>>, <<$\n>>]) of + nomatch -> + maybe_invalid_response_headers(maps:next(It)); + _ -> + error_terminate + end; +maybe_invalid_response_headers({K, V, It}) -> + %% We build a temporary binary for simplicity's sake + %% when we have a list/iolist. + maybe_invalid_response_headers({K, iolist_to_binary(V), It}); +maybe_invalid_response_headers(none) -> + ok. + %% The set-cookie header is special; we can only send one cookie per header. headers_to_list(Headers0=#{<<"set-cookie">> := SetCookies}) -> Headers1 = maps:to_list(maps:remove(<<"set-cookie">>, Headers0)),
test/handlers/resp_invalid_headers_h.erl+44 −0 added@@ -0,0 +1,44 @@ +%% This module sends invalid response headers +%% using various reply functions. + +-module(resp_invalid_headers_h). + +-export([init/2]). +-export([upgrade/4]). + +init(Req0, Opts) -> + case cowboy_req:path(Req0) of + <<"/reply">> -> + Req = cowboy_req:reply(200, #{ + <<"x-test">> => <<"bad\r\nvalue">>, + <<"x-list">> => "good value as a list" + }, <<"OK">>, Req0), + {ok, Req, Opts}; + <<"/stream_reply">> -> + Req = cowboy_req:stream_reply(200, #{ + <<"x-test">> => "bad\r\nvalue" + }, Req0), + cowboy_req:stream_body(<<"OK">>, fin, Req), + {ok, Req, Opts}; + <<"/stream_trailers">> -> + Req = cowboy_req:stream_reply(200, #{ + <<"trailer">> => <<"x-test">> + }, Req0), + timer:sleep(100), + cowboy_req:stream_body(<<"OK">>, nofin, Req), + timer:sleep(100), + cowboy_req:stream_trailers(#{<<"x-test">> => <<"bad\r\nvalue">>}, Req), + {ok, Req, Opts}; + <<"/inform">> -> + ok = cowboy_req:inform(100, #{<<"x-test">> => ["bad", $\r, $\n, <<"value">>]}, Req0), + timer:sleep(100), + Req = cowboy_req:reply(200, #{}, <<"OK">>, Req0), + {ok, Req, Opts}; + <<"/switch_protocol">> -> + {resp_invalid_headers_h, Req0, Opts} + end. + +upgrade(Req=#{pid := Pid, streamid := StreamID}, Env, _Handler, _State) -> + Headers = #{<<"x-test">> => <<"bad\r\nvalue">>}, + Pid ! {{Pid, StreamID}, {switch_protocol, Headers, ?MODULE, undefined}}, + {ok, Req, Env}.
test/http_SUITE.erl+225 −0 modified@@ -28,6 +28,8 @@ -import(cowboy_test, [raw_recv/3]). -import(cowboy_test, [raw_expect_recv/2]). +-include_lib("stdlib/include/assert.hrl"). + all() -> [{group, clear_no_parallel}, {group, clear}]. @@ -489,6 +491,229 @@ do_idle_timeout_recv_loop(Ref, Pid, ConnPid, StreamRef, ExpectCompletion) -> error(timeout) end. +invalid_response_headers_inform(Config) -> + doc("Ensure invalid response headers are rejected by default."), + Dispatch = cowboy_router:compile([{'_', [ + {"/inform", resp_invalid_headers_h, []} + ]}]), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => Dispatch} + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + Request = "GET /inform HTTP/1.1\r\nhost: localhost\r\n\r\n", + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, Request), + {'HTTP/1.1', 500, _, Rest} = cow_http:parse_status_line(raw_recv_head(Client)), + {_, _} = cow_http:parse_headers(Rest), + ok + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +invalid_response_headers_inform_ignore(Config) -> + doc("Ensure invalid response headers are sent " + "when allowed by configuration."), + Dispatch = cowboy_router:compile([{'_', [ + {"/inform", resp_invalid_headers_h, []} + ]}]), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => Dispatch}, + invalid_response_headers => ignore + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + Request = "GET /inform HTTP/1.1\r\nhost: localhost\r\n\r\n", + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, Request), + {'HTTP/1.1', 100, _, Rest} = cow_http:parse_status_line(raw_recv_head(Client)), + ?assertError(function_clause, cow_http:parse_headers(Rest), + "Invalid header goes through."), + {'HTTP/1.1', 200, _, _} = cow_http:parse_status_line(raw_recv_head(Client)), + ok + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +invalid_response_headers_reply(Config) -> + doc("Ensure invalid response headers are rejected by default."), + Dispatch = cowboy_router:compile([{'_', [ + {"/reply", resp_invalid_headers_h, []} + ]}]), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => Dispatch} + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + Request = "GET /reply HTTP/1.1\r\nhost: localhost\r\n\r\n", + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, Request), + {'HTTP/1.1', 500, _, Rest} = cow_http:parse_status_line(raw_recv_head(Client)), + {_, _} = cow_http:parse_headers(Rest), + ok + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +invalid_response_headers_reply_ignore(Config) -> + doc("Ensure invalid response headers are sent " + "when allowed by configuration."), + Dispatch = cowboy_router:compile([{'_', [ + {"/reply", resp_invalid_headers_h, []} + ]}]), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => Dispatch}, + invalid_response_headers => ignore + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + Request = "GET /reply HTTP/1.1\r\nhost: localhost\r\n\r\n", + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, Request), + {'HTTP/1.1', 200, _, Rest} = cow_http:parse_status_line(raw_recv_head(Client)), + ?assertError(function_clause, cow_http:parse_headers(Rest), + "Invalid header goes through.") + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +invalid_response_headers_stream_reply(Config) -> + doc("Ensure invalid response headers are rejected by default."), + Dispatch = cowboy_router:compile([{'_', [ + {"/stream_reply", resp_invalid_headers_h, []} + ]}]), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => Dispatch}, + invalid_response_headers => error_terminate + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + Request = "GET /stream_reply HTTP/1.1\r\nhost: localhost\r\n\r\n", + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, Request), + {'HTTP/1.1', 500, _, Rest} = cow_http:parse_status_line(raw_recv_head(Client)), + {_, _} = cow_http:parse_headers(Rest), + ok + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +invalid_response_headers_stream_reply_ignore(Config) -> + doc("Ensure invalid response headers are sent " + "when allowed by configuration."), + Dispatch = cowboy_router:compile([{'_', [ + {"/stream_reply", resp_invalid_headers_h, []} + ]}]), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => Dispatch}, + invalid_response_headers => ignore + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + Request = "GET /stream_reply HTTP/1.1\r\nhost: localhost\r\n\r\n", + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, Request), + {'HTTP/1.1', 200, _, Rest} = cow_http:parse_status_line(raw_recv_head(Client)), + ?assertError(function_clause, cow_http:parse_headers(Rest), + "Invalid header goes through.") + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +invalid_response_headers_stream_trailers(Config) -> + doc("Ensure invalid response headers are rejected by default."), + Dispatch = cowboy_router:compile([{'_', [ + {"/stream_trailers", resp_invalid_headers_h, []} + ]}]), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => Dispatch}, + invalid_response_headers => error_terminate + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + Request = "GET /stream_trailers HTTP/1.1\r\nhost: localhost\r\n" + "te: trailers\r\n\r\n", + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, Request), + {'HTTP/1.1', 200, _, Rest0} = cow_http:parse_status_line(raw_recv_head(Client)), + {Headers, <<>>} = cow_http:parse_headers(Rest0), + {_, _} = lists:keyfind(<<"trailer">>, 1, Headers), + {ok, <<"2\r\nOK\r\n">>} = raw_recv(Client, 0, 1000), + %% Connection should be closed without trailers. + {error, closed} = raw_recv(Client, 0, 1000) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +invalid_response_headers_stream_trailers_ignore(Config) -> + doc("Ensure invalid response headers are sent " + "when allowed by configuration."), + Dispatch = cowboy_router:compile([{'_', [ + {"/stream_trailers", resp_invalid_headers_h, []} + ]}]), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => Dispatch}, + invalid_response_headers => ignore + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + Request = "GET /stream_trailers HTTP/1.1\r\nhost: localhost\r\n" + "te: trailers\r\n\r\n", + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, Request), + {'HTTP/1.1', 200, _, Rest0} = cow_http:parse_status_line(raw_recv_head(Client)), + {Headers, <<>>} = cow_http:parse_headers(Rest0), + {_, _} = lists:keyfind(<<"trailer">>, 1, Headers), + {ok, <<"2\r\nOK\r\n">>} = raw_recv(Client, 0, 1000), + {ok, Rest1} = raw_recv(Client, 0, 1000), + ?assertError(function_clause, cow_http:parse_headers(Rest1), + "Invalid header goes through.") + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +invalid_response_headers_switch_protocol(Config) -> + doc("Ensure invalid response headers are rejected by default."), + Dispatch = cowboy_router:compile([{'_', [ + {"/switch_protocol", resp_invalid_headers_h, []} + ]}]), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => Dispatch} + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + Request = "GET /switch_protocol HTTP/1.1\r\nhost: localhost\r\n\r\n", + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, Request), + {'HTTP/1.1', 500, _, Rest} = cow_http:parse_status_line(raw_recv_head(Client)), + {_, _} = cow_http:parse_headers(Rest), + ok + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +invalid_response_headers_switch_protocol_ignore(Config) -> + doc("Ensure invalid response headers are sent " + "when allowed by configuration."), + Dispatch = cowboy_router:compile([{'_', [ + {"/switch_protocol", resp_invalid_headers_h, []} + ]}]), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => Dispatch}, + invalid_response_headers => ignore + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + Request = "GET /switch_protocol HTTP/1.1\r\nhost: localhost\r\n\r\n", + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, Request), + {'HTTP/1.1', 101, _, Rest} = cow_http:parse_status_line(raw_recv_head(Client)), + ?assertError(function_clause, cow_http:parse_headers(Rest), + "Invalid header goes through.") + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + max_authorization_header_value_length(Config) -> doc("Confirm the max_authorization_header_value_length option " "correctly limits the length of authorization header values."),
Vulnerability mechanics
Root cause
"The cowlib library improperly neutralizes CRLF sequences in HTTP header string values, allowing for HTTP response splitting."
Attack vector
An attacker can control input that is used to build structured HTTP header values. When these values are processed by `cow_http_struct_hd:escape_string/2` in cowlib, non-VCHAR bytes, including CR and LF, are not escaped. This allows an attacker to inject CRLF sequences into a header, which are then interpreted by the HTTP parser as the end of the current header and the beginning of a new one, enabling HTTP response splitting [ref_id=1].
Affected code
The vulnerability lies in the `cow_http_struct_hd:escape_string/2` function within the cowlib library, which fails to properly escape CRLF sequences. The fix is implemented in `cowboy_http.erl` and `gun.erl` by adding checks for invalid headers before they are sent, specifically within the `maybe_invalid_response_headers` and `maybe_invalid_request_headers` functions respectively [ref_id=1, ref_id=2].
What the fix does
The patch introduces a check for CRLF sequences within header values before they are serialized. The `maybe_invalid_response_headers` function, called before sending responses, now inspects header values for CRLF sequences. If found, and the `invalid_response_headers` option is set to `error_terminate`, the connection is terminated with an error, preventing the injection of malicious headers and thus mitigating HTTP response splitting [ref_id=1].
Generated on Jun 8, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.