diff --git a/NEWS.md b/NEWS.md index 98bba794..f7ee1820 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,47 @@ # NEWS +4.2.0 - 2026-06-03 +------------------ + +### Added + +- IPv6 for HTTP/3. The `family` connect option (`inet` | `inet6`) is forwarded + to QUIC, which resolves DNS and races addresses with Happy Eyeballs (RFC + 8305). IPv6 literals such as `https://[::1]/` work too. `family` may be set in + `connect_options` or `ssl_options`. +- 0-RTT and session resumption for HTTP/3. The server's session ticket is cached + in the pool per `{host, port, transport}` and replayed on the next + connection; a bodyless one-shot request is then sent as 0-RTT, otherwise the + ticket gives a resumed handshake. Enabled by default and controlled by the + `zero_rtt` option, with an explicit `session_ticket` taking precedence over + the cache. New `hackney_h3` helpers: `early_data_accepted/1`, + `get_session_ticket/1`, `wait_session_ticket/2`. + +### Fixed + +- Recover from an expired cross-signed root instead of failing the handshake + (e.g. Let's Encrypt's ISRG Root X2 cross-signed by the expired ISRG Root X1). + For HTTP/1.1 and HTTP/2 the verification function rewrites `cert_expired` to + `root_cert_expired` so OTP's cross-sign recovery runs; for HTTP/3 and + WebTransport the same recovery is in quic 1.6.2. A genuinely expired leaf or + intermediate still fails, and partial chains keep working. +- HTTP/3 connections from the pool now apply `ssl_options` (`cacerts`, + `insecure`) that previously did not reach the QUIC layer. +- A pooled connection that stops between checkout and the request call no + longer leaks `exit:{normal, _}` (or `exit:noproc`) to the caller. The + request, body and streaming calls now return `{error, closed}` instead + (issue #861). +- A proxy host given as an atom (e.g. `localhost`) or a binary is accepted + again for `{ProxyHost, Port}`, `{connect, ...}` and `{socks5, ...}` proxy + options, instead of being silently ignored. Regression from a too-strict + `is_list/1` guard (issue #858). + +### Dependencies + +- quic 1.4.5 -> 1.6.2. +- h2 0.6.1 -> 0.8.0. +- webtransport 0.2.6 -> 0.3.0. + 4.1.0 - 2026-05-29 ------------------ diff --git a/src/hackney.app.src b/src/hackney.app.src index eac6d527..4e426cf0 100644 --- a/src/hackney.app.src +++ b/src/hackney.app.src @@ -4,7 +4,7 @@ {application, hackney, [ {description, "Simple HTTP client with HTTP/1.1, HTTP/2, and HTTP/3 support"}, - {vsn, "4.1.0"}, + {vsn, "4.2.0"}, {registered, [hackney_pool]}, {applications, [kernel, stdlib, diff --git a/src/hackney.erl b/src/hackney.erl index 825441c2..8333bf64 100644 --- a/src/hackney.erl +++ b/src/hackney.erl @@ -1705,17 +1705,17 @@ get_proxy_config(Scheme, TargetHost, Options) -> false; ProxyUrl when is_binary(ProxyUrl); is_list(ProxyUrl) -> parse_proxy_option(ProxyUrl, Scheme, Options); - {ProxyHost, ProxyPort} when is_list(ProxyHost), is_integer(ProxyPort) -> + {ProxyHost, ProxyPort} when (is_list(ProxyHost) orelse is_atom(ProxyHost) orelse is_binary(ProxyHost)), is_integer(ProxyPort) -> %% Simple tuple: use HTTP proxy for http, CONNECT for https ProxyAuth = proplists:get_value(proxy_auth, Options), ProxyTransport = proplists:get_value(proxy_transport, Options, tcp), - {proxy_type_for_scheme(Scheme), ProxyHost, ProxyPort, ProxyAuth, ProxyTransport}; - {connect, ProxyHost, ProxyPort} when is_list(ProxyHost), is_integer(ProxyPort) -> + {proxy_type_for_scheme(Scheme), normalize_proxy_host(ProxyHost), ProxyPort, ProxyAuth, ProxyTransport}; + {connect, ProxyHost, ProxyPort} when (is_list(ProxyHost) orelse is_atom(ProxyHost) orelse is_binary(ProxyHost)), is_integer(ProxyPort) -> %% Explicit CONNECT tunnel ProxyAuth = proplists:get_value(proxy_auth, Options), ProxyTransport = proplists:get_value(proxy_transport, Options, tcp), - {connect, ProxyHost, ProxyPort, ProxyAuth, ProxyTransport}; - {socks5, ProxyHost, ProxyPort} when is_list(ProxyHost), is_integer(ProxyPort) -> + {connect, normalize_proxy_host(ProxyHost), ProxyPort, ProxyAuth, ProxyTransport}; + {socks5, ProxyHost, ProxyPort} when (is_list(ProxyHost) orelse is_atom(ProxyHost) orelse is_binary(ProxyHost)), is_integer(ProxyPort) -> %% SOCKS5 proxy User = proplists:get_value(socks5_user, Options), Pass = proplists:get_value(socks5_pass, Options, <<>>), @@ -1724,11 +1724,18 @@ get_proxy_config(Scheme, TargetHost, Options) -> _ -> {User, Pass} end, ProxyTransport = proplists:get_value(proxy_transport, Options, tcp), - {socks5, ProxyHost, ProxyPort, Auth, ProxyTransport}; + {socks5, normalize_proxy_host(ProxyHost), ProxyPort, Auth, ProxyTransport}; _ -> false end. +%% @private Accept a proxy host given as a string, a binary or an atom +%% (e.g. `localhost'), as hackney 1.x did, and normalise it to a string. +%% A new is_list/1 guard regressed atom hosts to a silent fall-through (#858). +normalize_proxy_host(H) when is_list(H) -> H; +normalize_proxy_host(H) when is_atom(H) -> atom_to_list(H); +normalize_proxy_host(H) when is_binary(H) -> binary_to_list(H). + %% Parse proxy URL and determine type based on target scheme parse_proxy_option(ProxyUrl, Scheme, Options) -> case parse_proxy_url(ProxyUrl) of diff --git a/src/hackney_conn.erl b/src/hackney_conn.erl index 041ed4f4..9d551d64 100644 --- a/src/hackney_conn.erl +++ b/src/hackney_conn.erl @@ -271,10 +271,31 @@ request(Pid, Method, Path, Headers, Body, Timeout) -> {ok, integer(), list()} | {ok, integer(), list(), binary()} | {error, term()}. request(Pid, Method, Path, Headers, Body, Timeout, ReqOpts) -> case valid_request_target(Path) of - ok -> gen_statem:call(Pid, {request, Method, Path, Headers, Body, ReqOpts}, Timeout); + ok -> safe_call(Pid, {request, Method, Path, Headers, Body, ReqOpts}, Timeout); Err -> Err end. +%% @private gen_statem:call that converts a callee which has already stopped, +%% or stops while the call is in flight, into `{error, closed}' instead of +%% letting `exit:{normal, _}' / `exit:noproc' reach the caller. A pooled +%% connection can stop between checkout and the call (issue #861); the brief +%% linger in the `closed' state narrows the window but cannot close it. Other +%% exits (e.g. timeout) propagate unchanged. +safe_call(Pid, Msg) -> + safe_call(Pid, Msg, infinity). + +safe_call(Pid, Msg, Timeout) -> + try + gen_statem:call(Pid, Msg, Timeout) + catch + exit:noproc -> {error, closed}; + exit:{noproc, _} -> {error, closed}; + exit:normal -> {error, closed}; + exit:{normal, _} -> {error, closed}; + exit:shutdown -> {error, closed}; + exit:{shutdown, _} -> {error, closed} + end. + %% @private GHSA-j9wq: the request target (path + query) is written verbatim %% into the HTTP/1.1 request line and the HTTP/2 / HTTP/3 :path pseudo-header. %% Raw CR, LF or NUL bytes let a caller-controlled URL inject extra header @@ -297,7 +318,7 @@ valid_request_target(_) -> {ok, integer(), list()} | {error, term()}. request_streaming(Pid, Method, Path, Headers, Body) -> case valid_request_target(Path) of - ok -> gen_statem:call(Pid, {request_streaming, Method, Path, Headers, Body}, infinity); + ok -> safe_call(Pid, {request_streaming, Method, Path, Headers, Body}, infinity); Err -> Err end. @@ -307,24 +328,24 @@ request_streaming(Pid, Method, Path, Headers, Body) -> -spec send_request_headers(pid(), binary(), binary(), list()) -> ok | {error, term()}. send_request_headers(Pid, Method, Path, Headers) -> case valid_request_target(Path) of - ok -> gen_statem:call(Pid, {send_headers, Method, Path, Headers}, infinity); + ok -> safe_call(Pid, {send_headers, Method, Path, Headers}, infinity); Err -> Err end. %% @doc Send a chunk of the request body. -spec send_body_chunk(pid(), iodata()) -> ok | {error, term()}. send_body_chunk(Pid, Data) -> - gen_statem:call(Pid, {send_body_chunk, Data}, infinity). + safe_call(Pid, {send_body_chunk, Data}, infinity). %% @doc Finish sending the request body. -spec finish_send_body(pid()) -> ok | {error, term()}. finish_send_body(Pid) -> - gen_statem:call(Pid, finish_send_body, infinity). + safe_call(Pid, finish_send_body, infinity). %% @doc Start receiving the response after sending the full body. -spec start_response(pid()) -> {ok, integer(), list(), pid()} | {error, term()}. start_response(Pid) -> - gen_statem:call(Pid, start_response, infinity). + safe_call(Pid, start_response, infinity). %% @doc Get the full response body. -spec body(pid()) -> {ok, binary()} | {error, term()}. @@ -333,13 +354,13 @@ body(Pid) -> -spec body(pid(), timeout()) -> {ok, binary()} | {error, term()}. body(Pid, Timeout) -> - gen_statem:call(Pid, body, Timeout). + safe_call(Pid, body, Timeout). %% @doc Stream the response body in chunks. %% Returns {ok, Data} for each chunk, {done, Pid} when complete. -spec stream_body(pid()) -> {ok, binary()} | done | {error, term()}. stream_body(Pid) -> - gen_statem:call(Pid, stream_body). + safe_call(Pid, stream_body). %% @doc Send an HTTP request asynchronously. %% Returns {ok, Ref} immediately. Response is sent as messages: @@ -366,7 +387,7 @@ request_async(Pid, Method, Path, Headers, Body, AsyncMode, StreamTo) -> {ok, pid()} | {error, term()}. request_async(Pid, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect) -> case valid_request_target(Path) of - ok -> gen_statem:call(Pid, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect}); + ok -> safe_call(Pid, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect}); Err -> Err end. @@ -374,7 +395,7 @@ request_async(Pid, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedir {ok, pid()} | {error, term()}. request_async(Pid, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, ReqOpts) -> case valid_request_target(Path) of - ok -> gen_statem:call(Pid, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, ReqOpts}); + ok -> safe_call(Pid, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, ReqOpts}); Err -> Err end. diff --git a/test/hackney_conn_tests.erl b/test/hackney_conn_tests.erl index be1b0505..202d6609 100644 --- a/test/hackney_conn_tests.erl +++ b/test/hackney_conn_tests.erl @@ -32,7 +32,11 @@ hackney_conn_test_() -> {"set_owner on closed connection returns invalid_state (#850)", fun test_set_owner_closed_returns_error/0}, {"set_owner_async stops a closed pooled connection (#850)", - fun test_set_owner_async_closed_pooled_stops/0} + fun test_set_owner_async_closed_pooled_stops/0}, + {"request on a dead connection returns {error, closed} (#861)", + fun test_request_dead_conn_returns_error/0}, + {"body on a dead connection returns {error, closed} (#861)", + fun test_body_dead_conn_returns_error/0} ]}. %% Integration tests - use embedded Cowboy server @@ -264,6 +268,24 @@ test_set_owner_async_closed_pooled_stops() -> ?assertNot(is_process_alive(Pid)), gen_tcp:close(ListenSock). +%% #861: a pooled connection can stop between checkout and the call, so a +%% request to an already-dead connection must return {error, closed} rather +%% than letting exit:{normal,_}/noproc crash the caller. +test_request_dead_conn_returns_error() -> + Pid = dead_conn_pid(), + ?assertEqual({error, closed}, + hackney_conn:request(Pid, <<"GET">>, <<"/">>, [], <<>>)). + +test_body_dead_conn_returns_error() -> + Pid = dead_conn_pid(), + ?assertEqual({error, closed}, hackney_conn:body(Pid)). + +%% A pid that has already terminated normally. +dead_conn_pid() -> + {Pid, Ref} = spawn_monitor(fun() -> ok end), + receive {'DOWN', Ref, process, Pid, _} -> ok after 1000 -> ok end, + Pid. + %% Start a hackney_conn already in `connected` state via a pre-established %% local socket pair (no server needed). Extra opts are merged in. connected_conn(Extra) -> diff --git a/test/hackney_proxy_tests.erl b/test/hackney_proxy_tests.erl index a970b49a..d5c2dc2f 100644 --- a/test/hackney_proxy_tests.erl +++ b/test/hackney_proxy_tests.erl @@ -258,6 +258,21 @@ get_proxy_config_test_() -> ]), ?assertEqual({socks5, "socks.local", 1080, {<<"user">>, <<"pass">>}, tcp}, Result) end}, + {"SOCKS5 tuple accepts an atom host (#858)", + fun() -> + Result = hackney:get_proxy_config(http, "example.com", [{proxy, {socks5, localhost, 1080}}]), + ?assertEqual({socks5, "localhost", 1080, undefined, tcp}, Result) + end}, + {"SOCKS5 tuple accepts a binary host (#858)", + fun() -> + Result = hackney:get_proxy_config(http, "example.com", [{proxy, {socks5, <<"socks.local">>, 1080}}]), + ?assertEqual({socks5, "socks.local", 1080, undefined, tcp}, Result) + end}, + {"plain tuple accepts an atom host (#858)", + fun() -> + Result = hackney:get_proxy_config(http, "example.com", [{proxy, {localhost, 8080}}]), + ?assertEqual({http, "localhost", 8080, undefined, tcp}, Result) + end}, {"HTTP URL proxy for HTTP scheme returns http type", fun() -> Result = hackney:get_proxy_config(http, "example.com", [{proxy, "http://proxy.local:8080"}]),