diff --git a/.github/workflows/erlang.yml b/.github/workflows/erlang.yml index c5101cf7..cf82e040 100644 --- a/.github/workflows/erlang.yml +++ b/.github/workflows/erlang.yml @@ -18,8 +18,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - otp: ["27.2", "28.0"] - rebar3: ['3.24.0'] + otp: ["27.2", "28.0", "29.0"] + rebar3: ['3.25.0'] steps: - uses: actions/checkout@v4 with: @@ -46,7 +46,7 @@ jobs: strategy: matrix: otp: ["27.2"] - rebar3: ['3.24.0'] + rebar3: ['3.25.0'] steps: - uses: actions/checkout@v4 with: @@ -69,7 +69,7 @@ jobs: strategy: matrix: otp: ["27"] - rebar3: ['3.24.0'] + rebar3: ['3.25.0'] steps: - name: Install Erlang and Go env: @@ -101,7 +101,7 @@ jobs: release: "14.2" usesh: true prepare: | - pkg install -y pcre2 erlang-runtime28 rebar3 cmake git gmake go llvm18 + pkg install -y pcre2 erlang-runtime28 rebar3 cmake git gmake go llvm18 ca_root_nss run: | # Ensure Erlang 28 is in PATH export PATH="/usr/local/lib/erlang28/bin:$PATH" diff --git a/NEWS.md b/NEWS.md index 9c61f5bd..ce19f2ea 100644 --- a/NEWS.md +++ b/NEWS.md @@ -3,9 +3,23 @@ 4.0.3 - 2026-05-28 ------------------ +### Security + +- HTTP/3 now verifies the server certificate. quic 1.4.4 authenticates the + server by default; hackney passes the request's `insecure` option and any + configured CA (`cacerts`/`cacertfile` in `ssl_options`) through to the QUIC + connection, so verification can be disabled or pointed at a custom trust + store. Without a configured CA, quic uses its default trust store. + +### Changed + +- Replace the deprecated `catch Expr` form with `try ... catch` so hackney + compiles cleanly on OTP 29. + ### Dependencies -- Bump quic to 1.4.4. +- Bump quic to 1.4.5. +- Bump h2 to 0.6.1. 4.0.2 - 2026-05-25 ------------------ diff --git a/rebar.config b/rebar.config index ea873835..d5ef01bc 100644 --- a/rebar.config +++ b/rebar.config @@ -49,9 +49,9 @@ {deps, [ %% Pure Erlang QUIC + HTTP/3 stack - {quic, "1.4.4"}, + {quic, "1.4.5"}, %% Pure Erlang HTTP/2 stack - {h2, "0.6.0"}, + {h2, "0.6.1"}, {idna, "~>7.1.0"}, {mimerl, "~>1.4"}, {certifi, "~>2.16.0"}, diff --git a/src/hackney.erl b/src/hackney.erl index 96442aa8..b920e528 100644 --- a/src/hackney.erl +++ b/src/hackney.erl @@ -122,7 +122,7 @@ connect_direct(Transport, Host, Port, Options) -> ok -> {ok, ConnPid}; {error, Reason} -> - catch hackney_conn:stop(ConnPid), + stop_conn(ConnPid), {error, Reason} end; {error, Reason} -> @@ -235,19 +235,24 @@ try_new_h3_connection(Host, Port, Transport, Options, PoolHandler) -> case hackney_conn:connect(ConnPid, ConnectTimeout) of ok -> %% Verify it's HTTP/3 - case catch hackney_conn:get_protocol(ConnPid) of + try hackney_conn:get_protocol(ConnPid) of http3 -> %% Register for multiplexing PoolHandler:register_h3(Host, Port, Transport, ConnPid, Options), {ok, ConnPid}; _ -> - %% Not HTTP/3 or connection terminated, close and fail - catch hackney_conn:stop(ConnPid), + %% Not HTTP/3, close and fail + stop_conn(ConnPid), + hackney_altsvc:mark_h3_blocked(Host, Port), + false + catch + _:_ -> + %% Connection terminated before we could check hackney_altsvc:mark_h3_blocked(Host, Port), false end; {error, _Reason} -> - catch hackney_conn:stop(ConnPid), + stop_conn(ConnPid), hackney_altsvc:mark_h3_blocked(Host, Port), false end; @@ -277,7 +282,7 @@ connect_pool_new(Transport, Host, Port, Options, PoolHandler) -> {error, Reason} -> %% Upgrade failed - release slot and close connection hackney_load_regulation:release(Host, Port), - catch hackney_conn:stop(ConnPid), + stop_conn(ConnPid), {error, Reason} end; {error, Reason} -> @@ -290,17 +295,18 @@ connect_pool_new(Transport, Host, Port, Options, PoolHandler) -> end. %% @private Register HTTP/2 connection for multiplexing if applicable -%% Uses catch to handle race condition where connection terminates before call +%% Wrapped in try to handle a race where the connection terminates before the call maybe_register_h2(ConnPid, Host, Port, Transport, Options, PoolHandler) -> - case catch hackney_conn:get_protocol(ConnPid) of + try hackney_conn:get_protocol(ConnPid) of http2 -> %% HTTP/2 negotiated - register for connection sharing PoolHandler:register_h2(Host, Port, Transport, ConnPid, Options); http1 -> ok; http3 -> - ok; - {'EXIT', _} -> + ok + catch + _:_ -> %% Connection terminated before we could check - ignore ok end. @@ -315,18 +321,30 @@ maybe_upgrade_ssl(hackney_ssl, ConnPid, Host, Options) -> _ -> [{protocols, Protocols} | SslOpts] end, %% Check if connection is already SSL (e.g., reused SSL connection) - case catch hackney_conn:is_upgraded_ssl(ConnPid) of + try hackney_conn:is_upgraded_ssl(ConnPid) of true -> %% Already SSL, no upgrade needed ok; _ -> %% Upgrade TCP to SSL with ALPN hackney_conn:upgrade_to_ssl(ConnPid, [{server_name_indication, Host} | SslOpts2]) + catch + _:_ -> + %% Connection terminated, attempt upgrade anyway + hackney_conn:upgrade_to_ssl(ConnPid, [{server_name_indication, Host} | SslOpts2]) end; maybe_upgrade_ssl(_, _ConnPid, _Host, _Options) -> %% Not SSL, no upgrade needed ok. +%% @private Stop a connection, tolerating an already-dead process. +stop_conn(ConnPid) -> + try hackney_conn:stop(ConnPid) catch _:_ -> ok end. + +%% @private Signal the websocket process to shut down, ignoring errors. +shutdown_ws(WsPid) -> + try exit(WsPid, shutdown) catch _:_ -> ok end. + %% @doc Close a connection. -spec close(conn()) -> ok. close(ConnPid) when is_pid(ConnPid) -> @@ -678,11 +696,11 @@ ws_connect(URL, Options) when is_binary(URL) orelse is_list(URL) -> ok -> {ok, WsPid}; {error, Reason} -> - catch exit(WsPid, shutdown), + shutdown_ws(WsPid), {error, Reason} catch exit:{timeout, _} -> - catch exit(WsPid, shutdown), + shutdown_ws(WsPid), {error, connect_timeout}; exit:{noproc, _} -> {error, {ws_process_died, noproc}} diff --git a/src/hackney_conn.erl b/src/hackney_conn.erl index 4a81d6d1..57eb918d 100644 --- a/src/hackney_conn.erl +++ b/src/hackney_conn.erl @@ -570,7 +570,7 @@ terminate(_Reason, _State, #conn_data{socket = Socket, transport = Transport, undefined -> ok; _ -> erlang:demonitor(H2Mon, [flush]) end, - catch h2_connection:close(H2Conn) + close_h2(H2Conn) end, %% Close HTTP/3 connection if present case H3Conn of @@ -2270,9 +2270,9 @@ skip_response_body(Data) -> %% @private Try HTTP/3 connection via QUIC %% lsquic handles its own UDP socket creation and DNS resolution. -try_h3_connect(Host, Port, Timeout, _ConnectOpts) -> +try_h3_connect(Host, Port, Timeout, ConnectOpts) -> HostBin = if is_list(Host) -> list_to_binary(Host); true -> Host end, - case hackney_h3:connect(HostBin, Port, #{}, self()) of + case hackney_h3:connect(HostBin, Port, h3_tls_opts(ConnectOpts), self()) of {ok, ConnRef} -> %% Drive event loop until connected wait_h3_connected(ConnRef, Timeout, erlang:monotonic_time(millisecond)); @@ -2280,6 +2280,40 @@ try_h3_connect(Host, Port, Timeout, _ConnectOpts) -> Error end. +%% @private Map hackney's TLS options to the QUIC client verification. +%% quic >= 1.4.4 verifies the server certificate. An insecure connection opts +%% out; an explicitly configured CA (cacerts/cacertfile) is used as the trust +%% store; otherwise quic verifies against its own default (OS) trust store. +h3_tls_opts(ConnectOpts) -> + SslOpts = proplists:get_value(ssl_options, ConnectOpts, []), + Insecure = proplists:get_value(insecure, ConnectOpts, + proplists:get_value(insecure, SslOpts, false)), + case Insecure of + true -> #{verify => verify_none}; + false -> h3_ca_opts(SslOpts) + end. + +%% @private Use an explicitly configured CA as the H3 trust store. quic only +%% accepts DER cacerts, so a cacertfile is decoded here. With no CA configured +%% the map is empty and quic falls back to its default trust store. +h3_ca_opts(SslOpts) -> + case proplists:get_value(cacerts, SslOpts) of + undefined -> + case proplists:get_value(cacertfile, SslOpts) of + undefined -> #{}; + File -> #{cacerts => cacertfile_ders(File)} + end; + CACerts -> + #{cacerts => CACerts} + end. + +%% @private Read a PEM cacertfile into a list of DER certificates. +cacertfile_ders(File) -> + case file:read_file(File) of + {ok, Pem} -> [Der || {'Certificate', Der, _} <- public_key:pem_decode(Pem)]; + {error, _} -> [] + end. + %% @private Drive QUIC event loop until connected wait_h3_connected(ConnRef, Timeout, StartTime) -> Elapsed = erlang:monotonic_time(millisecond) - StartTime, @@ -2379,11 +2413,11 @@ start_h2_connection(Socket, Data, From, Origin) -> [CancelIdle, {reply, From, ok}]} end; {error, WaitErr} -> - catch h2_connection:close(H2Conn), + close_h2(H2Conn), h2_start_failure(Origin, From, WaitErr) end; {error, ActivateErr} -> - catch h2_connection:close(H2Conn), + close_h2(H2Conn), h2_start_failure(Origin, From, ActivateErr) end; {error, Reason} -> @@ -2395,6 +2429,10 @@ h2_start_failure(first_connect, From, Reason) -> h2_start_failure(after_upgrade, From, Reason) -> {keep_state_and_data, [{reply, From, {error, Reason}}]}. +%% @private Close an HTTP/2 connection, tolerating an already-closed one. +close_h2(H2Conn) -> + try h2_connection:close(H2Conn) catch _:_ -> ok end. + %% @private Send an HTTP/2 request via the h2 library. do_h2_request(From, Method, Path, Headers, Body, Data) -> do_h2_send(From, Method, Path, Headers, Body, @@ -3132,7 +3170,7 @@ handle_h3_termination(Error, Data) -> %% directives are honored even on h3 responses. maybe_record_altsvc(Headers, #conn_data{host = Host, port = Port}) when is_list(Headers) -> - _ = catch hackney_altsvc:parse_and_cache(Host, Port, Headers), + _ = (try hackney_altsvc:parse_and_cache(Host, Port, Headers) catch _:_ -> ok end), ok; maybe_record_altsvc(_Headers, _Data) -> ok. diff --git a/src/hackney_h3.erl b/src/hackney_h3.erl index 9e6d1963..39172cbb 100644 --- a/src/hackney_h3.erl +++ b/src/hackney_h3.erl @@ -744,7 +744,7 @@ handle_call(_Request, _From, State) -> {reply, {error, unknown_request}, State}. handle_cast({close, _Reason}, #state{h3_conn = Conn} = State) -> - catch quic_h3:close(Conn), + close_h3(Conn), {stop, normal, State}; handle_cast(_Msg, State) -> {noreply, State}. @@ -795,7 +795,7 @@ handle_info({quic_h3, Conn, {error, Code, Reason}}, handle_info({'DOWN', MonRef, process, _Pid, _Reason}, #state{owner_mon = MonRef, h3_conn = Conn} = State) -> - catch quic_h3:close(Conn), + close_h3(Conn), {stop, normal, State}; handle_info(_Info, State) -> @@ -808,7 +808,7 @@ terminate(_Reason, #state{conn_ref = Ref, h3_conn = Conn}) -> end, case Conn of undefined -> ok; - _ -> catch quic_h3:close(Conn) + _ -> close_h3(Conn) end, ok. @@ -816,6 +816,10 @@ terminate(_Reason, #state{conn_ref = Ref, h3_conn = Conn}) -> %% Internal adapter helpers %%==================================================================== +%% @private Close a QUIC/HTTP3 connection, tolerating an already-closed one. +close_h3(Conn) -> + try quic_h3:close(Conn) catch _:_ -> ok end. + build_h3_opts(Host, Opts) -> HostStr = binary_to_list(Host), Verify = case maps:get(insecure_skip_verify, Opts, false) of diff --git a/src/hackney_happy.erl b/src/hackney_happy.erl index b155043d..27425ee7 100644 --- a/src/hackney_happy.erl +++ b/src/hackney_happy.erl @@ -102,7 +102,7 @@ do_connect_2(Pid, MRef, Timeout) -> end. connect_gc(Pid, MRef) -> - catch exit(Pid, normal), + (try exit(Pid, normal) catch _:_ -> ok end), erlang:demonitor(MRef, [flush]). @@ -129,20 +129,20 @@ getaddrs(Name) -> getbyname(Hostname, Type) -> %% First try DNS resolution using inet_res:getbyname - case (catch inet_res:getbyname(Hostname, Type)) of + try inet_res:getbyname(Hostname, Type) of {'ok', #hostent{h_addr_list=AddrList}} -> AddrList; - {error, _Reason} -> + {error, _Reason} -> %% DNS failed, try fallback to /etc/hosts using inet:gethostbyname %% This fixes NXDOMAIN errors in Docker Compose environments where %% hostnames are resolved via /etc/hosts entries - fallback_hosts_lookup(Hostname, Type); - Else -> - %% ERLANG 22 has an issue when g matching some DNS server messages + fallback_hosts_lookup(Hostname, Type) + catch + Class:Reason -> ?report_debug("DNS error", [{hostname, Hostname} ,{type, Type} - ,{error, Else}]), - %% Try fallback on unexpected errors too + ,{error, {Class, Reason}}]), + %% Try fallback on resolver crashes too fallback_hosts_lookup(Hostname, Type) end. @@ -152,10 +152,13 @@ fallback_hosts_lookup(Hostname, Type) -> a -> inet; aaaa -> inet6 end, - case (catch inet:gethostbyname(Hostname, InetType)) of + try inet:gethostbyname(Hostname, InetType) of {'ok', #hostent{h_addr_list=AddrList}} -> AddrList; - _ -> + _ -> + [] + catch + _:_ -> [] end. diff --git a/src/hackney_pool.erl b/src/hackney_pool.erl index c1aaaf93..1113e6b2 100644 --- a/src/hackney_pool.erl +++ b/src/hackney_pool.erl @@ -677,7 +677,7 @@ terminate(_Reason, #state{available=Available, maps:foreach( fun(_Key, Pids) -> lists:foreach(fun(Pid) -> - catch hackney_conn:stop(Pid) + stop_conn(Pid) end, Pids) end, Available @@ -686,7 +686,7 @@ terminate(_Reason, #state{available=Available, %% Stop all HTTP/2 connections maps:foreach( fun(_Key, Pid) -> - catch hackney_conn:stop(Pid) + stop_conn(Pid) end, H2Conns ), @@ -694,7 +694,7 @@ terminate(_Reason, #state{available=Available, %% Stop all HTTP/3 connections maps:foreach( fun(_Key, Pid) -> - catch hackney_conn:stop(Pid) + stop_conn(Pid) end, H3Conns ), @@ -711,6 +711,10 @@ connection_key(Host0, Port, Transport) -> Host = string:lowercase(Host0), {Host, Port, Transport}. +%% @private Stop a connection, tolerating an already-dead process. +stop_conn(Pid) -> + try hackney_conn:stop(Pid) catch _:_ -> ok end. + find_available(Key, Available) -> case maps:find(Key, Available) of {ok, [Pid | Rest]} -> @@ -772,7 +776,7 @@ start_connection(Key, Owner, Opts, State) -> PidMonitors = maps:put(Pid, MonRef, State#state.pid_monitors), {ok, Pid, State#state{pid_monitors=PidMonitors}}; {error, Reason} -> - catch hackney_conn:stop(Pid), + stop_conn(Pid), {error, Reason} end; {error, Reason} -> @@ -798,12 +802,12 @@ do_checkin(Pid, State) -> %% Check if this connection should not be reused: %% - SSL upgraded connections (security requirement) %% - Proxy tunnel connections (SOCKS5, HTTP CONNECT - issue #283) - ShouldClose = (catch hackney_conn:is_upgraded_ssl(Pid)) =:= true orelse - (catch hackney_conn:is_no_reuse(Pid)) =:= true, + ShouldClose = (try hackney_conn:is_upgraded_ssl(Pid) catch _:_ -> false end) =:= true orelse + (try hackney_conn:is_no_reuse(Pid) catch _:_ -> false end) =:= true, case ShouldClose of true -> %% Connection should not be reused - close it - catch hackney_conn:stop(Pid), + stop_conn(Pid), %% Remove monitor if exists PidMonitors2 = case maps:take(Pid, PidMonitors) of {MonRef, PM} -> @@ -1038,7 +1042,7 @@ prewarm_connections(PoolPid, Host, Port, Count, IdleTimeout) -> %% Checkin the new connection to the pool gen_server:cast(PoolPid, {prewarm_checkin, Pid, {Host, Port, hackney_tcp}}); {error, _Reason} -> - catch hackney_conn:stop(Pid) + stop_conn(Pid) end; {error, _Reason} -> ok diff --git a/src/hackney_trace.erl b/src/hackney_trace.erl index 77642a5d..9c39c184 100644 --- a/src/hackney_trace.erl +++ b/src/hackney_trace.erl @@ -129,42 +129,47 @@ handle_trace({trace_ts, _Who, call, [_Sev, "stop trace", stop_trace, [stop_trace]]}, Timestamp}, {_, standard_io} = Fd) -> - (catch io:format(standard_io, "stop trace at ~s~n", [format_timestamp(Timestamp)])), + safe(fun() -> io:format(standard_io, "stop trace at ~s~n", [format_timestamp(Timestamp)]) end), Fd; handle_trace({trace_ts, _Who, call, {?MODULE, report_event, [_Sev, "stop trace", stop_trace, [stop_trace]]}, Timestamp}, standard_io = Fd) -> - (catch io:format(Fd, "stop trace at ~s~n", [format_timestamp(Timestamp)])), + safe(fun() -> io:format(Fd, "stop trace at ~s~n", [format_timestamp(Timestamp)]) end), Fd; handle_trace({trace_ts, _Who, call, {?MODULE, report_event, [_Sev, "stop trace", stop_trace, [stop_trace]]}, Timestamp}, {_Service, Fd}) -> - (catch io:format(Fd, "stop trace at ~s~n", [format_timestamp(Timestamp)])), - (catch file:close(Fd)), + safe(fun() -> io:format(Fd, "stop trace at ~s~n", [format_timestamp(Timestamp)]) end), + safe(fun() -> file:close(Fd) end), closed_file; handle_trace({trace_ts, _Who, call, {?MODULE, report_event, [_Sev, "stop trace", stop_trace, [stop_trace]]}, Timestamp}, Fd) -> - (catch io:format(Fd, "stop trace at ~s~n", [format_timestamp(Timestamp)])), - (catch file:close(Fd)), + safe(fun() -> io:format(Fd, "stop trace at ~s~n", [format_timestamp(Timestamp)]) end), + safe(fun() -> file:close(Fd) end), closed_file; handle_trace({trace_ts, Who, call, {?MODULE, report_event, [Sev, Label, Service, Content]}, Timestamp}, Fd) -> - (catch print_hackney_trace(Fd, Sev, Timestamp, Who, - Label, Service, Content)), + safe(fun() -> print_hackney_trace(Fd, Sev, Timestamp, Who, + Label, Service, Content) end), Fd; handle_trace(Event, Fd) -> - (catch print_trace(Fd, Event)), + safe(fun() -> print_trace(Fd, Event) end), Fd. +%% @private Run a best-effort trace side effect, ignoring its result and any failure. +safe(Fun) -> + _ = (try Fun() catch _:_ -> ok end), + ok. + print_hackney_trace({Service, Fd}, Sev, Timestamp, Who, Label, Service, Content) -> diff --git a/src/hackney_ws.erl b/src/hackney_ws.erl index 0da27024..12759a5c 100644 --- a/src/hackney_ws.erl +++ b/src/hackney_ws.erl @@ -241,8 +241,8 @@ init([Owner, Opts]) -> %% @private terminate(_Reason, _State, #ws_data{socket = undefined}) -> ok; -terminate(_Reason, _State, #ws_data{socket = Socket, transport = Transport}) -> - catch Transport:close(Socket), +terminate(_Reason, _State, #ws_data{} = WsData) -> + close_socket(WsData), ok. %% @private @@ -1073,5 +1073,5 @@ set_socket_active(#ws_data{socket = Socket}, Mode) -> close_socket(#ws_data{socket = undefined}) -> ok; close_socket(#ws_data{socket = Socket, transport = Transport}) -> - catch Transport:close(Socket), + try Transport:close(Socket) catch _:_ -> ok end, ok.