From b586349c8f70a5ada04cd4546fc06ce64126d14a Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 28 May 2026 01:03:35 +0200 Subject: [PATCH 1/8] erlang: support OTP 29 and HTTP/3 server verification Replace the deprecated catch expression with try across the codebase so hackney compiles cleanly on OTP 29, reusing small stop/close helpers instead of repeating the wrapper. Wire hackney's TLS options through to the QUIC connection so HTTP/3 honors the request's insecure option and uses certifi as the default trust store, matching the HTTPS path, now that quic 1.4.4 verifies the server certificate by default. Add OTP 29 to the CI matrix. --- .github/workflows/erlang.yml | 2 +- NEWS.md | 12 ++++++++ src/hackney.erl | 44 +++++++++++++++++++++--------- src/hackney_conn.erl | 53 ++++++++++++++++++++++++++++++++---- src/hackney_h3.erl | 10 +++++-- src/hackney_happy.erl | 23 +++++++++++----- src/hackney_pool.erl | 20 ++++++++------ src/hackney_trace.erl | 22 +++++++++------ src/hackney_ws.erl | 6 ++-- 9 files changed, 142 insertions(+), 50 deletions(-) diff --git a/.github/workflows/erlang.yml b/.github/workflows/erlang.yml index c5101cf7..3e0f8b26 100644 --- a/.github/workflows/erlang.yml +++ b/.github/workflows/erlang.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - otp: ["27.2", "28.0"] + otp: ["27.2", "28.0", "29.0"] rebar3: ['3.24.0'] steps: - uses: actions/checkout@v4 diff --git a/NEWS.md b/NEWS.md index 9c61f5bd..e03a3a5e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -3,6 +3,18 @@ 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 its TLS options through to the QUIC + connection so the request's `insecure` option and CA configuration are + honored, with certifi as the default trust store to match the HTTPS path. + +### Changed + +- Replace the deprecated `catch Expr` form with `try ... catch` so hackney + compiles cleanly on OTP 29. + ### Dependencies - Bump quic to 1.4.4. 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..d49d92ce 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,43 @@ try_h3_connect(Host, Port, Timeout, _ConnectOpts) -> Error end. +%% @private Map hackney's TLS options to the QUIC client verification +%% options. quic >= 1.4.4 verifies the server certificate by default, so an +%% insecure connection must opt out explicitly. The trust store defaults to +%% certifi to match the HTTPS path instead of relying on the OS 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 -> maps:put(verify, verify_peer, h3_ca_opts(SslOpts)) + end. + +%% @private Resolve the CA trust store for an H3 verification. quic only +%% accepts DER-encoded CAs (cacerts), so a cacertfile is decoded here. +h3_ca_opts(SslOpts) -> + case proplists:get_value(cacerts, SslOpts) of + undefined -> + case proplists:get_value(cacertfile, SslOpts) of + undefined -> #{cacerts => certifi:cacerts()}; + File -> #{cacerts => cacertfile_ders(File)} + end; + CACerts -> + #{cacerts => CACerts} + end. + +%% @private Read a PEM cacertfile into a list of DER certificates. A missing +%% or unreadable file yields an empty trust store so verification fails +%% closed rather than silently falling back to another store. +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 +2416,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 +2432,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 +3173,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..438a6f6e 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,26 @@ 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 ?report_debug("DNS error", [{hostname, Hostname} ,{type, Type} ,{error, Else}]), - %% Try fallback on unexpected errors too + %% Try fallback on unexpected results too + fallback_hosts_lookup(Hostname, Type) + catch + Class:Reason -> + ?report_debug("DNS error", [{hostname, Hostname} + ,{type, Type} + ,{error, {Class, Reason}}]), + %% Try fallback on resolver crashes too fallback_hosts_lookup(Hostname, Type) end. @@ -152,10 +158,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..97f19808 100644 --- a/src/hackney_trace.erl +++ b/src/hackney_trace.erl @@ -129,42 +129,46 @@ 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 any failure. +safe(Fun) -> + try Fun() catch _:_ -> ok end. + 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. From 85e5b7f44977a59e95515b1bedb028c7f93123f1 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 28 May 2026 01:32:38 +0200 Subject: [PATCH 2/8] deps: bump h2 to 0.6.1 for OTP 29 0.6.1 replaces the deprecated catch form, so the dependency builds on OTP 29 without warnings_as_errors failing. --- NEWS.md | 1 + rebar.config | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index e03a3a5e..4b4b409f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -18,6 +18,7 @@ ### Dependencies - Bump quic to 1.4.4. +- Bump h2 to 0.6.1. 4.0.2 - 2026-05-25 ------------------ diff --git a/rebar.config b/rebar.config index ea873835..2960cdbf 100644 --- a/rebar.config +++ b/rebar.config @@ -51,7 +51,7 @@ %% Pure Erlang QUIC + HTTP/3 stack {quic, "1.4.4"}, %% Pure Erlang HTTP/2 stack - {h2, "0.6.0"}, + {h2, "0.6.1"}, {idna, "~>7.1.0"}, {mimerl, "~>1.4"}, {certifi, "~>2.16.0"}, From 8dad3cedc1b701d1ee1ba7470c10a5714ec57afb Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 28 May 2026 01:35:28 +0200 Subject: [PATCH 3/8] erlang: clear dialyzer warnings from the try conversion Drop the now-unreachable catch-all in getbyname (try cannot yield the {'EXIT', _} the old catch did) and have the trace safe/1 helper return ok so its result is not an unmatched union. --- src/hackney_happy.erl | 6 ------ src/hackney_trace.erl | 5 +++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/hackney_happy.erl b/src/hackney_happy.erl index 438a6f6e..27425ee7 100644 --- a/src/hackney_happy.erl +++ b/src/hackney_happy.erl @@ -136,12 +136,6 @@ getbyname(Hostname, Type) -> %% 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 -> - ?report_debug("DNS error", [{hostname, Hostname} - ,{type, Type} - ,{error, Else}]), - %% Try fallback on unexpected results too fallback_hosts_lookup(Hostname, Type) catch Class:Reason -> diff --git a/src/hackney_trace.erl b/src/hackney_trace.erl index 97f19808..9c39c184 100644 --- a/src/hackney_trace.erl +++ b/src/hackney_trace.erl @@ -165,9 +165,10 @@ handle_trace(Event, Fd) -> safe(fun() -> print_trace(Fd, Event) end), Fd. -%% @private Run a best-effort trace side effect, ignoring any failure. +%% @private Run a best-effort trace side effect, ignoring its result and any failure. safe(Fun) -> - try Fun() catch _:_ -> ok end. + _ = (try Fun() catch _:_ -> ok end), + ok. print_hackney_trace({Service, Fd}, From 5edfa607b40adf249c86eaeab6db3420485e64a2 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 28 May 2026 01:43:29 +0200 Subject: [PATCH 4/8] ci: bump rebar3 to 3.25.0 for OTP 29 support rebar3 3.24.0 does not run on OTP 29, so setup-beam failed on the new OTP 29 job. 3.25.0 supports OTP 27, 28 and 29. --- .github/workflows/erlang.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/erlang.yml b/.github/workflows/erlang.yml index 3e0f8b26..592bad72 100644 --- a/.github/workflows/erlang.yml +++ b/.github/workflows/erlang.yml @@ -19,7 +19,7 @@ jobs: strategy: matrix: otp: ["27.2", "28.0", "29.0"] - rebar3: ['3.24.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: From ff1a88ce7114d31aea077b289338a7905378e293 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 28 May 2026 01:53:25 +0200 Subject: [PATCH 5/8] h3: do not force certifi as the QUIC trust store Passing certifi's full cacerts list to the QUIC client stalled the handshake and made HTTP/3 connections time out and fall back to TCP. h3_tls_opts now only maps the insecure option and otherwise lets quic apply its default verification, which already works. --- NEWS.md | 5 ++--- src/hackney_conn.erl | 34 +++++----------------------------- 2 files changed, 7 insertions(+), 32 deletions(-) diff --git a/NEWS.md b/NEWS.md index 4b4b409f..bce6f34c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,9 +6,8 @@ ### Security - HTTP/3 now verifies the server certificate. quic 1.4.4 authenticates the - server by default; hackney passes its TLS options through to the QUIC - connection so the request's `insecure` option and CA configuration are - honored, with certifi as the default trust store to match the HTTPS path. + server by default; hackney passes the request's `insecure` option through to + the QUIC connection so verification can still be disabled when needed. ### Changed diff --git a/src/hackney_conn.erl b/src/hackney_conn.erl index d49d92ce..512680a9 100644 --- a/src/hackney_conn.erl +++ b/src/hackney_conn.erl @@ -2280,41 +2280,17 @@ try_h3_connect(Host, Port, Timeout, ConnectOpts) -> Error end. -%% @private Map hackney's TLS options to the QUIC client verification -%% options. quic >= 1.4.4 verifies the server certificate by default, so an -%% insecure connection must opt out explicitly. The trust store defaults to -%% certifi to match the HTTPS path instead of relying on the OS store. +%% @private Map hackney's insecure option to the QUIC client verification. +%% quic >= 1.4.4 verifies the server certificate against its default trust +%% store; an insecure connection must opt out explicitly. When not insecure +%% we pass nothing and let quic apply its default verification. 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 -> maps:put(verify, verify_peer, h3_ca_opts(SslOpts)) - end. - -%% @private Resolve the CA trust store for an H3 verification. quic only -%% accepts DER-encoded CAs (cacerts), so a cacertfile is decoded here. -h3_ca_opts(SslOpts) -> - case proplists:get_value(cacerts, SslOpts) of - undefined -> - case proplists:get_value(cacertfile, SslOpts) of - undefined -> #{cacerts => certifi:cacerts()}; - File -> #{cacerts => cacertfile_ders(File)} - end; - CACerts -> - #{cacerts => CACerts} - end. - -%% @private Read a PEM cacertfile into a list of DER certificates. A missing -%% or unreadable file yields an empty trust store so verification fails -%% closed rather than silently falling back to another store. -cacertfile_ders(File) -> - case file:read_file(File) of - {ok, Pem} -> - [Der || {'Certificate', Der, _} <- public_key:pem_decode(Pem)]; - {error, _} -> - [] + false -> #{} end. %% @private Drive QUIC event loop until connected From 0a1244a7df886d57971653ec32d78c509170ae8c Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 28 May 2026 02:03:18 +0200 Subject: [PATCH 6/8] h3: honor custom CA from ssl_options Pass cacerts/cacertfile from ssl_options through to the QUIC connection so HTTP/3 can use a custom trust store, while the default remains quic's own trust store. Forcing certifi by default broke validation; this only overrides the store when the caller explicitly configures one. --- NEWS.md | 6 ++++-- src/hackney_conn.erl | 31 ++++++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/NEWS.md b/NEWS.md index bce6f34c..53660270 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,8 +6,10 @@ ### Security - HTTP/3 now verifies the server certificate. quic 1.4.4 authenticates the - server by default; hackney passes the request's `insecure` option through to - the QUIC connection so verification can still be disabled when needed. + 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 diff --git a/src/hackney_conn.erl b/src/hackney_conn.erl index 512680a9..57eb918d 100644 --- a/src/hackney_conn.erl +++ b/src/hackney_conn.erl @@ -2280,17 +2280,38 @@ try_h3_connect(Host, Port, Timeout, ConnectOpts) -> Error end. -%% @private Map hackney's insecure option to the QUIC client verification. -%% quic >= 1.4.4 verifies the server certificate against its default trust -%% store; an insecure connection must opt out explicitly. When not insecure -%% we pass nothing and let quic apply its default verification. +%% @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 -> #{} + 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 From 8315f8e5d9691a9a42783e5dafae0971e00f91b2 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 28 May 2026 02:17:55 +0200 Subject: [PATCH 7/8] ci: install ca_root_nss on FreeBSD for H3 verification quic 1.4.4 verifies the server certificate by default, which needs an OS trust store. The FreeBSD VM had none, so HTTP/3 tests against external servers failed; ca_root_nss provides the CA bundle. --- .github/workflows/erlang.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/erlang.yml b/.github/workflows/erlang.yml index 592bad72..cf82e040 100644 --- a/.github/workflows/erlang.yml +++ b/.github/workflows/erlang.yml @@ -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" From d543f12ad50ab0616f55f4e0189cdab96b72019b Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 28 May 2026 07:18:22 +0200 Subject: [PATCH 8/8] deps: bump quic to 1.4.5 1.4.5 fixes verify_chain to handle servers that send extra or cross-signed certs above the anchored intermediate, so HTTP/3 verification works against certifi and the FreeBSD NSS trust store. --- NEWS.md | 2 +- rebar.config | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index 53660270..ce19f2ea 100644 --- a/NEWS.md +++ b/NEWS.md @@ -18,7 +18,7 @@ ### 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 2960cdbf..d5ef01bc 100644 --- a/rebar.config +++ b/rebar.config @@ -49,7 +49,7 @@ {deps, [ %% Pure Erlang QUIC + HTTP/3 stack - {quic, "1.4.4"}, + {quic, "1.4.5"}, %% Pure Erlang HTTP/2 stack {h2, "0.6.1"}, {idna, "~>7.1.0"},