aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLoïc Hoguin <[email protected]>2025-03-28 11:42:29 +0100
committerLoïc Hoguin <[email protected]>2025-04-09 17:18:53 +0200
commit403a0af4cd8dd378c500e5ec7604bcc68c5ee5b8 (patch)
treeab92777e55b7f18ab171f9914723b93ff025342e
parent5a0c8f31c4ae1885fd05f627f89c088bcbe2b4d0 (diff)
downloadgun-403a0af4cd8dd378c500e5ec7604bcc68c5ee5b8.tar.gz
gun-403a0af4cd8dd378c500e5ec7604bcc68c5ee5b8.tar.bz2
gun-403a0af4cd8dd378c500e5ec7604bcc68c5ee5b8.zip
Catch post-handshake TLS 1.3 alerts
When TLS 1.3 is used and `fail_if_no_peer_cert` (or equivalent) is configured on the server, such as in mTLS scenarios, and the client certificate is missing or invalid, the TLS 1.3 alert will be sent after the handshake has completed. The same is true for post-handshake authentication in TLS 1.3 which Erlang/OTP doesn't yet support, but will at some point in the future. Due to the asynchronous nature of some `ssl` socket operations, such as sending, the alert may not always be returned from a socket call. When the ssl socket is active we would receive it as a message instead, so when Gun gets `{error,closed}` it must look for the active message and see if an alert occurred. When the ssl socket is passive we don't, so we must query the socket for it (trying to set the socket active at that point gets us the alert in the return value). There is a span between handshake and the initial active mode set where the socket is passive and may send data (the HTTP/2 preface) so we must account for both cases. Because we sometimes have to wait for the alert as a message, and we don't want to wait for a very long time (200ms), we sometimes may lose the alert. Perhaps in the future this wait time can be made configurable for users that really require getting the alert. The tests are only enabled on Linux because other OSes have intermittent failures (likely due to timing).
-rw-r--r--src/gun.erl87
-rw-r--r--src/gun_http2.erl25
-rw-r--r--src/gun_http3.erl1
-rw-r--r--src/gun_tls.erl2
-rw-r--r--src/gun_tls_proxy.erl33
-rw-r--r--test/gun_SUITE.erl328
-rw-r--r--test/rfc7540_SUITE.erl3
7 files changed, 461 insertions, 18 deletions
diff --git a/src/gun.erl b/src/gun.erl
index 085e47f..e23da3a 100644
--- a/src/gun.erl
+++ b/src/gun.erl
@@ -332,7 +332,7 @@
keepalive_ref :: undefined | reference(),
socket :: undefined | inet:socket() | ssl:sslsocket() | pid(),
transport :: module(),
- active = true :: boolean(),
+ active = false :: boolean(),
messages :: {atom(), atom(), atom()},
protocol :: module(),
protocol_state :: any(),
@@ -1331,10 +1331,18 @@ normal_tls_handshake(Socket, State=#state{
EvHandlerState1 = EvHandler:tls_handshake_start(HandshakeEvent, EvHandlerState0),
case gun_tls:connect(Socket, TLSOpts, TLSTimeout) of
{ok, TLSSocket} ->
- %% This call may return {error,closed} when the socket has
- %% been closed by the peer. This should be very rare (due to
- %% timing) but can happen for example when client certificates
- %% were required but not sent or invalid with some servers.
+ %% When initially connecting we are in passive mode and
+ %% in that state we expect this call to always succeed.
+ %% In rare scenarios (suspended Gun process) it may
+ %% return {error,closed}, but this indicates that the
+ %% socket process is gone and we cannot retrieve a potential
+ %% TLS alert.
+ %%
+ %% When using HTTP/1.1 CONNECT we are also in passive mode
+ %% because CONNECT involves a response that is received via
+ %% active mode, which automatically goes into passive mode
+ %% ({active,once}), and we only reenable active mode after
+ %% processing commands.
case ssl:negotiated_protocol(TLSSocket) of
{error, Reason = closed} ->
EvHandlerState = EvHandler:tls_handshake_end(HandshakeEvent#{
@@ -1368,7 +1376,8 @@ connected_protocol_init(internal, {connected, Retries, Socket, NewProtocol},
{ok, StateName, ProtoState} ->
%% @todo Don't send gun_up and gun_down if active/1 fails here.
reply(Owner, {gun_up, self(), Protocol:name()}),
- State1 = State0#state{socket=Socket, protocol=Protocol, protocol_state=ProtoState},
+ State1 = State0#state{socket=Socket, protocol=Protocol,
+ protocol_state=ProtoState, active=true},
case active(State1) of
{ok, State2} ->
State = case Protocol:has_keepalive() of
@@ -1899,7 +1908,8 @@ commands([TLSHandshake={tls_handshake, _, _, _}], State) ->
disconnect(State0=#state{owner=Owner, status=Status, opts=Opts,
intermediaries=Intermediaries, socket=Socket, transport=Transport0,
protocol=Protocol, protocol_state=ProtoState,
- event_handler=EvHandler, event_handler_state=EvHandlerState0}, Reason) ->
+ event_handler=EvHandler, event_handler_state=EvHandlerState0}, Reason0) ->
+ Reason = maybe_tls_alert(State0, Reason0),
EvHandlerState1 = Protocol:close(Reason, ProtoState, EvHandler, EvHandlerState0),
_ = Transport0:close(Socket),
EvHandlerState = EvHandler:disconnect(#{reason => Reason}, EvHandlerState1),
@@ -1913,6 +1923,9 @@ disconnect(State0=#state{owner=Owner, status=Status, opts=Opts,
%% We closed the socket, discard any remaining socket events.
disconnect_flush(State1),
KilledStreams = Protocol:down(ProtoState),
+ %% @todo Reason here may be {error, Reason1} which leads to
+ %% different behavior compared to down messages received
+ %% from failing to connect where Reason1 is what gets sent.
reply(Owner, {gun_down, self(), Protocol:name(), Reason, KilledStreams}),
Retry = maps:get(retry, Opts, 5),
State2 = keepalive_cancel(State1#state{
@@ -1935,6 +1948,66 @@ disconnect(State0=#state{owner=Owner, status=Status, opts=Opts,
{next_event, internal, {retries, Retry, Reason}}}
end.
+%% With TLS 1.3 the handshake may not have validated the certificate
+%% by the time it completes. The validation may therefore fail at any
+%% time afterwards. TLS 1.3 also introduced post-handshake authentication
+%% which would produce the same results. Erlang/OTP's ssl has a number
+%% of asynchronous functions which won't return the alert as an error
+%% and instead return a plain {error,closed}, including ssl:send.
+%% Gun must therefore check whether a close is resulting from a TLS alert
+%% and use that alert as a more descriptive disconnect reason.
+%%
+%% Sometimes, ssl:send will return {error,einval}, because while the
+%% TLS pseudo-socket still exists, the underlying TCP socket is already
+%% gone. In that case we can still query the TLS pseudo-socket to get
+%% the detailed TLS alert.
+%%
+%% @todo We currently do not support retrieving the alert from a gun_tls_proxy
+%% socket. We need a test case to best understand what should be done there.
+%% But since the socket belongs to that process we likely need additional
+%% changes there to make it work.
+maybe_tls_alert(#state{socket=Socket, transport=gun_tls,
+ active=true, messages={_, _, Error}}, Reason0)
+ %% The unwrapped tuple we get half the time makes this clause more complex.
+ when Reason0 =:= {error, closed}; Reason0 =:= {error, einval}; Reason0 =:= closed ->
+ %% When active mode is enabled we should have the alert in our
+ %% mailbox so we can just retrieve it. In case it is late we
+ %% use a short timeout to increase the chances of catching it.
+ receive
+ {Error, Socket, Reason} ->
+ Reason
+ after 200 ->
+ Reason0
+ end;
+maybe_tls_alert(#state{socket=Socket, transport=Transport=gun_tls,
+ active=false}, Reason0)
+ when Reason0 =:= {error, closed}; Reason0 =:= {error, einval}; Reason0 =:= closed ->
+ %% When active mode is disabled we can do a number of operations to
+ %% receive the alert. Enabling active mode is one of them.
+ case Transport:setopts(Socket, [{active, once}]) of
+ {error, Reason={tls_alert, _}} ->
+ Reason;
+ _ ->
+ Reason0
+ end;
+%% We unwrap the TLS alert error for consistency.
+%% @todo Consistenly wrap/unwrap all errors instead of just this one.
+maybe_tls_alert(_, {error, Reason={tls_alert, _}}) ->
+ Reason;
+%% We may also need to receive the alert when proxying TLS.
+maybe_tls_alert(#state{socket=Socket, transport=gun_tls_proxy,
+ active=true, messages={_, _, Error}}, Reason0)
+ %% The unwrapped tuple we get half the time makes this clause more complex.
+ when Reason0 =:= {error, closed}; Reason0 =:= {error, einval}; Reason0 =:= closed ->
+ receive
+ {Error, Socket, Reason} ->
+ Reason
+ after 200 ->
+ Reason0
+ end;
+maybe_tls_alert(_, Reason) ->
+ Reason.
+
disconnect_flush(State=#state{socket=Socket, messages={OK, Closed, Error}}) ->
receive
{OK, Socket, _} -> disconnect_flush(State);
diff --git a/src/gun_http2.erl b/src/gun_http2.erl
index b39be6e..0feb40e 100644
--- a/src/gun_http2.erl
+++ b/src/gun_http2.erl
@@ -206,6 +206,16 @@ init(ReplyTo, Socket, Transport, Opts0) ->
{ok, connected, #http2_state{reply_to=ReplyTo, socket=Socket, transport=Transport,
opts=Opts, base_stream_ref=BaseStreamRef, tunnel_transport=TunnelTransport,
content_handlers=Handlers, http2_machine=HTTP2Machine}};
+ Error0={error, R} when R =:= closed; R =:= einval ->
+ %% Check whether we have a TLS alert and in that case,
+ %% return it. We must do this here because Protocol:init
+ %% failure doesn't go through disconnect.
+ case Transport:setopts(Socket, [{active, once}]) of
+ Error={error, {tls_alert, _}} ->
+ Error;
+ _ ->
+ Error0
+ end;
Error={error, _Reason} ->
Error
end.
@@ -488,8 +498,20 @@ tunnel_commands([{state, ProtoState}|Tail], Stream=#stream{tunnel=Tunnel},
State, EvHandler, EvHandlerState) ->
tunnel_commands(Tail, Stream#stream{tunnel=Tunnel#tunnel{protocol_state=ProtoState}},
State, EvHandler, EvHandlerState);
-tunnel_commands([{error, Reason}|_], #stream{id=StreamID, ref=StreamRef, reply_to=ReplyTo},
+tunnel_commands([{error, Reason0}|_], #stream{id=StreamID, ref=StreamRef, reply_to=ReplyTo},
State, _EvHandler, EvHandlerState) ->
+ %% See gun:maybe_tls_alert for details.
+ Reason = case Reason0 of
+ closed ->
+ receive
+ {handle_continue, StreamRef, {tls_proxy_error, _Socket, Reason1}} ->
+ Reason1
+ after 200 ->
+ Reason0
+ end;
+ _ ->
+ Reason0
+ end,
gun:reply(ReplyTo, {gun_error, self(), stream_ref(State, StreamRef),
{stream_error, Reason, 'Tunnel closed unexpectedly.'}}),
{{state, delete_stream(State, StreamID)}, EvHandlerState};
@@ -948,6 +970,7 @@ close(Reason0, State=#http2_state{streams=Streams}, _, EvHandlerState) ->
end, [], Streams),
EvHandlerState.
+%% @todo This can get {error,closed} leading to {closed,{error,closed}}.
close_reason(closed) -> closed;
close_reason(Reason) -> {closed, Reason}.
diff --git a/src/gun_http3.erl b/src/gun_http3.erl
index 38d8970..26aa072 100644
--- a/src/gun_http3.erl
+++ b/src/gun_http3.erl
@@ -104,6 +104,7 @@ default_keepalive() -> infinity.
init(ReplyTo, Conn, Transport, Opts) ->
Handlers = maps:get(content_handlers, Opts, [gun_data_h]),
{ok, SettingsBin, HTTP3Machine0} = cow_http3_machine:init(client, Opts),
+ %% @todo We may get a TLS 1.3 error/alert here in mTLS scenarios.
{ok, ControlID} = Transport:start_unidi_stream(Conn, [<<0>>, SettingsBin]),
{ok, EncoderID} = Transport:start_unidi_stream(Conn, [<<2>>]),
{ok, DecoderID} = Transport:start_unidi_stream(Conn, [<<3>>]),
diff --git a/src/gun_tls.erl b/src/gun_tls.erl
index cf1bcfd..ef2d800 100644
--- a/src/gun_tls.erl
+++ b/src/gun_tls.erl
@@ -35,7 +35,7 @@ connect(Socket, Opts, Timeout) ->
send(Socket, Packet) ->
ssl:send(Socket, Packet).
--spec setopts(ssl:sslsocket(), list()) -> ok | {error, atom()}.
+-spec setopts(ssl:sslsocket(), list()) -> ok | {error, any()}.
setopts(Socket, Opts) ->
ssl:setopts(Socket, Opts).
diff --git a/src/gun_tls_proxy.erl b/src/gun_tls_proxy.erl
index 024c7a7..f3719b6 100644
--- a/src/gun_tls_proxy.erl
+++ b/src/gun_tls_proxy.erl
@@ -131,6 +131,8 @@ cb_send(Pid, Data) ->
try
gen_statem:call(Pid, {?FUNCTION_NAME, Data})
catch
+ exit:{{shutdown, close}, _} ->
+ {error, closed};
exit:{noproc, _} ->
{error, closed}
end.
@@ -140,6 +142,8 @@ cb_setopts(Pid, Opts) ->
try
gen_statem:call(Pid, {?FUNCTION_NAME, Opts})
catch
+ exit:{{shutdown, close}, _} ->
+ {error, closed};
exit:{noproc, _} ->
{error, einval}
end.
@@ -178,6 +182,9 @@ sockname(Pid) ->
close(Pid) ->
?DEBUG_LOG("pid ~0p", [Pid]),
try
+ %% We must unlink before closing otherwise the closing
+ %% will take down the Gun process with it.
+ unlink(Pid),
gen_statem:call(Pid, ?FUNCTION_NAME)
catch
%% May happen for example when the handshake fails.
@@ -236,11 +243,13 @@ not_connected(cast, Msg={setopts, _}, State) ->
{keep_state_and_data, postpone};
not_connected(cast, Msg={connect_proc, {ok, Socket}}, State=#state{owner_pid=OwnerPid, extra=Extra}) ->
?DEBUG_LOG("msg ~0p state ~0p", [Msg, State]),
- OwnerPid ! {?MODULE, self(), {ok, ssl:negotiated_protocol(Socket)}, Extra},
- %% We need to spawn this call before OTP-21.2 because it triggers
- %% a cb_setopts call that blocks us. Might be OK to just leave it
- %% like this once we support 21.2+ only.
- spawn(fun() -> ok = ssl:setopts(Socket, [{active, true}]) end),
+ Negotiated = ssl:negotiated_protocol(Socket),
+ _ = case ssl:setopts(Socket, [{active, true}]) of
+ ok ->
+ OwnerPid ! {?MODULE, self(), {ok, Negotiated}, Extra};
+ Error ->
+ OwnerPid ! {?MODULE, self(), Error, Extra}
+ end,
{next_state, connected, State#state{proxy_socket=Socket}};
not_connected(cast, Msg={connect_proc, Error}, State=#state{owner_pid=OwnerPid, extra=Extra}) ->
?DEBUG_LOG("msg ~0p state ~0p", [Msg, State]),
@@ -332,8 +341,20 @@ handle_common(cast, Msg={cb_controlling_process, ProxyPid}, State) ->
handle_common(cast, Msg={setopts, Opts}, State) ->
?DEBUG_LOG("msg ~0p state ~0p", [Msg, State]),
{keep_state, owner_setopts(Opts, State)};
-handle_common(cast, Msg={send_result, From, Result}, State) ->
+handle_common(cast, Msg={send_result, From, Result0}, State=#state{proxy_socket=Socket}) ->
?DEBUG_LOG("msg ~0p state ~0p", [Msg, State]),
+ %% See gun:maybe_tls_alert for details.
+ Result = case Result0 of
+ {error, closed} ->
+ receive
+ {ssl_error, Socket, Reason} ->
+ {error, Reason}
+ after 200 ->
+ Result0
+ end;
+ _ ->
+ Result0
+ end,
gen_statem:reply(From, Result),
keep_state_and_data;
%% Messages from the real socket.
diff --git a/test/gun_SUITE.erl b/test/gun_SUITE.erl
index 766b1e7..7c0440b 100644
--- a/test/gun_SUITE.erl
+++ b/test/gun_SUITE.erl
@@ -29,10 +29,31 @@ suite() ->
[{timetrap, 30000}].
all() ->
- [{group, gun}].
+ [
+ {group, tls13_post_handshake_alert},
+ {group, gun},
+ {group, tls13_post_handshake_alert}
+ ].
groups() ->
- [{gun, [parallel], ct_helper:all(?MODULE)}].
+ [
+ {gun, [parallel], ct_helper:all(?MODULE)},
+ %% We run these tests in parallel in 'gun' as well as
+ %% sequentially to have more chances of hitting different
+ %% parts of the code. We also run them before/after 'gun'.
+ {tls13_post_handshake_alert, [], [
+ tls13_post_handshake_alert_http1,
+ tls13_post_handshake_alert_http2,
+ tls13_post_handshake_alert_http1_via_http,
+ tls13_post_handshake_alert_http2_via_http,
+ tls13_post_handshake_alert_http1_via_https,
+ tls13_post_handshake_alert_http2_via_https,
+ tls13_post_handshake_alert_http1_via_h2c,
+ tls13_post_handshake_alert_http2_via_h2c,
+ tls13_post_handshake_alert_http1_via_h2,
+ tls13_post_handshake_alert_http2_via_h2
+ ]}
+ ].
%% Tests.
@@ -633,6 +654,309 @@ supervise_false(_) ->
[] = [P || {_, P, _, _} <- supervisor:which_children(gun_sup), P =:= Pid],
ok.
+tls13_post_handshake_alert_http1(_) ->
+ case {os:type(), erlang:function_exported(lists, enumerate, 3)} of
+ {{unix, linux}, true} ->
+ do_tls13_post_handshake_alert_http1();
+ {{unix, linux}, false} ->
+ {skip, "Handling of TLS 1.3 alerts was improved in an OTP-26 patch release."};
+ _ ->
+ {skip, "This test is only enabled on Linux to avoid intermittent failures."}
+ end.
+
+do_tls13_post_handshake_alert_http1() ->
+ doc("Ensure that a TLS 1.3 post-handshake alert is properly "
+ "propagated when using HTTP/1.1 in mTLS scenarios."),
+ TestOpts = ct_helper:get_certs_from_ets(),
+ {ok, ListenSocket} = ssl:listen(0, [binary,
+ {log_level, none},
+ {versions, ['tlsv1.3']},
+ %% The alert will be triggered by the missing certificate.
+ {verify, verify_peer},
+ {fail_if_no_peer_cert, true},
+ %% We only use the certs, not other options,
+ %% as we require TLS 1.3 and want a specific behavior.
+ {cacerts, proplists:get_value(cacerts, TestOpts)},
+ {cert, proplists:get_value(cert, TestOpts)},
+ {key, proplists:get_value(key, TestOpts)}
+ ]),
+ {ok, {_, OriginPort}} = ssl:sockname(ListenSocket),
+ _ = spawn_link(fun() ->
+ {ok, ClientSocket} = ssl:transport_accept(ListenSocket, 5000),
+ {error, {tls_alert, _}} = ssl:handshake(ClientSocket, 5000),
+ receive after infinity -> ok end
+ end),
+ {ok, ConnPid} = gun:open("localhost", OriginPort, #{
+ retry => 0,
+ transport => tls,
+ tls_opts => [
+ {log_level, none},
+ {verify, verify_none}
+ ]
+ }),
+ %% We want to send data immediately as soon as the connection is up.
+ %% So we do it before we receive the gun_up message.
+ StreamRef = gun:post(ConnPid, "/", [{<<"content-type">>, <<"application/octet-stream">>}]),
+ %% Yes, this was written on April 1st.
+ _ = [begin
+ gun:data(ConnPid, StreamRef, nofin, <<"April fools!">>)
+ end || _ <- lists:seq(1, 100)],
+ %% The alert will occur after the gun_up message has been sent
+ %% in the case of HTTP/1.1 (when active mode gets enabled).
+ {ok, http} = gun:await_up(ConnPid),
+ {error, {down, {shutdown,
+ {tls_alert, {certificate_required, _}}}}} = gun:await(ConnPid, undefined),
+ gun:close(ConnPid).
+
+tls13_post_handshake_alert_http2(_) ->
+ case {os:type(), erlang:function_exported(lists, enumerate, 3)} of
+ {{unix, linux}, true} ->
+ do_tls13_post_handshake_alert_http2();
+ {{unix, linux}, false} ->
+ {skip, "Handling of TLS 1.3 alerts was improved in an OTP-26 patch release."};
+ _ ->
+ {skip, "This test is only enabled on Linux to avoid intermittent failures."}
+ end.
+
+do_tls13_post_handshake_alert_http2() ->
+ doc("Ensure that a TLS 1.3 post-handshake alert is properly "
+ "propagated when using HTTP/2 in mTLS scenarios."),
+ TestOpts = ct_helper:get_certs_from_ets(),
+ {ok, ListenSocket} = ssl:listen(0, [binary,
+ {log_level, none},
+ {versions, ['tlsv1.3']},
+ %% Enable HTTP/2.
+ {alpn_preferred_protocols, [<<"h2">>]},
+ %% The alert will be triggered by the missing certificate.
+ {verify, verify_peer},
+ {fail_if_no_peer_cert, true},
+ %% We only use the certs, not other options,
+ %% as we require TLS 1.3 and want a specific behavior.
+ {cacerts, proplists:get_value(cacerts, TestOpts)},
+ {cert, proplists:get_value(cert, TestOpts)},
+ {key, proplists:get_value(key, TestOpts)}
+ ]),
+ {ok, {_, OriginPort}} = ssl:sockname(ListenSocket),
+ _ = spawn_link(fun() ->
+ {ok, ClientSocket} = ssl:transport_accept(ListenSocket, 5000),
+ {error, {tls_alert, _}} = ssl:handshake(ClientSocket, 5000),
+ receive after infinity -> ok end
+ end),
+ {ok, ConnPid} = gun:open("localhost", OriginPort, #{
+ retry => 0,
+ transport => tls,
+ tls_opts => [
+ {log_level, none},
+ {verify, verify_none}
+ ]
+ }),
+ case gun:await_up(ConnPid) of
+ {error, {down, {shutdown, {tls_alert, {certificate_required, _}}}}} ->
+ ct:log("TLS alert received in gun:await_up");
+ {ok, http2} ->
+ {error, {down, {shutdown,
+ {tls_alert, {certificate_required, _}}}}} = gun:await(ConnPid, undefined),
+ ct:log("TLS alert received after gun:await_up")
+ end,
+ gun:close(ConnPid).
+
+tls13_post_handshake_alert_http1_via_http(_) ->
+ doc("Ensure that a TLS 1.3 post-handshake alert is properly "
+ "propagated when connecting to an HTTP/1.1 server "
+ "over an HTTP/1.1 clear-text tunnel in mTLS scenarios."),
+ case {os:type(), erlang:function_exported(lists, enumerate, 3)} of
+ {{unix, linux}, true} ->
+ do_tls13_post_handshake_alert_via_tunnel(http, http);
+ {{unix, linux}, false} ->
+ {skip, "Handling of TLS 1.3 alerts was improved in an OTP-26 patch release."};
+ _ ->
+ {skip, "This test is only enabled on Linux to avoid intermittent failures."}
+ end.
+
+tls13_post_handshake_alert_http2_via_http(_) ->
+ doc("Ensure that a TLS 1.3 post-handshake alert is properly "
+ "propagated when connecting to an HTTP/2 server "
+ "over an HTTP/1.1 clear-text tunnel in mTLS scenarios."),
+ case {os:type(), erlang:function_exported(lists, enumerate, 3)} of
+ {{unix, linux}, true} ->
+ do_tls13_post_handshake_alert_via_tunnel(http, http2);
+ {{unix, linux}, false} ->
+ {skip, "Handling of TLS 1.3 alerts was improved in an OTP-26 patch release."};
+ _ ->
+ {skip, "This test is only enabled on Linux to avoid intermittent failures."}
+ end.
+
+tls13_post_handshake_alert_http1_via_https(_) ->
+ doc("Ensure that a TLS 1.3 post-handshake alert is properly "
+ "propagated when connecting to an HTTP/1.1 server "
+ "over an HTTP/1.1 TLS tunnel in mTLS scenarios."),
+ case {os:type(), erlang:function_exported(lists, enumerate, 3)} of
+ {{unix, linux}, true} ->
+ do_tls13_post_handshake_alert_via_tunnel(https, http);
+ {{unix, linux}, false} ->
+ {skip, "Handling of TLS 1.3 alerts was improved in an OTP-26 patch release."};
+ _ ->
+ {skip, "This test is only enabled on Linux to avoid intermittent failures."}
+ end.
+
+tls13_post_handshake_alert_http2_via_https(_) ->
+ doc("Ensure that a TLS 1.3 post-handshake alert is properly "
+ "propagated when connecting to an HTTP/2 server "
+ "over an HTTP/1.1 TLS tunnel in mTLS scenarios."),
+ case {os:type(), erlang:function_exported(lists, enumerate, 3)} of
+ {{unix, linux}, true} ->
+ do_tls13_post_handshake_alert_via_tunnel(https, http2);
+ {{unix, linux}, false} ->
+ {skip, "Handling of TLS 1.3 alerts was improved in an OTP-26 patch release."};
+ _ ->
+ {skip, "This test is only enabled on Linux to avoid intermittent failures."}
+ end.
+
+tls13_post_handshake_alert_http1_via_h2c(_) ->
+ doc("Ensure that a TLS 1.3 post-handshake alert is properly "
+ "propagated when connecting to an HTTP/1.1 server "
+ "over an HTTP/2 clear-text tunnel in mTLS scenarios."),
+ case {os:type(), erlang:function_exported(lists, enumerate, 3)} of
+ {{unix, linux}, true} ->
+ do_tls13_post_handshake_alert_via_tunnel(h2c, http);
+ {{unix, linux}, false} ->
+ {skip, "Handling of TLS 1.3 alerts was improved in an OTP-26 patch release."};
+ _ ->
+ {skip, "This test is only enabled on Linux to avoid intermittent failures."}
+ end.
+
+tls13_post_handshake_alert_http2_via_h2c(_) ->
+ doc("Ensure that a TLS 1.3 post-handshake alert is properly "
+ "propagated when connecting to an HTTP/2 server "
+ "over an HTTP/2 clear-text tunnel in mTLS scenarios."),
+ case {os:type(), erlang:function_exported(lists, enumerate, 3)} of
+ {{unix, linux}, true} ->
+ do_tls13_post_handshake_alert_via_tunnel(h2c, http2);
+ {{unix, linux}, false} ->
+ {skip, "Handling of TLS 1.3 alerts was improved in an OTP-26 patch release."};
+ _ ->
+ {skip, "This test is only enabled on Linux to avoid intermittent failures."}
+ end.
+
+tls13_post_handshake_alert_http1_via_h2(_) ->
+ doc("Ensure that a TLS 1.3 post-handshake alert is properly "
+ "propagated when connecting to an HTTP/1.1 server "
+ "over an HTTP/2 TLS tunnel in mTLS scenarios."),
+ case {os:type(), erlang:function_exported(lists, enumerate, 3)} of
+ {{unix, linux}, true} ->
+ do_tls13_post_handshake_alert_via_tunnel(h2, http);
+ {{unix, linux}, false} ->
+ {skip, "Handling of TLS 1.3 alerts was improved in an OTP-26 patch release."};
+ _ ->
+ {skip, "This test is only enabled on Linux to avoid intermittent failures."}
+ end.
+
+tls13_post_handshake_alert_http2_via_h2(_) ->
+ doc("Ensure that a TLS 1.3 post-handshake alert is properly "
+ "propagated when connecting to an HTTP/2 server "
+ "over an HTTP/2 TLS tunnel in mTLS scenarios."),
+ case {os:type(), erlang:function_exported(lists, enumerate, 3)} of
+ {{unix, linux}, true} ->
+ do_tls13_post_handshake_alert_via_tunnel(h2, http2);
+ {{unix, linux}, false} ->
+ {skip, "Handling of TLS 1.3 alerts was improved in an OTP-26 patch release."};
+ _ ->
+ {skip, "This test is only enabled on Linux to avoid intermittent failures."}
+ end.
+
+do_tls13_post_handshake_alert_via_tunnel(ProxyType, OriginProtocol) ->
+ TestOpts = ct_helper:get_certs_from_ets(),
+ ExtraOpts = case OriginProtocol of
+ http -> [];
+ http2 -> [{alpn_preferred_protocols, [<<"h2">>]}]
+ end,
+ {ok, ListenSocket} = ssl:listen(0, [binary,
+ {log_level, none},
+ {versions, ['tlsv1.3']},
+ %% The alert will be triggered by the missing certificate.
+ {verify, verify_peer},
+ {fail_if_no_peer_cert, true},
+ %% We only use the certs, not other options,
+ %% as we require TLS 1.3 and want a specific behavior.
+ {cacerts, proplists:get_value(cacerts, TestOpts)},
+ {cert, proplists:get_value(cert, TestOpts)},
+ {key, proplists:get_value(key, TestOpts)}
+ |ExtraOpts]),
+ {ok, {_, OriginPort}} = ssl:sockname(ListenSocket),
+ _ = spawn_link(fun() ->
+ {ok, ClientSocket} = ssl:transport_accept(ListenSocket, 5000),
+ {error, {tls_alert, _}} = ssl:handshake(ClientSocket, 5000),
+ receive after infinity -> ok end
+ end),
+ {ok, ProxyPid, ProxyPort} = tunnel_SUITE:do_proxy_start(ProxyType),
+ {ProxyTransport, ProxyProtocol} = tunnel_SUITE:do_type(ProxyType),
+ {ok, ConnPid} = gun:open("localhost", ProxyPort, #{
+ retry => 0,
+ transport => ProxyTransport,
+ tls_opts => [{verify, verify_none}],
+ protocols => [ProxyProtocol]
+ %,trace => true
+ }),
+ {ok, ProxyProtocol} = gun:await_up(ConnPid),
+ tunnel_SUITE:do_handshake_completed(ProxyProtocol, ProxyPid),
+ TunnelStreamRef = gun:connect(ConnPid, #{
+ host => "localhost",
+ port => OriginPort,
+ transport => tls,
+ tls_opts => [
+ {log_level, none},
+ {verify, verify_none}
+ ]
+ }),
+ {response, fin, 200, _} = gun:await(ConnPid, TunnelStreamRef),
+ %% When going through an HTTP/2 tunnel we must wait for the protocol
+ %% to be up before sending data.
+ Continue = case ProxyProtocol of
+ http2 ->
+ case gun:await(ConnPid, TunnelStreamRef) of
+ {up, OriginProtocol} ->
+ ok;
+ {error, {stream_error, {stream_error,
+ {tls_alert, {certificate_required, _}}, _}}} ->
+ stop
+ end;
+ http ->
+ ok
+ end,
+ case Continue of
+ stop ->
+ ok;
+ ok ->
+ %% Otherwise we want to send data immediately as soon as the connection
+ %% is up. So we do it before we receive the gun_up message.
+ StreamRef = gun:post(ConnPid, "/", [{<<"content-type">>, <<"application/octet-stream">>}],
+ #{tunnel => TunnelStreamRef}),
+ %% Yes, this was written on April 1st.
+ _ = [begin
+ gun:data(ConnPid, StreamRef, nofin, <<"April fools!">>)
+ end || _ <- lists:seq(1, 100)],
+ _ = case ProxyProtocol of
+ http2 ->
+ %% We already received the gun_tunnel_up at this point for HTTP/2.
+ {error, {stream_error, {stream_error,
+ {tls_alert, {certificate_required, _}}, _}}} = gun:await(ConnPid, TunnelStreamRef);
+ http ->
+ %% The alert will occur after the gun_up message has been sent
+ %% in the case of HTTP/1.1 (when active mode gets enabled).
+ %% But when we are using TLS over TLS the timing is a little
+ %% different and we may get a failure before receiving gun_tunnel_up.
+ case gun:await(ConnPid, TunnelStreamRef) of
+ {up, OriginProtocol} ->
+ {error, {down, {shutdown,
+ {tls_alert, {certificate_required, _}}}}} = gun:await(ConnPid, undefined);
+ {error, {stream_error, {closed,
+ {tls_alert, {certificate_required, _}}}}} ->
+ ok
+ end
+ end
+ end,
+ gun:close(ConnPid).
+
tls_handshake_error_gun_http2_init_retry_0(_) ->
doc("Ensure an early TLS connection close is propagated "
"to the user of the connection."),
diff --git a/test/rfc7540_SUITE.erl b/test/rfc7540_SUITE.erl
index 847449e..2293d6d 100644
--- a/test/rfc7540_SUITE.erl
+++ b/test/rfc7540_SUITE.erl
@@ -110,7 +110,8 @@ do_proxy_parse(<<Len:24, 0:8, _:8, StreamID:32, Payload:Len/binary, Rest/bits>>,
ok ->
do_proxy_parse(Rest, Proxy);
{error, _} ->
- ok
+ %% Wait forever when a connection gets closed. We will exit with the test process.
+ timer:sleep(infinity)
end;
do_proxy_parse(<<Len:24, 1:8, _:8, StreamID:32, ReqHeadersBlock:Len/binary, Rest/bits>>,
Proxy=#proxy{parent=Parent, socket=Socket, transport=Transport,