Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/erlang.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -46,7 +46,7 @@ jobs:
strategy:
matrix:
otp: ["27.2"]
rebar3: ['3.24.0']
rebar3: ['3.25.0']
steps:
- uses: actions/checkout@v4
with:
Expand All @@ -69,7 +69,7 @@ jobs:
strategy:
matrix:
otp: ["27"]
rebar3: ['3.24.0']
rebar3: ['3.25.0']
steps:
- name: Install Erlang and Go
env:
Expand Down Expand Up @@ -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"
Expand Down
16 changes: 15 additions & 1 deletion NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
------------------
Expand Down
4 changes: 2 additions & 2 deletions rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
44 changes: 31 additions & 13 deletions src/hackney.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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} ->
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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} ->
Expand All @@ -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.
Expand All @@ -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) ->
Expand Down Expand Up @@ -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}}
Expand Down
50 changes: 44 additions & 6 deletions src/hackney_conn.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2270,16 +2270,50 @@ 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));
{error, _} = Error ->
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,
Expand Down Expand Up @@ -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} ->
Expand All @@ -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,
Expand Down Expand Up @@ -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.
10 changes: 7 additions & 3 deletions src/hackney_h3.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down Expand Up @@ -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) ->
Expand All @@ -808,14 +808,18 @@ 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.

%%====================================================================
%% 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
Expand Down
23 changes: 13 additions & 10 deletions src/hackney_happy.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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]).


Expand All @@ -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.

Expand All @@ -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.

Expand Down
Loading
Loading