Version in base suite: 25.2.3+dfsg-1+deb12u3 Base version: erlang_25.2.3+dfsg-1+deb12u3 Target version: erlang_25.2.3+dfsg-1+deb12u4 Base file: /srv/ftp-master.debian.org/ftp/pool/main/e/erlang/erlang_25.2.3+dfsg-1+deb12u3.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/e/erlang/erlang_25.2.3+dfsg-1+deb12u4.dsc changelog | 40 +++ gbp.conf | 2 patches/CVE-2025-48038.patch | 34 +++ patches/CVE-2025-48039.patch | 239 +++++++++++++++++++++ patches/CVE-2025-48040.patch | 477 +++++++++++++++++++++++++++++++++++++++++++ patches/CVE-2025-48041.patch | 268 ++++++++++++++++++++++++ patches/CVE-2026-21620.patch | 299 ++++++++++++++++++++++++++ patches/CVE-2026-23941.patch | 159 ++++++++++++++ patches/CVE-2026-23942.patch | 144 ++++++++++++ patches/CVE-2026-23943.patch | 476 ++++++++++++++++++++++++++++++++++++++++++ patches/series | 8 salsa-ci.yml | 6 12 files changed, 2152 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp9xpihhd4/erlang_25.2.3+dfsg-1+deb12u3.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp9xpihhd4/erlang_25.2.3+dfsg-1+deb12u4.dsc: no acceptable signature found diff -Nru erlang-25.2.3+dfsg/debian/changelog erlang-25.2.3+dfsg/debian/changelog --- erlang-25.2.3+dfsg/debian/changelog 2025-08-31 10:57:31.000000000 +0000 +++ erlang-25.2.3+dfsg/debian/changelog 2026-04-07 10:54:55.000000000 +0000 @@ -1,3 +1,43 @@ +erlang (1:25.2.3+dfsg-1+deb12u4) bookworm; urgency=medium + + [ Jochen Sprickerhof ] + * Add salsa-ci + * Add gbp.conf. + Needed to reproduce the orig.tar with empty directories. + * Fix CVE-2025-48038: allocation of resources without limits or throttling + vulnerability in the ssh_sftp module allows excessive allocation, + resource leak exposure (closes: #1115093). + * Fix CVE-2025-48039: allocation of resources without limits or throttling + vulnerability in the ssh_sftp module allows excessive allocation, + resource leak exposure (closes: #1115092). + * Fix CVE-2025-48040: uncontrolled resource consumption vulnerability in + the ssh_sftp module allows excessive allocation, flooding (closes: 1115091). + * Fix CVE-2025-48041: allocation of resources without limits or throttling + vulnerability in the ssh_sftp module allows excessive allocation, + flooding (closes: #1115090). + + [ Lucas Kanashiro ] + * Fix CVE-2026-23941. + Inconsistent Interpretation of HTTP Requests ('HTTP Request Smuggling') + vulnerability in Erlang OTP (inets httpd module) allows HTTP Request + Smuggling. + * Fix CVE-2026-23942. + Improper Limitation of a Pathname to a Restricted Directory ('Path + Traversal') vulnerability in Erlang OTP (ssh_sftpd module) allows Path + Traversal. + * Fix CVE-2026-23943. + Improper Handling of Highly Compressed Data (Compression Bomb) + vulnerability in Erlang OTP ssh (ssh_transport modules) allows Denial of + Service via Resource Depletion. + Closes: #1130912. + + [ Sergei Golovan ] + * Fix CVE-2026-21620. + Relative Path Traversal, Improper Isolation or Compartmentalization + vulnerability in Erlang/OTP (tftp_file modules) (closes: 1128651). + + -- Sergei Golovan Tue, 07 Apr 2026 13:54:55 +0300 + erlang (1:25.2.3+dfsg-1+deb12u3) bookworm-proposed-updates; urgency=medium * Fix FTBFS with newer xsltproc. diff -Nru erlang-25.2.3+dfsg/debian/gbp.conf erlang-25.2.3+dfsg/debian/gbp.conf --- erlang-25.2.3+dfsg/debian/gbp.conf 1970-01-01 00:00:00.000000000 +0000 +++ erlang-25.2.3+dfsg/debian/gbp.conf 2026-04-07 10:54:55.000000000 +0000 @@ -0,0 +1,2 @@ +[DEFAULT] +pristine-tar = True diff -Nru erlang-25.2.3+dfsg/debian/patches/CVE-2025-48038.patch erlang-25.2.3+dfsg/debian/patches/CVE-2025-48038.patch --- erlang-25.2.3+dfsg/debian/patches/CVE-2025-48038.patch 1970-01-01 00:00:00.000000000 +0000 +++ erlang-25.2.3+dfsg/debian/patches/CVE-2025-48038.patch 2026-04-07 10:54:55.000000000 +0000 @@ -0,0 +1,34 @@ +From: Jakub Witczak +Date: Wed, 27 Aug 2025 17:49:08 +0200 +Subject: ssh: verify file handle size limit for client data + +- reject handles exceeding 256 bytes (as specified for SFTP) + +Origin: https://github.com/erlang/otp/commit/f09e0201ff701993dc24a08f15e524daf72db42f +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-48038 +--- + lib/ssh/src/ssh_sftpd.erl | 11 +++++++++++ + 1 file changed, 11 insertions(+) + +diff --git a/lib/ssh/src/ssh_sftpd.erl b/lib/ssh/src/ssh_sftpd.erl +index 6bcad0d..cd24c3e 100644 +--- a/lib/ssh/src/ssh_sftpd.erl ++++ b/lib/ssh/src/ssh_sftpd.erl +@@ -222,6 +222,17 @@ handle_data(Type, ChannelId, Data0, State = #state{pending = Pending}) -> + handle_data(Type, ChannelId, Data, State#state{pending = <<>>}) + end. + ++%% From draft-ietf-secsh-filexfer-02 "The file handle strings MUST NOT be longer than 256 bytes." ++handle_op(Request, ReqId, <>, State = #state{xf = XF}) ++ when (Request == ?SSH_FXP_CLOSE orelse ++ Request == ?SSH_FXP_FSETSTAT orelse ++ Request == ?SSH_FXP_FSTAT orelse ++ Request == ?SSH_FXP_READ orelse ++ Request == ?SSH_FXP_READDIR orelse ++ Request == ?SSH_FXP_WRITE), ++ HLen > 256 -> ++ ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_INVALID_HANDLE, "Invalid handle"), ++ State; + handle_op(?SSH_FXP_INIT, Version, B, State) when is_binary(B) -> + XF = State#state.xf, + Vsn = lists:min([XF#ssh_xfer.vsn, Version]), diff -Nru erlang-25.2.3+dfsg/debian/patches/CVE-2025-48039.patch erlang-25.2.3+dfsg/debian/patches/CVE-2025-48039.patch --- erlang-25.2.3+dfsg/debian/patches/CVE-2025-48039.patch 1970-01-01 00:00:00.000000000 +0000 +++ erlang-25.2.3+dfsg/debian/patches/CVE-2025-48039.patch 2026-04-07 10:54:55.000000000 +0000 @@ -0,0 +1,239 @@ +From: Jakub Witczak +Date: Fri, 11 Jul 2025 13:59:41 +0200 +Subject: ssh: ssh_sftpd verify path size for client data + +- reject max_path exceeding the 4096 limit or according to other option value + +Origin: https://github.com/erlang/otp/commit/043ee3c943e2977c1acdd740ad13992fd60b6bf0 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-48039 +--- + lib/ssh/doc/src/ssh_sftpd.xml | 8 ++++ + lib/ssh/src/ssh_sftpd.erl | 32 +++++++++++++- + lib/ssh/test/ssh_sftpd_SUITE.erl | 90 ++++++++++++++++++++++++++-------------- + 3 files changed, 97 insertions(+), 33 deletions(-) + +diff --git a/lib/ssh/doc/src/ssh_sftpd.xml b/lib/ssh/doc/src/ssh_sftpd.xml +index 49a23f4..efabf3f 100644 +--- a/lib/ssh/doc/src/ssh_sftpd.xml ++++ b/lib/ssh/doc/src/ssh_sftpd.xml +@@ -65,6 +65,14 @@ + If supplied, the number of filenames returned to the SFTP client per READDIR + request is limited to at most the given value.

+ ++ max_path ++ ++

The default value is 4096. Positive integer ++ value represents the maximum path length which cannot be ++ exceeded in data provided by the SFTP client. (Note: ++ limitations might be also enforced by underlying operating ++ system)

++
+ root + +

Sets the SFTP root directory. Then the user cannot see any files +diff --git a/lib/ssh/src/ssh_sftpd.erl b/lib/ssh/src/ssh_sftpd.erl +index cd24c3e..5632848 100644 +--- a/lib/ssh/src/ssh_sftpd.erl ++++ b/lib/ssh/src/ssh_sftpd.erl +@@ -52,6 +52,7 @@ + file_handler, % atom() - callback module + file_state, % state for the file callback module + max_files, % integer >= 0 max no files sent during READDIR ++ max_path, % integer > 0 - max length of path + options, % from the subsystem declaration + handles % list of open handles + %% handle is either {, directory, {Path, unread|eof}} or +@@ -65,6 +66,7 @@ + Options :: [ {cwd, string()} | + {file_handler, CbMod | {CbMod, FileState}} | + {max_files, integer()} | ++ {max_path, integer()} | + {root, string()} | + {sftpd_vsn, integer()} + ], +@@ -115,8 +117,12 @@ init(Options) -> + {Root0, State0} + end, + MaxLength = proplists:get_value(max_files, Options, 0), ++ MaxPath = proplists:get_value(max_path, Options, 4096), + Vsn = proplists:get_value(sftpd_vsn, Options, 5), +- {ok, State#state{cwd = CWD, root = Root, max_files = MaxLength, ++ {ok, State#state{cwd = CWD, ++ root = Root, ++ max_files = MaxLength, ++ max_path = MaxPath, + options = Options, + handles = [], pending = <<>>, + xf = #ssh_xfer{vsn = Vsn, ext = []}}}. +@@ -233,6 +239,30 @@ handle_op(Request, ReqId, <>, State = #state{xf = XF}) + HLen > 256 -> + ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_INVALID_HANDLE, "Invalid handle"), + State; ++handle_op(Request, ReqId, <>, ++ State = #state{max_path = MaxPath, xf = XF}) ++ when (Request == ?SSH_FXP_LSTAT orelse ++ Request == ?SSH_FXP_MKDIR orelse ++ Request == ?SSH_FXP_OPEN orelse ++ Request == ?SSH_FXP_OPENDIR orelse ++ Request == ?SSH_FXP_READLINK orelse ++ Request == ?SSH_FXP_REALPATH orelse ++ Request == ?SSH_FXP_REMOVE orelse ++ Request == ?SSH_FXP_RMDIR orelse ++ Request == ?SSH_FXP_SETSTAT orelse ++ Request == ?SSH_FXP_STAT), ++ PLen > MaxPath -> ++ ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_NO_SUCH_PATH, ++ "No such path"), ++ State; ++handle_op(Request, ReqId, <>, ++ State = #state{max_path = MaxPath, xf = XF}) ++ when (Request == ?SSH_FXP_RENAME orelse ++ Request == ?SSH_FXP_SYMLINK), ++ (PLen > MaxPath orelse PLen2 > MaxPath) -> ++ ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_NO_SUCH_PATH, ++ "No such path"), ++ State; + handle_op(?SSH_FXP_INIT, Version, B, State) when is_binary(B) -> + XF = State#state.xf, + Vsn = lists:min([XF#ssh_xfer.vsn, Version]), +diff --git a/lib/ssh/test/ssh_sftpd_SUITE.erl b/lib/ssh/test/ssh_sftpd_SUITE.erl +index 42677b7..f04cde3 100644 +--- a/lib/ssh/test/ssh_sftpd_SUITE.erl ++++ b/lib/ssh/test/ssh_sftpd_SUITE.erl +@@ -43,6 +43,7 @@ + open_file_dir_v6/1, + read_dir/1, + read_file/1, ++ max_path/1, + real_path/1, + relative_path/1, + relpath/1, +@@ -71,9 +72,8 @@ + -define(SSH_TIMEOUT, 10000). + -define(REG_ATTERS, <<0,0,0,0,1>>). + -define(UNIX_EPOCH, 62167219200). +- +--define(is_set(F, Bits), +- ((F) band (Bits)) == (F)). ++-define(MAX_PATH, 200). ++-define(is_set(F, Bits), ((F) band (Bits)) == (F)). + + %%-------------------------------------------------------------------- + %% Common Test interface functions ----------------------------------- +@@ -86,6 +86,7 @@ all() -> + [open_close_file, + open_close_dir, + read_file, ++ max_path, + read_dir, + write_file, + rename_file, +@@ -180,7 +181,8 @@ init_per_testcase(TestCase, Config) -> + {sftpd_vsn, 6}])], + ssh:daemon(0, [{subsystems, SubSystems}|Options]); + _ -> +- SubSystems = [ssh_sftpd:subsystem_spec([])], ++ SubSystems = [ssh_sftpd:subsystem_spec( ++ [{max_path, ?MAX_PATH}])], + ssh:daemon(0, [{subsystems, SubSystems}|Options]) + end, + +@@ -333,6 +335,23 @@ read_file(Config) when is_list(Config) -> + + {ok, Data} = file:read_file(FileName). + ++%%-------------------------------------------------------------------- ++max_path(Config) when is_list(Config) -> ++ PrivDir = proplists:get_value(priv_dir, Config), ++ FileName = filename:join(PrivDir, "test.txt"), ++ {Cm, Channel} = proplists:get_value(sftp, Config), ++ %% verify max_path limit ++ LongFileName = ++ filename:join(PrivDir, ++ "t" ++ lists:flatten(lists:duplicate(?MAX_PATH, "e")) ++ "st.txt"), ++ {ok, _} = file:copy(FileName, LongFileName), ++ ReqId1 = req_id(), ++ {ok, <>, _} = ++ open_file(LongFileName, Cm, Channel, ReqId1, ++ ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, ++ ?SSH_FXF_OPEN_EXISTING). ++ + %%-------------------------------------------------------------------- + read_dir(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), +@@ -388,35 +407,33 @@ rename_file(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + FileName = filename:join(PrivDir, "test.txt"), + NewFileName = filename:join(PrivDir, "test1.txt"), +- ReqId = 0, ++ LongFileName = ++ filename:join(PrivDir, ++ "t" ++ lists:flatten(lists:duplicate(?MAX_PATH, "e")) ++ "st.txt"), + {Cm, Channel} = proplists:get_value(sftp, Config), +- +- {ok, <>, _} = +- rename(FileName, NewFileName, Cm, Channel, ReqId, 6, 0), +- +- NewReqId = ReqId + 1, +- +- {ok, <>, _} = +- rename(NewFileName, FileName, Cm, Channel, NewReqId, 6, +- ?SSH_FXP_RENAME_OVERWRITE), +- +- NewReqId1 = NewReqId + 1, +- file:copy(FileName, NewFileName), +- +- %% No overwrite +- {ok, <>, _} = +- rename(FileName, NewFileName, Cm, Channel, NewReqId1, 6, +- ?SSH_FXP_RENAME_NATIVE), +- +- NewReqId2 = NewReqId1 + 1, +- +- {ok, <>, _} = +- rename(FileName, NewFileName, Cm, Channel, NewReqId2, 6, +- ?SSH_FXP_RENAME_ATOMIC). ++ Version = 6, ++ [begin ++ case Action of ++ {Code, AFile, BFile, Flags} -> ++ ReqId = req_id(), ++ ct:log("ReqId = ~p,~nCode = ~p,~nAFile = ~p,~nBFile = ~p,~nFlags = ~p", ++ [ReqId, Code, AFile, BFile, Flags]), ++ {ok, <>, _} = ++ rename(AFile, BFile, Cm, Channel, ReqId, Version, Flags); ++ {file_copy, AFile, BFile} -> ++ {ok, _} = file:copy(AFile, BFile) ++ end ++ end || ++ Action <- ++ [{?SSH_FX_OK, FileName, NewFileName, 0}, ++ {?SSH_FX_OK, NewFileName, FileName, ?SSH_FXP_RENAME_OVERWRITE}, ++ {file_copy, FileName, NewFileName}, ++ %% no overwrite ++ {?SSH_FX_FILE_ALREADY_EXISTS, FileName, NewFileName, ?SSH_FXP_RENAME_NATIVE}, ++ {?SSH_FX_OP_UNSUPPORTED, FileName, NewFileName, ?SSH_FXP_RENAME_ATOMIC}, ++ %% max_path ++ {?SSH_FX_NO_SUCH_PATH, FileName, LongFileName, 0}]], ++ ok. + + %%-------------------------------------------------------------------- + mk_rm_dir(Config) when is_list(Config) -> +@@ -1078,3 +1095,12 @@ encode_file_type(Type) -> + + not_default_permissions() -> + 8#600. %% User read-write-only ++ ++req_id() -> ++ ReqId = ++ case get(req_id) of ++ undefined -> 0; ++ I -> I ++ end, ++ put(req_id, ReqId + 1), ++ ReqId. diff -Nru erlang-25.2.3+dfsg/debian/patches/CVE-2025-48040.patch erlang-25.2.3+dfsg/debian/patches/CVE-2025-48040.patch --- erlang-25.2.3+dfsg/debian/patches/CVE-2025-48040.patch 1970-01-01 00:00:00.000000000 +0000 +++ erlang-25.2.3+dfsg/debian/patches/CVE-2025-48040.patch 2026-04-07 10:54:55.000000000 +0000 @@ -0,0 +1,477 @@ +From: Jakub Witczak +Date: Wed, 20 Aug 2025 10:30:55 +0200 +Subject: ssh: key exchange robustness improvements + +- reduce untrusted data processing for non-debug logs +- trim badmatch exceptions to avoid processing potentially malicious data +- terminate with kexinit_error when too many algorithms are received in KEX init message + +Origin: https://github.com/erlang/otp/commit/548f1295d86d0803da884db8685cc16d461d0d5a +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-48040 +--- + lib/ssh/src/ssh_connection.erl | 3 +- + lib/ssh/src/ssh_connection_handler.erl | 35 +++++++--- + lib/ssh/src/ssh_lib.erl | 15 ++++- + lib/ssh/src/ssh_message.erl | 42 +++++++----- + lib/ssh/src/ssh_transport.erl | 120 +++++++++++++++++++-------------- + lib/ssh/test/ssh_connection_SUITE.erl | 12 +++- + 6 files changed, 147 insertions(+), 80 deletions(-) + +diff --git a/lib/ssh/src/ssh_connection.erl b/lib/ssh/src/ssh_connection.erl +index badf3a2..b21d249 100644 +--- a/lib/ssh/src/ssh_connection.erl ++++ b/lib/ssh/src/ssh_connection.erl +@@ -481,10 +481,9 @@ handle_msg(Msg, Connection, server, Ssh = #ssh{authenticated = false}) -> + %% respond by disconnecting, preferably with a proper disconnect message + %% sent to ease troubleshooting. + MsgFun = fun(M) -> +- MaxLogItemLen = ?GET_OPT(max_log_item_len, Ssh#ssh.opts), + io_lib:format("Connection terminated. Unexpected message for unauthenticated user." + " Message: ~w", [M], +- [{chars_limit, MaxLogItemLen}]) ++ [{chars_limit, ssh_lib:max_log_len(Ssh)}]) + end, + ?LOG_DEBUG(MsgFun, [Msg]), + {disconnect, {?SSH_DISCONNECT_PROTOCOL_ERROR, "Connection refused"}, handle_stop(Connection)}; +diff --git a/lib/ssh/src/ssh_connection_handler.erl b/lib/ssh/src/ssh_connection_handler.erl +index ba46468..fa3b374 100644 +--- a/lib/ssh/src/ssh_connection_handler.erl ++++ b/lib/ssh/src/ssh_connection_handler.erl +@@ -1146,12 +1146,21 @@ handle_event(info, {Proto, Sock, NewData}, StateName, + {next_event, internal, Msg} + ]} + catch +- C:E:ST -> +- MaxLogItemLen = ?GET_OPT(max_log_item_len,SshParams#ssh.opts), ++ Class:Reason0:Stacktrace -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ MsgFun = ++ fun(debug) -> ++ io_lib:format("Bad packet: Decrypted, but can't decode~n~p:~p~n~p", ++ [Class,Reason,Stacktrace], ++ [{chars_limit, ssh_lib:max_log_len(SshParams)}]); ++ (_) -> ++ io_lib:format("Bad packet: Decrypted, but can't decode ~p:~p", ++ [Class, Reason], ++ [{chars_limit, ssh_lib:max_log_len(SshParams)}]) ++ end, + {Shutdown, D} = + ?send_disconnect(?SSH_DISCONNECT_PROTOCOL_ERROR, +- io_lib:format("Bad packet: Decrypted, but can't decode~n~p:~p~n~p", +- [C,E,ST], [{chars_limit, MaxLogItemLen}]), ++ ?SELECT_MSG(MsgFun), + StateName, D1), + {stop, Shutdown, D} + end; +@@ -1181,12 +1190,20 @@ handle_event(info, {Proto, Sock, NewData}, StateName, + StateName, D0), + {stop, Shutdown, D} + catch +- C:E:ST -> +- MaxLogItemLen = ?GET_OPT(max_log_item_len,SshParams#ssh.opts), ++ Class:Reason0:Stacktrace -> ++ MsgFun = ++ fun(debug) -> ++ io_lib:format("Bad packet: Couldn't decrypt~n~p:~p~n~p", ++ [Class,Reason0,Stacktrace], ++ [{chars_limit, ssh_lib:max_log_len(SshParams)}]); ++ (_) -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ io_lib:format("Bad packet: Couldn't decrypt~n~p:~p", ++ [Class,Reason], ++ [{chars_limit, ssh_lib:max_log_len(SshParams)}]) ++ end, + {Shutdown, D} = +- ?send_disconnect(?SSH_DISCONNECT_PROTOCOL_ERROR, +- io_lib:format("Bad packet: Couldn't decrypt~n~p:~p~n~p", +- [C,E,ST], [{chars_limit, MaxLogItemLen}]), ++ ?send_disconnect(?SSH_DISCONNECT_PROTOCOL_ERROR, ?SELECT_MSG(MsgFun), + StateName, D0), + {stop, Shutdown, D} + end; +diff --git a/lib/ssh/src/ssh_lib.erl b/lib/ssh/src/ssh_lib.erl +index 3d29b5e..c6791f1 100644 +--- a/lib/ssh/src/ssh_lib.erl ++++ b/lib/ssh/src/ssh_lib.erl +@@ -28,7 +28,9 @@ + format_address_port/2, format_address_port/1, + format_address/1, + format_time_ms/1, +- comp/2 ++ comp/2, ++ trim_reason/1, ++ max_log_len/1 + ]). + + -include("ssh.hrl"). +@@ -86,3 +88,14 @@ comp([], [], Truth) -> + + comp(_, _, _) -> + false. ++%% We don't want to process badmatch details, potentially containing ++%% malicious data of unknown size ++trim_reason({badmatch, V}) when is_binary(V) -> ++ badmatch; ++trim_reason(E) -> ++ E. ++ ++max_log_len(#ssh{opts = Opts}) -> ++ ?GET_OPT(max_log_item_len, Opts); ++max_log_len(Opts) when is_map(Opts) -> ++ ?GET_OPT(max_log_item_len, Opts). +diff --git a/lib/ssh/src/ssh_message.erl b/lib/ssh/src/ssh_message.erl +index e22a4e2..4d5ac74 100644 +--- a/lib/ssh/src/ssh_message.erl ++++ b/lib/ssh/src/ssh_message.erl +@@ -43,7 +43,7 @@ + + -behaviour(ssh_dbg). + -export([ssh_dbg_trace_points/0, ssh_dbg_flags/1, ssh_dbg_on/1, ssh_dbg_off/1, ssh_dbg_format/2]). +--define(ALG_NAME_LIMIT, 64). ++-define(ALG_NAME_LIMIT, 64). % RFC4251 sec6 + + ucl(B) -> + try unicode:characters_to_list(B) of +@@ -821,23 +821,33 @@ decode_kex_init(<>, Acc, 0) -> + %% See rfc 4253 7.1 + X = 0, + list_to_tuple(lists:reverse([X, erl_boolean(Bool) | Acc])); +-decode_kex_init(<>, Acc, N) -> ++decode_kex_init(<>, Acc, N) when ++ byte_size(Data) < ?MAX_NUM_ALGORITHMS * ?ALG_NAME_LIMIT -> + BinParts = binary:split(Data, <<$,>>, [global]), +- Process = +- fun(<<>>, PAcc) -> +- PAcc; +- (Part, PAcc) -> +- case byte_size(Part) > ?ALG_NAME_LIMIT of +- true -> +- ?LOG_DEBUG("Ignoring too long name", []), ++ AlgCount = length(BinParts), ++ case AlgCount =< ?MAX_NUM_ALGORITHMS of ++ true -> ++ Process = ++ fun(<<>>, PAcc) -> + PAcc; +- false -> +- Name = binary:bin_to_list(Part), +- [Name | PAcc] +- end +- end, +- Names = lists:foldr(Process, [], BinParts), +- decode_kex_init(Rest, [Names | Acc], N - 1). ++ (Part, PAcc) -> ++ case byte_size(Part) =< ?ALG_NAME_LIMIT of ++ true -> ++ Name = binary:bin_to_list(Part), ++ [Name | PAcc]; ++ false -> ++ ?LOG_DEBUG("Ignoring too long name", []), ++ PAcc ++ end ++ end, ++ Names = lists:foldr(Process, [], BinParts), ++ decode_kex_init(Rest, [Names | Acc], N - 1); ++ false -> ++ throw({error, {kexinit_error, N, {alg_count, AlgCount}}}) ++ end; ++decode_kex_init(<>, _Acc, N) -> ++ throw({error, {kexinit, N, {string_size, byte_size(Data)}}}). ++ + + + %%%================================================================ +diff --git a/lib/ssh/src/ssh_transport.erl b/lib/ssh/src/ssh_transport.erl +index 6081a02..5abc11b 100644 +--- a/lib/ssh/src/ssh_transport.erl ++++ b/lib/ssh/src/ssh_transport.erl +@@ -405,8 +405,9 @@ handle_kexinit_msg(#ssh_msg_kexinit{} = CounterPart, #ssh_msg_kexinit{} = Own, + key_exchange_first_msg(Algos#alg.kex, + Ssh#ssh{algorithms = Algos}) + catch +- Class:Error -> +- Msg = kexinit_error(Class, Error, client, Own, CounterPart), ++ Class:Reason0 -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ Msg = kexinit_error(Class, Reason, client, Own, CounterPart, Ssh), + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, Msg) + end; + +@@ -422,31 +423,38 @@ handle_kexinit_msg(#ssh_msg_kexinit{} = CounterPart, #ssh_msg_kexinit{} = Own, + Algos -> + {ok, Ssh#ssh{algorithms = Algos}} + catch +- Class:Error -> +- Msg = kexinit_error(Class, Error, server, Own, CounterPart), ++ Class:Reason0 -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ Msg = kexinit_error(Class, Reason, server, Own, CounterPart, Ssh), + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, Msg) + end. + +-kexinit_error(Class, Error, Role, Own, CounterPart) -> ++kexinit_error(Class, Error, Role, Own, CounterPart, Ssh) -> + {Fmt,Args} = + case {Class,Error} of + {error, {badmatch,{false,Alg}}} -> + {Txt,W,C} = alg_info(Role, Alg), +- {"No common ~s algorithm,~n" +- " we have:~n ~s~n" +- " peer have:~n ~s~n", +- [Txt, +- lists:join(", ", element(W,Own)), +- lists:join(", ", element(C,CounterPart)) +- ]}; ++ MsgFun = ++ fun(debug) -> ++ {"No common ~s algorithm,~n" ++ " we have:~n ~s~n" ++ " peer have:~n ~s~n", ++ [Txt, ++ lists:join(", ", element(W,Own)), ++ lists:join(", ", element(C,CounterPart))]}; ++ (_) -> ++ {"No common ~s algorithm", [Txt]} ++ end, ++ ?SELECT_MSG(MsgFun); + _ -> + {"Kexinit failed in ~p: ~p:~p", [Role,Class,Error]} + end, +- try io_lib:format(Fmt, Args) of ++ try io_lib:format(Fmt, Args, [{chars_limit, ssh_lib:max_log_len(Ssh)}]) of + R -> R + catch + _:_ -> +- io_lib:format("Kexinit failed in ~p: ~p:~p", [Role, Class, Error]) ++ io_lib:format("Kexinit failed in ~p: ~p:~p", [Role, Class, Error], ++ [{chars_limit, ssh_lib:max_log_len(Ssh)}]) + end. + + alg_info(client, Alg) -> +@@ -598,14 +606,19 @@ handle_kexdh_init(#ssh_msg_kexdh_init{e = E}, + session_id = sid(Ssh1, H)}}; + {error,unsupported_sign_alg} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Unsupported algorithm ~p", [SignAlg]) +- ) ++ io_lib:format("Unsupported algorithm ~p", [SignAlg], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}])) + end; + true -> +- ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ++ MsgFun = ++ fun(debug) -> + io_lib:format("Kexdh init failed, received 'e' out of bounds~n E=~p~n P=~p", +- [E,P]) +- ) ++ [E,P], [{chars_limit, ssh_lib:max_log_len(Opts)}]); ++ (_) -> ++ io_lib:format("Kexdh init failed, received 'e' out of bounds", [], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}] ) ++ end, ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ?SELECT_MSG(MsgFun)) + end. + + handle_kexdh_reply(#ssh_msg_kexdh_reply{public_host_key = PeerPubHostKey, +@@ -626,14 +639,15 @@ handle_kexdh_reply(#ssh_msg_kexdh_reply{public_host_key = PeerPubHostKey, + session_id = sid(Ssh, H)})}; + Error -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Kexdh init failed. Verify host key: ~p",[Error]) ++ io_lib:format("Kexdh init failed. Verify host key: ~p",[Error], ++ [{chars_limit, ssh_lib:max_log_len(Ssh0)}]) + ) + end; + + true -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, + io_lib:format("Kexdh init failed, received 'f' out of bounds~n F=~p~n P=~p", +- [F,P]) ++ [F,P], [{chars_limit, ssh_lib:max_log_len(Ssh0)}]) + ) + end. + +@@ -659,7 +673,8 @@ handle_kex_dh_gex_request(#ssh_msg_kex_dh_gex_request{min = Min0, + }}; + {error,_} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("No possible diffie-hellman-group-exchange group found",[]) ++ io_lib:format("No possible diffie-hellman-group-exchange group found",[], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}]) + ) + end; + +@@ -691,8 +706,8 @@ handle_kex_dh_gex_request(#ssh_msg_kex_dh_gex_request_old{n = NBits}, + }}; + {error,_} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("No possible diffie-hellman-group-exchange group found",[]) +- ) ++ io_lib:format("No possible diffie-hellman-group-exchange group found",[], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}])) + end; + + handle_kex_dh_gex_request(_, _) -> +@@ -718,7 +733,6 @@ handle_kex_dh_gex_group(#ssh_msg_kex_dh_gex_group{p = P, g = G}, Ssh0) -> + {Public, Private} = generate_key(dh, [P,G,2*Sz]), + {SshPacket, Ssh1} = + ssh_packet(#ssh_msg_kex_dh_gex_init{e = Public}, Ssh0), % Pub = G^Priv mod P (def) +- + {ok, SshPacket, + Ssh1#ssh{keyex_key = {{Private, Public}, {G, P}}}}. + +@@ -749,19 +763,22 @@ handle_kex_dh_gex_init(#ssh_msg_kex_dh_gex_init{e = E}, + }}; + {error,unsupported_sign_alg} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Unsupported algorithm ~p", [SignAlg]) +- ) ++ io_lib:format("Unsupported algorithm ~p", [SignAlg], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}])) + end; + true -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- "Kexdh init failed, received 'k' out of bounds" +- ) ++ "Kexdh init failed, received 'k' out of bounds") + end; + true -> +- ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Kexdh gex init failed, received 'e' out of bounds~n E=~p~n P=~p", +- [E,P]) +- ) ++ MsgFun = ++ fun(debug) -> ++ io_lib:format("Kexdh gex init failed, received 'e' out of bounds~n" ++ " E=~p~n P=~p", [E,P]); ++ (_) -> ++ io_lib:format("Kexdh gex init failed, received 'e' out of bounds", []) ++ end, ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ?SELECT_MSG(MsgFun)) + end. + + handle_kex_dh_gex_reply(#ssh_msg_kex_dh_gex_reply{public_host_key = PeerPubHostKey, +@@ -786,20 +803,18 @@ handle_kex_dh_gex_reply(#ssh_msg_kex_dh_gex_reply{public_host_key = PeerPubHostK + session_id = sid(Ssh, H)})}; + Error -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Kexdh gex reply failed. Verify host key: ~p",[Error]) +- ) ++ io_lib:format("Kexdh gex reply failed. Verify host key: ~p", ++ [Error], [{chars_limit, ssh_lib:max_log_len(Ssh0)}])) + end; + + true -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- "Kexdh gex init failed, 'K' out of bounds" +- ) ++ "Kexdh gex init failed, 'K' out of bounds") + end; + true -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, + io_lib:format("Kexdh gex init failed, received 'f' out of bounds~n F=~p~n P=~p", +- [F,P]) +- ) ++ [F,P], [{chars_limit, ssh_lib:max_log_len(Ssh0)}])) + end. + + %%%---------------------------------------------------------------- +@@ -833,17 +848,25 @@ handle_kex_ecdh_init(#ssh_msg_kex_ecdh_init{q_c = PeerPublic}, + session_id = sid(Ssh1, H)}}; + {error,unsupported_sign_alg} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Unsupported algorithm ~p", [SignAlg]) +- ) ++ io_lib:format("Unsupported algorithm ~p", [SignAlg], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}])) + end + catch +- Class:Error -> +- ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ++ Class:Reason0 -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ MsgFun = ++ fun(debug) -> + io_lib:format("ECDH compute key failed in server: ~p:~p~n" + "Kex: ~p, Curve: ~p~n" + "PeerPublic: ~p", +- [Class,Error,Kex,Curve,PeerPublic]) +- ) ++ [Class,Reason,Kex,Curve,PeerPublic], ++ [{chars_limit, ssh_lib:max_log_len(Ssh0)}]); ++ (_) -> ++ io_lib:format("ECDH compute key failed in server: ~p:~p", ++ [Class,Reason], ++ [{chars_limit, ssh_lib:max_log_len(Ssh0)}]) ++ end, ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ?SELECT_MSG(MsgFun)) + end. + + handle_kex_ecdh_reply(#ssh_msg_kex_ecdh_reply{public_host_key = PeerPubHostKey, +@@ -866,15 +889,14 @@ handle_kex_ecdh_reply(#ssh_msg_kex_ecdh_reply{public_host_key = PeerPubHostKey, + session_id = sid(Ssh, H)})}; + Error -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("ECDH reply failed. Verify host key: ~p",[Error]) +- ) ++ io_lib:format("ECDH reply failed. Verify host key: ~p",[Error], ++ [{chars_limit, ssh_lib:max_log_len(Ssh0)}])) + end + catch + Class:Error -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, + io_lib:format("Peer ECDH public key seem invalid: ~p:~p", +- [Class,Error]) +- ) ++ [Class,Error], [{chars_limit, ssh_lib:max_log_len(Ssh0)}])) + end. + + +diff --git a/lib/ssh/test/ssh_connection_SUITE.erl b/lib/ssh/test/ssh_connection_SUITE.erl +index 06d90cc..d529cf5 100644 +--- a/lib/ssh/test/ssh_connection_SUITE.erl ++++ b/lib/ssh/test/ssh_connection_SUITE.erl +@@ -1345,6 +1345,8 @@ gracefull_invalid_long_start_no_nl(Config) when is_list(Config) -> + end. + + kex_error(Config) -> ++ #{level := Level} = logger:get_primary_config(), ++ ok = logger:set_primary_config(level, debug), + PrivDir = proplists:get_value(priv_dir, Config), + UserDir = filename:join(PrivDir, nopubkey), % to make sure we don't use public-key-auth + file:make_dir(UserDir), +@@ -1365,6 +1367,10 @@ kex_error(Config) -> + ok % Other msg + end, + self()), ++ Cleanup = fun() -> ++ ok = logger:remove_handler(kex_error), ++ ok = logger:set_primary_config(level, Level) ++ end, + try + ssh_test_lib:connect(Host, Port, [{silently_accept_hosts, true}, + {user, "foo"}, +@@ -1382,7 +1388,7 @@ kex_error(Config) -> + %% ok + receive + {Ref, ErrMsgTxt} -> +- ok = logger:remove_handler(kex_error), ++ Cleanup(), + ct:log("ErrMsgTxt = ~n~s", [ErrMsgTxt]), + Lines = lists:map(fun string:trim/1, string:tokens(ErrMsgTxt, "\n")), + OK = (lists:all(fun(S) -> lists:member(S,Lines) end, +@@ -1400,12 +1406,12 @@ kex_error(Config) -> + ct:fail("unexpected error text msg", []) + end + after 20000 -> +- ok = logger:remove_handler(kex_error), ++ Cleanup(), + ct:fail("timeout", []) + end; + + error:{badmatch,{error,_}} -> +- ok = logger:remove_handler(kex_error), ++ Cleanup(), + ct:fail("unexpected error msg", []) + end. + diff -Nru erlang-25.2.3+dfsg/debian/patches/CVE-2025-48041.patch erlang-25.2.3+dfsg/debian/patches/CVE-2025-48041.patch --- erlang-25.2.3+dfsg/debian/patches/CVE-2025-48041.patch 1970-01-01 00:00:00.000000000 +0000 +++ erlang-25.2.3+dfsg/debian/patches/CVE-2025-48041.patch 2026-04-07 10:54:55.000000000 +0000 @@ -0,0 +1,268 @@ +From: Jakub Witczak +Date: Wed, 20 Aug 2025 10:31:50 +0200 +Subject: ssh: max_handles option added to ssh_sftpd + +- add max_handles option and update tests (1000 by default) +- remove sshd_read_file redundant testcase + +Origin: https://github.com/erlang/otp/commit/d49efa2d4fa9e6f7ee658719cd76ffe7a33c2401 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-48041 +--- + lib/ssh/doc/src/ssh_sftpd.xml | 4 ++ + lib/ssh/src/ssh_sftpd.erl | 30 +++++++++++---- + lib/ssh/test/ssh_sftpd_SUITE.erl | 83 ++++++++++++++++++---------------------- + 3 files changed, 64 insertions(+), 53 deletions(-) + +diff --git a/lib/ssh/doc/src/ssh_sftpd.xml b/lib/ssh/doc/src/ssh_sftpd.xml +index efabf3f..7c250a9 100644 +--- a/lib/ssh/doc/src/ssh_sftpd.xml ++++ b/lib/ssh/doc/src/ssh_sftpd.xml +@@ -65,6 +65,10 @@ + If supplied, the number of filenames returned to the SFTP client per READDIR + request is limited to at most the given value.

+
++ max_handles ++ ++

The default value is 1000. Positive integer value represents the maximum number of file handles allowed for a connection.

++
+ max_path + +

The default value is 4096. Positive integer +diff --git a/lib/ssh/src/ssh_sftpd.erl b/lib/ssh/src/ssh_sftpd.erl +index 5632848..dfa566a 100644 +--- a/lib/ssh/src/ssh_sftpd.erl ++++ b/lib/ssh/src/ssh_sftpd.erl +@@ -52,6 +52,7 @@ + file_handler, % atom() - callback module + file_state, % state for the file callback module + max_files, % integer >= 0 max no files sent during READDIR ++ max_handles, % integer > 0 - max number of file handles + max_path, % integer > 0 - max length of path + options, % from the subsystem declaration + handles % list of open handles +@@ -66,6 +67,7 @@ + Options :: [ {cwd, string()} | + {file_handler, CbMod | {CbMod, FileState}} | + {max_files, integer()} | ++ {max_handles, integer()} | + {max_path, integer()} | + {root, string()} | + {sftpd_vsn, integer()} +@@ -117,11 +119,13 @@ init(Options) -> + {Root0, State0} + end, + MaxLength = proplists:get_value(max_files, Options, 0), ++ MaxHandles = proplists:get_value(max_handles, Options, 1000), + MaxPath = proplists:get_value(max_path, Options, 4096), + Vsn = proplists:get_value(sftpd_vsn, Options, 5), + {ok, State#state{cwd = CWD, + root = Root, + max_files = MaxLength, ++ max_handles = MaxHandles, + max_path = MaxPath, + options = Options, + handles = [], pending = <<>>, +@@ -286,14 +290,16 @@ handle_op(?SSH_FXP_REALPATH, ReqId, + end; + handle_op(?SSH_FXP_OPENDIR, ReqId, + <>, +- State0 = #state{xf = #ssh_xfer{vsn = Vsn}, +- file_handler = FileMod, file_state = FS0}) -> ++ State0 = #state{xf = #ssh_xfer{vsn = Vsn}, ++ file_handler = FileMod, file_state = FS0, ++ max_handles = MaxHandles}) -> + RelPath = unicode:characters_to_list(RPath), + AbsPath = relate_file_name(RelPath, State0), + + XF = State0#state.xf, + {IsDir, FS1} = FileMod:is_dir(AbsPath, FS0), + State1 = State0#state{file_state = FS1}, ++ HandlesCnt = length(State0#state.handles), + case IsDir of + false when Vsn > 5 -> + ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_NOT_A_DIRECTORY, +@@ -303,8 +309,12 @@ handle_op(?SSH_FXP_OPENDIR, ReqId, + ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_FAILURE, + "Not a directory"), + State1; +- true -> +- add_handle(State1, XF, ReqId, directory, {RelPath,unread}) ++ true when HandlesCnt < MaxHandles -> ++ add_handle(State1, XF, ReqId, directory, {RelPath,unread}); ++ true -> ++ ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_FAILURE, ++ "max_handles limit reached"), ++ State1 + end; + handle_op(?SSH_FXP_READDIR, ReqId, + <>, +@@ -755,7 +765,9 @@ open(Vsn, ReqId, Data, State) when Vsn >= 4 -> + do_open(ReqId, State, Path, Flags). + + do_open(ReqId, State0, Path, Flags) -> +- #state{file_handler = FileMod, file_state = FS0, xf = #ssh_xfer{vsn = Vsn}} = State0, ++ #state{file_handler = FileMod, file_state = FS0, xf = #ssh_xfer{vsn = Vsn}, ++ max_handles = MaxHandles} = State0, ++ HandlesCnt = length(State0#state.handles), + AbsPath = relate_file_name(Path, State0), + {IsDir, _FS1} = FileMod:is_dir(AbsPath, FS0), + case IsDir of +@@ -767,7 +779,7 @@ do_open(ReqId, State0, Path, Flags) -> + ssh_xfer:xf_send_status(State0#state.xf, ReqId, + ?SSH_FX_FAILURE, "File is a directory"), + State0; +- false -> ++ false when HandlesCnt < MaxHandles -> + OpenFlags = [binary | Flags], + {Res, FS1} = FileMod:open(AbsPath, OpenFlags, FS0), + State1 = State0#state{file_state = FS1}, +@@ -778,7 +790,11 @@ do_open(ReqId, State0, Path, Flags) -> + ssh_xfer:xf_send_status(State1#state.xf, ReqId, + ssh_xfer:encode_erlang_status(Error)), + State1 +- end ++ end; ++ false -> ++ ssh_xfer:xf_send_status(State0#state.xf, ReqId, ++ ?SSH_FX_FAILURE, "max_handles limit reached"), ++ State0 + end. + + %% resolve all symlinks in a path +diff --git a/lib/ssh/test/ssh_sftpd_SUITE.erl b/lib/ssh/test/ssh_sftpd_SUITE.erl +index f04cde3..fade45b 100644 +--- a/lib/ssh/test/ssh_sftpd_SUITE.erl ++++ b/lib/ssh/test/ssh_sftpd_SUITE.erl +@@ -52,7 +52,6 @@ + retrieve_attributes/1, + root_with_cwd/1, + set_attributes/1, +- sshd_read_file/1, + ver3_open_flags/1, + ver3_rename/1, + ver6_basic/1, +@@ -72,6 +71,7 @@ + -define(SSH_TIMEOUT, 10000). + -define(REG_ATTERS, <<0,0,0,0,1>>). + -define(UNIX_EPOCH, 62167219200). ++-define(MAX_HANDLES, 10). + -define(MAX_PATH, 200). + -define(is_set(F, Bits), ((F) band (Bits)) == (F)). + +@@ -98,8 +98,7 @@ all() -> + links, + ver3_rename, + ver3_open_flags, +- relpath, +- sshd_read_file, ++ relpath, + ver6_basic, + access_outside_root, + root_with_cwd, +@@ -182,7 +181,8 @@ init_per_testcase(TestCase, Config) -> + ssh:daemon(0, [{subsystems, SubSystems}|Options]); + _ -> + SubSystems = [ssh_sftpd:subsystem_spec( +- [{max_path, ?MAX_PATH}])], ++ [{max_handles, ?MAX_HANDLES}, ++ {max_path, ?MAX_PATH}])], + ssh:daemon(0, [{subsystems, SubSystems}|Options]) + end, + +@@ -318,22 +318,25 @@ open_close_dir(Config) when is_list(Config) -> + read_file(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + FileName = filename:join(PrivDir, "test.txt"), +- +- ReqId = 0, +- {Cm, Channel} = proplists:get_value(sftp, Config), +- +- {ok, <>, _} = +- open_file(FileName, Cm, Channel, ReqId, +- ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, +- ?SSH_FXF_OPEN_EXISTING), +- +- NewReqId = 1, +- +- {ok, <>, _} = +- read_file(Handle, 100, 0, Cm, Channel, NewReqId), +- +- {ok, Data} = file:read_file(FileName). ++ {Cm, Channel} = proplists:get_value(sftp, Config), ++ [begin ++ R1 = req_id(), ++ {ok, <>, _} = ++ open_file(FileName, Cm, Channel, R1, ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, ++ ?SSH_FXF_OPEN_EXISTING), ++ R2 = req_id(), ++ {ok, <>, _} = ++ read_file(Handle, 100, 0, Cm, Channel, R2), ++ {ok, Data} = file:read_file(FileName) ++ end || _I <- lists:seq(0, ?MAX_HANDLES-1)], ++ ReqId = req_id(), ++ {ok, <>, _} = ++ open_file(FileName, Cm, Channel, ReqId, ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, ++ ?SSH_FXF_OPEN_EXISTING), ++ ct:log("Message: ~s", [Msg]), ++ ok. + + %%-------------------------------------------------------------------- + max_path(Config) when is_list(Config) -> +@@ -356,12 +359,21 @@ max_path(Config) when is_list(Config) -> + read_dir(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + {Cm, Channel} = proplists:get_value(sftp, Config), +- ReqId = 0, +- {ok, <>, _} = +- open_dir(PrivDir, Cm, Channel, ReqId), +- ok = read_dir(Handle, Cm, Channel, ReqId). ++ [begin ++ R1 = req_id(), ++ {ok, <>, _} = ++ open_dir(PrivDir, Cm, Channel, R1), ++ R2 = req_id(), ++ ok = read_dir(Handle, Cm, Channel, R2) ++ end || _I <- lists:seq(0, ?MAX_HANDLES-1)], ++ ReqId = req_id(), ++ {ok, <>, _} = ++ open_dir(PrivDir, Cm, Channel, ReqId), ++ ct:log("Message: ~s", [Msg]), ++ ok. + +-%%-------------------------------------------------------------------- + write_file(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + FileName = filename:join(PrivDir, "test.txt"), +@@ -661,27 +673,6 @@ relpath(Config) when is_list(Config) -> + Root = Path + end. + +-%%-------------------------------------------------------------------- +-sshd_read_file(Config) when is_list(Config) -> +- PrivDir = proplists:get_value(priv_dir, Config), +- FileName = filename:join(PrivDir, "test.txt"), +- +- ReqId = 0, +- {Cm, Channel} = proplists:get_value(sftp, Config), +- +- {ok, <>, _} = +- open_file(FileName, Cm, Channel, ReqId, +- ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, +- ?SSH_FXF_OPEN_EXISTING), +- +- NewReqId = 1, +- +- {ok, <>, _} = +- read_file(Handle, 100, 0, Cm, Channel, NewReqId), +- +- {ok, Data} = file:read_file(FileName). +-%%-------------------------------------------------------------------- + ver6_basic(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + %FileName = filename:join(PrivDir, "test.txt"), diff -Nru erlang-25.2.3+dfsg/debian/patches/CVE-2026-21620.patch erlang-25.2.3+dfsg/debian/patches/CVE-2026-21620.patch --- erlang-25.2.3+dfsg/debian/patches/CVE-2026-21620.patch 1970-01-01 00:00:00.000000000 +0000 +++ erlang-25.2.3+dfsg/debian/patches/CVE-2026-21620.patch 2026-04-07 10:54:55.000000000 +0000 @@ -0,0 +1,299 @@ +From: Upstream +Date: Tue, 10 Feb 2026 18:13:21 +0100 +Subject: Patch fixes CVE-2026-21620 + +Ensure that relative path components does not allow +a requested file name to go outside the configured root_dir. + +root_dir should be checked to be a directory and absolute. + +If root_dir is used, Filename should be checked to be +relative under root_dir. + +--- a/lib/tftp/src/tftp_file.erl ++++ b/lib/tftp/src/tftp_file.erl +@@ -43,10 +43,6 @@ + + -include_lib("kernel/include/file.hrl"). + +--record(initial, +- {filename, +- is_native_ascii}). +- + -record(state, + {access, + filename, +@@ -294,45 +290,62 @@ + %%------------------------------------------------------------------- + + handle_options(Access, Filename, Mode, Options, Initial) -> +- I = #initial{filename = Filename, is_native_ascii = is_native_ascii()}, +- {Filename2, IsNativeAscii} = handle_initial(Initial, I), +- IsNetworkAscii = handle_mode(Mode, IsNativeAscii), ++ {Filename2, IsNativeAscii} = handle_initial(Initial, Filename), ++ IsNetworkAscii = ++ case Mode of ++ "netascii" when IsNativeAscii =:= true -> ++ true; ++ "octet" -> ++ false; ++ _ -> ++ throw({error, {badop, "Illegal mode " ++ Mode}}) ++ end, + Options2 = do_handle_options(Access, Filename2, Options), + {ok, Filename2, IsNativeAscii, IsNetworkAscii, Options2}. + +-handle_mode(Mode, IsNativeAscii) -> +- case Mode of +- "netascii" when IsNativeAscii =:= true -> true; +- "octet" -> false; +- _ -> throw({error, {badop, "Illegal mode " ++ Mode}}) ++handle_initial( ++ #state{filename = Filename, is_native_ascii = IsNativeAscii}, _FName) -> ++ {Filename, IsNativeAscii}; ++handle_initial(Initial, Filename) when is_list(Initial) -> ++ Opts = get_initial_opts(Initial, #{}), ++ {case Opts of ++ #{ root_dir := RootDir } -> ++ safe_filename(Filename, RootDir); ++ #{} -> ++ Filename ++ end, ++ maps:get(is_native_ascii, Opts, is_native_ascii())}. ++ ++get_initial_opts([], Opts) -> Opts; ++get_initial_opts([Opt | Initial], Opts) -> ++ case Opt of ++ {root_dir, RootDir} -> ++ is_map_key(root_dir, Opts) andalso ++ throw({error, {badop, "Internal error. root_dir already set"}}), ++ get_initial_opts(Initial, Opts#{ root_dir => RootDir }); ++ {native_ascii, Bool} when is_boolean(Bool) -> ++ get_initial_opts(Initial, Opts#{ is_native_ascii => Bool }) + end. + +-handle_initial([{root_dir, Dir} | Initial], I) -> +- case catch filename_join(Dir, I#initial.filename) of +- {'EXIT', _} -> +- throw({error, {badop, "Internal error. root_dir is not a string"}}); +- Filename2 -> +- handle_initial(Initial, I#initial{filename = Filename2}) +- end; +-handle_initial([{native_ascii, Bool} | Initial], I) -> +- case Bool of +- true -> handle_initial(Initial, I#initial{is_native_ascii = true}); +- false -> handle_initial(Initial, I#initial{is_native_ascii = false}) +- end; +-handle_initial([], I) when is_record(I, initial) -> +- {I#initial.filename, I#initial.is_native_ascii}; +-handle_initial(State, _) when is_record(State, state) -> +- {State#state.filename, State#state.is_native_ascii}. +- +-filename_join(Dir, Filename) -> +- case filename:pathtype(Filename) of +- absolute -> +- [_ | RelFilename] = filename:split(Filename), +- filename:join([Dir, RelFilename]); +- _ -> +- filename:join([Dir, Filename]) ++safe_filename(Filename, RootDir) -> ++ absolute =:= filename:pathtype(RootDir) orelse ++ throw({error, {badop, "Internal error. root_dir is not absolute"}}), ++ filelib:is_dir(RootDir) orelse ++ throw({error, {badop, "Internal error. root_dir not a directory"}}), ++ RelFilename = ++ case filename:pathtype(Filename) of ++ absolute -> ++ filename:join(tl(filename:split(Filename))); ++ _ -> Filename ++ end, ++ case filelib:safe_relative_path(RelFilename, RootDir) of ++ unsafe -> ++ throw({error, {badop, "Internal error. Filename out of bounds"}}); ++ SafeFilename -> ++ filename:join(RootDir, SafeFilename) + end. + ++ + do_handle_options(Access, Filename, [{Key, Val} | T]) -> + case Key of + "tsize" -> +--- a/lib/tftp/test/tftp_SUITE.erl ++++ b/lib/tftp/test/tftp_SUITE.erl +@@ -20,7 +20,7 @@ + + -module(tftp_SUITE). + +--compile(export_all). ++-compile([export_all, nowarn_export_all]). + + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %% Includes and defines +@@ -29,18 +29,13 @@ + -include_lib("common_test/include/ct.hrl"). + -include("tftp_test_lib.hrl"). + +--define(START_DAEMON(Port, Options), ++-define(START_DAEMON(Options), + begin +- {ok, Pid} = ?VERIFY({ok, _Pid}, tftp:start([{port, Port} | Options])), +- if +- Port == 0 -> +- {ok, ActualOptions} = ?IGNORE(tftp:info(Pid)), +- {value, {port, ActualPort}} = +- lists:keysearch(port, 1, ActualOptions), +- {ActualPort, Pid}; +- true -> +- {Port, Pid} +- end ++ {ok, Pid} = ?VERIFY({ok, _Pid}, tftp:start([{port, 0} | Options])), ++ {ok, ActualOptions} = ?IGNORE(tftp:info(Pid)), ++ {value, {port, ActualPort}} = ++ lists:keysearch(port, 1, ActualOptions), ++ {ActualPort, Pid} + end). + + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +@@ -78,6 +73,7 @@ + all() -> + [ + simple, ++ root_dir, + extra, + reuse_connection, + resend_client, +@@ -157,7 +153,7 @@ + simple(Config) when is_list(Config) -> + ?VERIFY(ok, application:start(tftp)), + +- {Port, DaemonPid} = ?IGNORE(?START_DAEMON(0, [{debug, brief}])), ++ {Port, DaemonPid} = ?IGNORE(?START_DAEMON([{debug, brief}])), + + %% Read fail + RemoteFilename = "tftp_temporary_remote_test_file.txt", +@@ -184,6 +180,73 @@ + ok. + + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% ++%% root_dir ++%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% ++ ++root_dir(doc) -> ++ ["Start the daemon and check the root_dir option."]; ++root_dir(suite) -> ++ []; ++root_dir(Config) when is_list(Config) -> ++ ?VERIFY(ok, application:start(tftp)), ++ PrivDir = get_conf(priv_dir, Config), ++ Root = hd(filename:split(PrivDir)), ++ Up = "..", ++ Remote = "remote.txt", ++ Local = "tftp_temporary_local_test_file.txt", ++ SideDir = fn_jn(PrivDir,tftp_side), ++ RootDir = fn_jn(PrivDir,tftp_root), ++ ?IGNORE(file:del_dir_r(RootDir)), ++ ?IGNORE(file:del_dir_r(SideDir)), ++ ok = filelib:ensure_path(fn_jn(RootDir,sub)), ++ ok = filelib:ensure_path(SideDir), ++ Blob = binary:copy(<<$1>>, 2000), ++ Size = byte_size(Blob), ++ ok = file:write_file(fn_jn(SideDir,Remote), Blob), ++ {Port, DaemonPid} = ++ ?IGNORE(?START_DAEMON([{debug, brief}, ++ {callback, ++ {"", tftp_file, [{root_dir, RootDir}]}}])), ++ try ++ %% Outside root_dir ++ ?VERIFY({error, {client_open, badop, _}}, ++ tftp:read_file( ++ fn_jn([Up,tftp_side,Remote]), binary, [{port, Port}])), ++ ?VERIFY({error, {client_open, badop, _}}, ++ tftp:write_file( ++ fn_jn([Up,tftp_side,Remote]), Blob, [{port, Port}])), ++ %% Nonexistent ++ ?VERIFY({error, {client_open, enoent, _}}, ++ tftp:read_file( ++ fn_jn(sub,Remote), binary, [{port, Port}])), ++ ?VERIFY({error, {client_open, enoent, _}}, ++ tftp:write_file( ++ fn_jn(nonexistent,Remote), Blob, [{port, Port}])), ++ %% Write and read ++ ?VERIFY({ok, Size}, ++ tftp:write_file( ++ fn_jn(sub,Remote), Blob, [{port, Port}])), ++ ?VERIFY({ok, Blob}, ++ tftp:read_file( ++ fn_jn([Root,sub,Remote]), binary, [{port, Port}])), ++ ?VERIFY({ok, Size}, ++ tftp:read_file( ++ fn_jn(sub,Remote), Local, [{port, Port}])), ++ ?VERIFY({ok, Blob}, file:read_file(Local)), ++ ?VERIFY(ok, file:delete(Local)), ++ ?VERIFY(ok, application:stop(tftp)) ++ after ++ %% Cleanup ++ unlink(DaemonPid), ++ exit(DaemonPid, kill), ++ ?IGNORE(file:del_dir_r(SideDir)), ++ ?IGNORE(file:del_dir_r(RootDir)), ++ ?IGNORE(application:stop(tftp)) ++ end, ++ ok. ++ ++ ++%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %% Extra + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +@@ -195,7 +258,7 @@ + ?VERIFY({'EXIT', {badarg,{fake_key, fake_flag}}}, + tftp:start([{port, 0}, {fake_key, fake_flag}])), + +- {Port, DaemonPid} = ?IGNORE(?START_DAEMON(0, [{debug, brief}])), ++ {Port, DaemonPid} = ?IGNORE(?START_DAEMON([{debug, brief}])), + + RemoteFilename = "tftp_extra_temporary_remote_test_file.txt", + LocalFilename = "tftp_extra_temporary_local_test_file.txt", +@@ -329,7 +392,7 @@ + []; + resend_client(Config) when is_list(Config) -> + Host = {127, 0, 0, 1}, +- {Port, DaemonPid} = ?IGNORE(?START_DAEMON(0, [{debug, all}])), ++ {Port, DaemonPid} = ?IGNORE(?START_DAEMON([{debug, all}])), + + ?VERIFY(ok, resend_read_client(Host, Port, 10)), + ?VERIFY(ok, resend_read_client(Host, Port, 512)), +@@ -890,7 +953,7 @@ + []; + reuse_connection(Config) when is_list(Config) -> + Host = {127, 0, 0, 1}, +- {Port, DaemonPid} = ?IGNORE(?START_DAEMON(0, [{debug, all}])), ++ {Port, DaemonPid} = ?IGNORE(?START_DAEMON([{debug, all}])), + + RemoteFilename = "reuse_connection.tmp", + BlkSize = 512, +@@ -964,7 +1027,7 @@ + large_file(Config) when is_list(Config) -> + ?VERIFY(ok, application:start(tftp)), + +- {Port, DaemonPid} = ?IGNORE(?START_DAEMON(0, [{debug, brief}])), ++ {Port, DaemonPid} = ?IGNORE(?START_DAEMON([{debug, brief}])), + + %% Read fail + RemoteFilename = "tftp_temporary_large_file_remote_test_file.txt", +@@ -999,3 +1062,15 @@ + after Timeout -> + timeout + end. ++ ++get_conf(Key, Config) -> ++ Default = make_ref(), ++ case proplists:get_value(Key, Config, Default) of ++ Default -> ++ erlang:error({no_key, Key}); ++ Value -> ++ Value ++ end. ++ ++fn_jn(A, B) -> filename:join(A, B). ++fn_jn(P) -> filename:join(P). diff -Nru erlang-25.2.3+dfsg/debian/patches/CVE-2026-23941.patch erlang-25.2.3+dfsg/debian/patches/CVE-2026-23941.patch --- erlang-25.2.3+dfsg/debian/patches/CVE-2026-23941.patch 1970-01-01 00:00:00.000000000 +0000 +++ erlang-25.2.3+dfsg/debian/patches/CVE-2026-23941.patch 2026-04-07 10:54:55.000000000 +0000 @@ -0,0 +1,159 @@ +From: Erlang/OTP +Date: Thu, 12 Mar 2026 16:58:29 +0100 +Subject: Merge branch 'whaileee/inets/httpd/http-request-smuggling/OTP-20007' + into maint-27 + +* whaileee/inets/httpd/http-request-smuggling/OTP-20007: + Prevent httpd from parsing HTTP requests when multiple Content-Length headers are present + +Origin: upstream, https://github.com/erlang/otp/commit/a761d391d8d08316cbd7d4a86733ba932b73c45b +Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1130912 +--- + lib/inets/src/http_server/httpd_request.erl | 53 ++++++++++++++-------- + .../src/http_server/httpd_request_handler.erl | 10 ++-- + lib/inets/test/httpd_SUITE.erl | 24 +++++++++- + 3 files changed, 63 insertions(+), 24 deletions(-) + +diff --git a/lib/inets/src/http_server/httpd_request.erl b/lib/inets/src/http_server/httpd_request.erl +index a16df69..f84f6ab 100644 +--- a/lib/inets/src/http_server/httpd_request.erl ++++ b/lib/inets/src/http_server/httpd_request.erl +@@ -210,7 +210,7 @@ parse_headers(<>, Header, Headers, _, _, + Headers), + {ok, list_to_tuple(lists:reverse([Body, {http_request:headers(FinalHeaders, #http_request_h{}), FinalHeaders} | Result]))}; + NewHeader -> +- case check_header(NewHeader, Options) of ++ case check_header(NewHeader, Headers, Options) of + ok -> + FinalHeaders = lists:filtermap(fun(H) -> + httpd_custom:customize_headers(Customize, request_header, H) +@@ -260,7 +260,7 @@ parse_headers(<>, Header, Headers, Current, Max, + parse_headers(Rest, [Octet], Headers, + Current, Max, Options, Result); + NewHeader -> +- case check_header(NewHeader, Options) of ++ case check_header(NewHeader, Headers, Options) of + ok -> + parse_headers(Rest, [Octet], [NewHeader | Headers], + Current, Max, Options, Result); +@@ -429,23 +429,36 @@ get_persistens(HTTPVersion,ParsedHeader,ConfigDB)-> + default_version()-> + "HTTP/1.1". + +-check_header({"content-length", Value}, Maxsizes) -> +- Max = proplists:get_value(max_content_length, Maxsizes), +- MaxLen = length(integer_to_list(Max)), +- case length(Value) =< MaxLen of +- true -> +- try +- list_to_integer(Value) +- of +- I when I>= 0 -> +- ok; +- _ -> +- {error, {size_error, Max, 411, "negative content-length"}} +- catch _:_ -> +- {error, {size_error, Max, 411, "content-length not an integer"}} +- end; +- false -> +- {error, {size_error, Max, 413, "content-length unreasonably long"}} ++check_header({"content-length", Value}, Headers, MaxSizes) -> ++ case check_parsed_content_length_values(Value, Headers) of ++ true -> ++ check_content_length_value(Value, MaxSizes); ++ false -> ++ {error, {bad_request, 400, "Multiple Content-Length headers with different values"}} + end; +-check_header(_, _) -> ++ ++check_header(_, _, _) -> + ok. ++ ++check_parsed_content_length_values(CurrentValue, Headers) -> ++ ContentLengths = [V || {"content-length", _} = V <- Headers], ++ length([V || {"content-length", Value} = V <- ContentLengths, Value =:= CurrentValue]) =:= length(ContentLengths). ++ ++check_content_length_value(Value, MaxSizes) -> ++ Max = proplists:get_value(max_content_length, MaxSizes), ++ MaxLen = length(integer_to_list(Max)), ++ case length(Value) =< MaxLen of ++ true -> ++ try ++ list_to_integer(Value) ++ of ++ I when I>= 0 -> ++ ok; ++ _ -> ++ {error, {size_error, Max, 411, "negative content-length"}} ++ catch _:_ -> ++ {error, {size_error, Max, 411, "content-length not an integer"}} ++ end; ++ false -> ++ {error, {size_error, Max, 413, "content-length unreasonably long"}} ++ end. +diff --git a/lib/inets/src/http_server/httpd_request_handler.erl b/lib/inets/src/http_server/httpd_request_handler.erl +index 33c07d3..5fd91b3 100644 +--- a/lib/inets/src/http_server/httpd_request_handler.erl ++++ b/lib/inets/src/http_server/httpd_request_handler.erl +@@ -235,12 +235,16 @@ handle_info({Proto, Socket, Data}, + httpd_response:send_status(NewModData, ErrCode, ErrStr, {max_size, MaxSize}), + {stop, normal, State#state{response_sent = true, + mod = NewModData}}; +- +- {error, {version_error, ErrCode, ErrStr}, Version} -> ++ {error, {version_error, ErrCode, ErrStr}, Version} -> + NewModData = ModData#mod{http_version = Version}, + httpd_response:send_status(NewModData, ErrCode, ErrStr), + {stop, normal, State#state{response_sent = true, +- mod = NewModData}}; ++ mod = NewModData}}; ++ {error, {bad_request, ErrCode, ErrStr}, Version} -> ++ NewModData = ModData#mod{http_version = Version}, ++ httpd_response:send_status(NewModData, ErrCode, ErrStr), ++ {stop, normal, State#state{response_sent = true, ++ mod = NewModData}}; + + {http_chunk = Module, Function, Args} when ChunkState =/= undefined -> + NewState = handle_chunk(Module, Function, Args, State), +diff --git a/lib/inets/test/httpd_SUITE.erl b/lib/inets/test/httpd_SUITE.erl +index 5639a23..3fbecb8 100644 +--- a/lib/inets/test/httpd_SUITE.erl ++++ b/lib/inets/test/httpd_SUITE.erl +@@ -122,7 +122,7 @@ groups() -> + disturbing_1_0, + reload_config_file + ]}, +- {post, [], [chunked_post, chunked_chunked_encoded_post, post_204]}, ++ {post, [], [chunked_post, chunked_chunked_encoded_post, post_204, multiple_content_length_header]}, + {basic_auth, [], [basic_auth_1_1, basic_auth_1_0, verify_href_1_1]}, + {auth_api, [], [auth_api_1_1, auth_api_1_0]}, + {auth_api_dets, [], [auth_api_1_1, auth_api_1_0]}, +@@ -1862,6 +1862,28 @@ tls_alert(Config) when is_list(Config) -> + Port = proplists:get_value(port, Config), + {error, {tls_alert, _}} = ssl:connect("localhost", Port, [{verify, verify_peer} | SSLOpts]). + ++%%------------------------------------------------------------------------- ++multiple_content_length_header() -> ++ [{doc, "Test Content-Length header"}]. ++ ++multiple_content_length_header(Config) when is_list(Config) -> ++ ok = http_status("POST / ", ++ {"Content-Length:0" ++ "\r\n", ++ ""}, ++ [{http_version, "HTTP/1.1"} |Config], ++ [{statuscode, 501}]), ++ ok = http_status("POST / ", ++ {"Content-Length:0" ++ "\r\n" ++ ++ "Content-Length:0" ++ "\r\n", ++ ""}, ++ [{http_version, "HTTP/1.1"} |Config], ++ [{statuscode, 501}]), ++ ok = http_status("POST / ", ++ {"Content-Length:1" ++ "\r\n" ++ ++ "Content-Length:0" ++ "\r\n", ++ "Z"}, ++ [{http_version, "HTTP/1.1"} |Config], ++ [{statuscode, 400}]). + %%-------------------------------------------------------------------- + %% Internal functions ----------------------------------- + %%-------------------------------------------------------------------- diff -Nru erlang-25.2.3+dfsg/debian/patches/CVE-2026-23942.patch erlang-25.2.3+dfsg/debian/patches/CVE-2026-23942.patch --- erlang-25.2.3+dfsg/debian/patches/CVE-2026-23942.patch 1970-01-01 00:00:00.000000000 +0000 +++ erlang-25.2.3+dfsg/debian/patches/CVE-2026-23942.patch 2026-04-07 10:54:55.000000000 +0000 @@ -0,0 +1,144 @@ +From: Erlang/OTP +Date: Thu, 12 Mar 2026 16:58:27 +0100 +Subject: Merge branch 'kuba/maint-27/ssh/sftp_path/OTP-20009' into maint-27 + +* kuba/maint-27/ssh/sftp_path/OTP-20009: + ssh: Fix path traversal vulnerability in ssh_sftpd root directory validation + +Origin: backport, https://github.com/erlang/otp/commit/9e0ac85d3485e7898e0da88a14be0ee2310a3b28 +Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1130912 +--- + lib/ssh/src/ssh_sftpd.erl | 12 ++++++++++- + lib/ssh/test/ssh_sftpd_SUITE.erl | 44 ++++++++++++++++++++++++++++------------ + 2 files changed, 42 insertions(+), 14 deletions(-) + +--- a/lib/ssh/src/ssh_sftpd.erl ++++ b/lib/ssh/src/ssh_sftpd.erl +@@ -873,7 +873,17 @@ + end. + + is_within_root(Root, File) -> +- lists:prefix(Root, File). ++ RootParts = filename:split(Root), ++ FileParts = filename:split(File), ++ is_prefix_components(RootParts, FileParts). ++ ++%% Verify if request file path is within configured root directory ++is_prefix_components([], _) -> ++ true; ++is_prefix_components([H|T1], [H|T2]) -> ++ is_prefix_components(T1, T2); ++is_prefix_components(_, _) -> ++ false. + + %% Remove leading slash (/), if any, in order to make the filename + %% relative (to the root) +--- a/lib/ssh/test/ssh_sftpd_SUITE.erl ++++ b/lib/ssh/test/ssh_sftpd_SUITE.erl +@@ -33,8 +33,7 @@ + end_per_testcase/2 + ]). + +--export([ +- access_outside_root/1, ++-export([access_outside_root/1, + links/1, + mk_rm_dir/1, + open_close_dir/1, +@@ -160,7 +159,7 @@ + RootDir = filename:join(BaseDir, a), + CWD = filename:join(RootDir, b), + %% Make the directory chain: +- ok = filelib:ensure_dir(filename:join(CWD, tmp)), ++ ok = filelib:ensure_path(CWD), + SubSystems = [ssh_sftpd:subsystem_spec([{root, RootDir}, + {cwd, CWD}])], + ssh:daemon(0, [{subsystems, SubSystems}|Options]); +@@ -221,7 +220,12 @@ + [{sftp, {Cm, Channel}}, {sftpd, Sftpd }| Config]. + + end_per_testcase(_TestCase, Config) -> +- catch ssh:stop_daemon(proplists:get_value(sftpd, Config)), ++ try ++ ssh:stop_daemon(proplists:get_value(sftpd, Config)) ++ catch ++ Class:Error:_Stack -> ++ ?CT_LOG("Class = ~p Error = ~p", [Class, Error]) ++ end, + {Cm, Channel} = proplists:get_value(sftp, Config), + ssh_connection:close(Cm, Channel), + ssh:close(Cm), +@@ -688,33 +692,47 @@ + access_outside_root(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + BaseDir = filename:join(PrivDir, access_outside_root), +- %% A file outside the tree below RootDir which is BaseDir/a +- %% Make the file BaseDir/bad : + BadFilePath = filename:join([BaseDir, bad]), + ok = file:write_file(BadFilePath, <<>>), ++ FileInSiblingDir = filename:join([BaseDir, a2, "secret.txt"]), ++ ok = filelib:ensure_dir(FileInSiblingDir), ++ ok = file:write_file(FileInSiblingDir, <<"secret">>), ++ TestFolderStructure = "" ++ ++ "PrivDir\n" ++ ++ "|-- access_outside_root (BaseDir)\n" ++ ++ "| |-- a (RootDir folder)\n" ++ ++ "| | +-- b (CWD folder)\n" ++ ++ "| |-- a2 (sibling folder with name prefix equal to RootDir)\n" ++ ++ "| | +-- secret.txt\n" ++ ++ "| +-- bad.txt\n" ++ ++ "", ++ ?CT_LOG("TestFolderStructure = ~n~s", [TestFolderStructure]), + {Cm, Channel} = proplists:get_value(sftp, Config), +- %% Try to access a file parallel to the RootDir: +- try_access("/../bad", Cm, Channel, 0), ++ %% Try to access a file parallel to the RootDir using parent traversal: ++ try_access("/../bad.txt", Cm, Channel, 0), + %% Try to access the same file via the CWD which is /b relative to the RootDir: +- try_access("../../bad", Cm, Channel, 1). +- ++ try_access("../../bad.txt", Cm, Channel, 1), ++ %% Try to access sibling folder name prefixed with root dir ++ try_access("/../a2/secret.txt", Cm, Channel, 2), ++ try_access("../../a2/secret.txt", Cm, Channel, 3). + + try_access(Path, Cm, Channel, ReqId) -> + Return = + open_file(Path, Cm, Channel, ReqId, + ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, + ?SSH_FXF_OPEN_EXISTING), +- ct:log("Try open ~p -> ~p",[Path,Return]), ++ ?CT_LOG("Try open ~p -> ~w",[Path,Return]), + case Return of + {ok, <>, _} -> ++ ?CT_LOG("Got the unexpected ?SSH_FXP_HANDLE",[]), + ct:fail("Could open a file outside the root tree!"); + {ok, <>, <<>>} -> + case Code of + ?SSH_FX_FILE_IS_A_DIRECTORY -> +- ct:log("Got the expected SSH_FX_FILE_IS_A_DIRECTORY status",[]), ++ ?CT_LOG("Got the expected SSH_FX_FILE_IS_A_DIRECTORY status",[]), + ok; + ?SSH_FX_FAILURE -> +- ct:log("Got the expected SSH_FX_FAILURE status",[]), ++ ?CT_LOG("Got the expected SSH_FX_FAILURE status",[]), + ok; + _ -> + case Rest of +--- a/lib/ssh/test/ssh_test_lib.hrl ++++ b/lib/ssh/test/ssh_test_lib.hrl +@@ -67,3 +67,14 @@ + ct:log("~p:~p Show file~n~s =~n~s~n", + [?MODULE,?LINE,File__, Contents__]) + end)(File)). ++ ++-define(SSH_TEST_LIB_FORMAT, "(~s ~p:~p in ~p) "). ++-define(SSH_TEST_LIB_ARGS, ++ [erlang:pid_to_list(self()), ?MODULE, ?LINE, ?FUNCTION_NAME]). ++-define(CT_LOG(F), ++ (ct:log(?SSH_TEST_LIB_FORMAT ++ F, ?SSH_TEST_LIB_ARGS, [esc_chars]))). ++-define(CT_LOG(F, Args), ++ (ct:log( ++ ?SSH_TEST_LIB_FORMAT ++ F, ++ ?SSH_TEST_LIB_ARGS ++ Args, ++ [esc_chars]))). diff -Nru erlang-25.2.3+dfsg/debian/patches/CVE-2026-23943.patch erlang-25.2.3+dfsg/debian/patches/CVE-2026-23943.patch --- erlang-25.2.3+dfsg/debian/patches/CVE-2026-23943.patch 1970-01-01 00:00:00.000000000 +0000 +++ erlang-25.2.3+dfsg/debian/patches/CVE-2026-23943.patch 2026-04-07 10:54:55.000000000 +0000 @@ -0,0 +1,476 @@ +From: Erlang/OTP +Date: Thu, 12 Mar 2026 16:58:28 +0100 +Subject: Merge branch + 'michal/maint-27/ssh/fix-unbounded-zlib-inflate/OTP-20011' into maint-27 + +* michal/maint-27/ssh/fix-unbounded-zlib-inflate/OTP-20011: + Add test for post-authentication compression + Add information about compression-based attacks to hardening guide + Adjust documentation to mention that zlib is disabled by default + Add tests that verify we disconnect on too large decompressed data + Always run compression test + Disable zlib by default and limit size of decompressed data + +Origin: backport, https://github.com/erlang/otp/commit/93073c3bd338c60cd2bae715ce6a1d4ffc1a8fd3 +Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1130912 +--- + lib/ssh/src/ssh_connection_handler.erl | 7 ++ + lib/ssh/src/ssh_transport.erl | 64 +++++++++++++-- + lib/ssh/test/ssh_basic_SUITE.erl | 66 ++++++++------- + lib/ssh/test/ssh_protocol_SUITE.erl | 146 ++++++++++++++++++++++++++++++++- + lib/ssh/test/ssh_trpt_test_lib.erl | 11 ++- + 5 files changed, 254 insertions(+), 40 deletions(-) + +--- a/lib/ssh/src/ssh_connection_handler.erl ++++ b/lib/ssh/src/ssh_connection_handler.erl +@@ -1188,6 +1188,13 @@ + io_lib:format("Bad packet: Size (~p bytes) exceeds max size", + [PacketLen]), + StateName, D0), ++ {stop, Shutdown, D}; ++ ++ {error, exceeds_max_decompressed_size} -> ++ {Shutdown, D} = ++ ?send_disconnect(?SSH_DISCONNECT_PROTOCOL_ERROR, ++ "Bad packet: Size after decompression exceeds max size", ++ StateName, D0), + {stop, Shutdown, D} + catch + Class:Reason0:Stacktrace -> +--- a/lib/ssh/src/ssh_transport.erl ++++ b/lib/ssh/src/ssh_transport.erl +@@ -192,6 +192,9 @@ + 'ssh-dss' + ]); + ++default_algorithms1(compression) -> ++ supported_algorithms(compression, same(['zlib'])); ++ + default_algorithms1(Alg) -> + supported_algorithms(Alg, []). + +@@ -1451,8 +1454,12 @@ + case unpack(pkt_type(CryptoAlg), mac_type(MacAlg), + DecryptedPfx, EncryptedBuffer, AEAD, TotalNeeded, Ssh0) of + {ok, Payload, NextPacketBytes, Ssh1} -> +- {Ssh, DecompressedPayload} = decompress(Ssh1, Payload), +- {packet_decrypted, DecompressedPayload, NextPacketBytes, Ssh}; ++ case decompress(Ssh1, Payload) of ++ {ok, Ssh, DecompressedPayload} -> ++ {packet_decrypted, DecompressedPayload, NextPacketBytes, Ssh}; ++ Other -> ++ Other ++ end; + Other -> + Other + end. +@@ -1968,15 +1975,56 @@ + {ok, Ssh#ssh{decompress = none, decompress_ctx = undefined}}. + + decompress(#ssh{decompress = none} = Ssh, Data) -> +- {Ssh, Data}; ++ {ok, Ssh, Data}; + decompress(#ssh{decompress = zlib, decompress_ctx = Context} = Ssh, Data) -> +- Decompressed = zlib:inflate(Context, Data), +- {Ssh, list_to_binary(Decompressed)}; ++ case safe_zlib_inflate(Context, Data) of ++ {ok, Decompressed} -> ++ {ok, Ssh, Decompressed}; ++ Other -> ++ Other ++ end; + decompress(#ssh{decompress = 'zlib@openssh.com', authenticated = false} = Ssh, Data) -> +- {Ssh, Data}; ++ {ok, Ssh, Data}; + decompress(#ssh{decompress = 'zlib@openssh.com', decompress_ctx = Context, authenticated = true} = Ssh, Data) -> +- Decompressed = zlib:inflate(Context, Data), +- {Ssh, list_to_binary(Decompressed)}. ++ case safe_zlib_inflate(Context, Data) of ++ {ok, Decompressed} -> ++ {ok, Ssh, Decompressed}; ++ Other -> ++ Other ++ end. ++ ++%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% ++%% Safe decompression loop ++%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% ++ ++safe_zlib_inflate(Context, Data) -> ++ safe_zlib_inflate_loop(Context, {0, []}, zlib:safeInflate(Context, Data)). ++ ++safe_zlib_inflate_loop(Context, {AccLen0, AccData}, {Status, Chunk}) ++ when Status == continue; Status == finished -> ++ ChunkLen = iolist_size(Chunk), ++ AccLen = AccLen0 + ChunkLen, ++ %% RFC 4253 section 6 ++ %% Align with packets that don't use compression, we can process payloads with length ++ %% that required minimum padding. ++ %% From ?SSH_MAX_PACKET_SIZE subtract: ++ %% 1 byte for length of padding_length field ++ %% 4 bytes for minimum allowed length of padding ++ %% We don't subtract: ++ %% 4 bytes for packet_length field - not included in packet_length ++ %% x bytes for mac (size depends on type of used mac) - not included in packet_length ++ case AccLen > (?SSH_MAX_PACKET_SIZE - 5) of ++ true -> ++ {error, exceeds_max_decompressed_size}; ++ false when Status == continue -> ++ Next = zlib:safeInflate(Context, []), ++ safe_zlib_inflate_loop(Context, {AccLen, [Chunk | AccData]}, Next); ++ false when Status == finished -> ++ Reversed = lists:reverse([Chunk | AccData]), ++ {ok, iolist_to_binary(Reversed)} ++ end; ++safe_zlib_inflate_loop(_Context, {_AccLen, _AccData}, {need_dictionary, Adler, _Chunk}) -> ++ erlang:error({need_dictionary, Adler}). + + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %% +--- a/lib/ssh/test/ssh_basic_SUITE.erl ++++ b/lib/ssh/test/ssh_basic_SUITE.erl +@@ -56,6 +56,7 @@ + double_close/1, + exec/1, + exec_compressed/1, ++ exec_compressed_post_auth_compression/1, + exec_with_io_in/1, + exec_with_io_out/1, + host_equal/2, +@@ -153,7 +154,7 @@ + ]}, + + {p_basic, [?PARALLEL], [send, peername_sockname, +- exec, exec_compressed, ++ exec, exec_compressed, exec_compressed_post_auth_compression, + exec_with_io_out, exec_with_io_in, + cli, cli_exit_normal, cli_exit_status, + idle_time_client, idle_time_server, +@@ -401,37 +402,42 @@ + %%-------------------------------------------------------------------- + %%% Test that compression option works + exec_compressed(Config) when is_list(Config) -> +- case ssh_test_lib:ssh_supports(zlib, compression) of +- false -> +- {skip, "zlib compression is not supported"}; ++ exec_compressed_helper(Config, 'zlib'). + +- true -> +- process_flag(trap_exit, true), +- SystemDir = filename:join(proplists:get_value(priv_dir, Config), system), +- UserDir = proplists:get_value(priv_dir, Config), +- +- {Pid, Host, Port} = ssh_test_lib:daemon([{system_dir, SystemDir},{user_dir, UserDir}, +- {preferred_algorithms,[{compression, [zlib]}]}, +- {failfun, fun ssh_test_lib:failfun/2}]), +- +- ConnectionRef = +- ssh_test_lib:connect(Host, Port, [{silently_accept_hosts, true}, +- {user_dir, UserDir}, +- {user_interaction, false}]), +- {ok, ChannelId} = ssh_connection:session_channel(ConnectionRef, infinity), +- success = ssh_connection:exec(ConnectionRef, ChannelId, +- "1+1.", infinity), +- Data = {ssh_cm, ConnectionRef, {data, ChannelId, 0, <<"2">>}}, +- case ssh_test_lib:receive_exec_result(Data) of +- expected -> +- ok; +- Other -> +- ct:fail(Other) +- end, +- ssh_test_lib:receive_exec_end(ConnectionRef, ChannelId), +- ssh:close(ConnectionRef), +- ssh:stop_daemon(Pid) +- end. ++%%-------------------------------------------------------------------- ++%%% Test that post authentication compression option works ++exec_compressed_post_auth_compression(Config) when is_list(Config) -> ++ exec_compressed_helper(Config, 'zlib@openssh.com'). ++ ++%%-------------------------------------------------------------------- ++%%% Exec compressed helper ++exec_compressed_helper(Config, CompressAlgorithm) -> ++ process_flag(trap_exit, true), ++ SystemDir = filename:join(proplists:get_value(priv_dir, Config), system), ++ UserDir = proplists:get_value(priv_dir, Config), ++ ++ {Pid, Host, Port} = ssh_test_lib:daemon([{system_dir, SystemDir},{user_dir, UserDir}, ++ {preferred_algorithms,[{compression, [CompressAlgorithm]}]}, ++ {failfun, fun ssh_test_lib:failfun/2}]), ++ ++ ConnectionRef = ++ ssh_test_lib:connect(Host, Port, [{silently_accept_hosts, true}, ++ {user_dir, UserDir}, ++ {user_interaction, false}, ++ {preferred_algorithms,[{compression, [CompressAlgorithm]}]}]), ++ {ok, ChannelId} = ssh_connection:session_channel(ConnectionRef, infinity), ++ success = ssh_connection:exec(ConnectionRef, ChannelId, ++ "1+1.", infinity), ++ Data = {ssh_cm, ConnectionRef, {data, ChannelId, 0, <<"2">>}}, ++ case ssh_test_lib:receive_exec_result(Data) of ++ expected -> ++ ok; ++ Other -> ++ ct:fail(Other) ++ end, ++ ssh_test_lib:receive_exec_end(ConnectionRef, ChannelId), ++ ssh:close(ConnectionRef), ++ ssh:stop_daemon(Pid). + + %%-------------------------------------------------------------------- + %%% Idle timeout test +--- a/lib/ssh/test/ssh_protocol_SUITE.erl ++++ b/lib/ssh/test/ssh_protocol_SUITE.erl +@@ -48,6 +48,10 @@ + bad_very_long_service_name/1, + client_handles_keyboard_interactive_0_pwds/1, + client_info_line/1, ++ decompression_bomb_client/1, ++ decompression_bomb_client_after_auth/1, ++ decompression_bomb_server/1, ++ decompression_bomb_server_after_auth/1, + do_gex_client_init/3, + do_gex_client_init_old/3, + empty_service_name/1, +@@ -132,7 +136,11 @@ + lib_no_match + ]}, + {packet_size_error, [], [packet_length_too_large, +- packet_length_too_short]}, ++ packet_length_too_short, ++ decompression_bomb_client, ++ decompression_bomb_client_after_auth, ++ decompression_bomb_server, ++ decompression_bomb_server_after_auth]}, + {field_size_error, [], [service_name_length_too_large, + service_name_length_too_short]}, + {kex, [], [custom_kexinit, +@@ -223,6 +231,8 @@ + [{preferred_algorithms,[{cipher,?DEFAULT_CIPHERS} + ]} + | Opts]); ++init_per_testcase(decompression_bomb_client, Config) -> ++ start_std_daemon(Config, [{preferred_algorithms, [{compression, ['zlib']}]}]); + init_per_testcase(_TestCase, Config) -> + check_std_daemon_works(Config, ?LINE). + +@@ -238,6 +248,8 @@ + TC == gex_client_old_request_exact ; + TC == gex_client_old_request_noexact -> + stop_std_daemon(Config); ++end_per_testcase(decompression_bomb_client, Config) -> ++ stop_std_daemon(Config); + end_per_testcase(_TestCase, Config) -> + check_std_daemon_works(Config, ?LINE). + +@@ -675,6 +687,138 @@ + ], InitialState). + + %%%-------------------------------------------------------------------- ++decompression_bomb_client(Config) -> ++ {ok, InitialState} = connect_and_kex(Config, ssh_trpt_test_lib:exec([]), ++ [{kex, [?DEFAULT_KEX]}, ++ {cipher, ?DEFAULT_CIPHERS}, ++ {compression, ['zlib']}], dh), ++ %% ?SSH_MAX_PACKET_SIZE - 9 is enough to trigger disconnect because Payload of ssh packet becomes: ++ %% 1 byte message identifier ++ %% 4 bytes length of data field ++ %% ?SSH_MAX_PACKET_SIZE - 9 bytes of data ++ %% This is longer than max decompressed Payload length which is ?SSH_MAX_PACKET_SIZE - 5 ++ %% See more in ssh_transport:safe_zlib_inflate_loop ++ Data = binary:copy(<<0>>, ?SSH_MAX_PACKET_SIZE - 9), ++ {ok, _} = ++ ssh_trpt_test_lib:exec([ ++ {send, #ssh_msg_ignore{data = Data}}, ++ {match, disconnect(), receive_msg} ++ ], InitialState). ++ ++%%%-------------------------------------------------------------------- ++decompression_bomb_client_after_auth(Config) -> ++ {ok, InitialState} = connect_and_kex(Config, ssh_trpt_test_lib:exec([]), ++ [{kex, [?DEFAULT_KEX]}, ++ {cipher, ?DEFAULT_CIPHERS}, ++ {compression, ['zlib@openssh.com']}], dh), ++ {User, Pwd} = server_user_password(Config), ++ {ok, AfterAuthState} = ++ ssh_trpt_test_lib:exec( ++ [{send, #ssh_msg_service_request{name = "ssh-userauth"}}, ++ {match, #ssh_msg_service_accept{name = "ssh-userauth"}, receive_msg}, ++ {send, #ssh_msg_userauth_request{user = User, ++ service = "ssh-connection", ++ method = "password", ++ data = <> ++ }}, ++ {match, #ssh_msg_userauth_success{_='_'}, receive_msg} ++ ], InitialState), ++ %% See explanation in decompression_bomb_client ++ Data = binary:copy(<<0>>, ?SSH_MAX_PACKET_SIZE - 9), ++ {ok, _} = ++ ssh_trpt_test_lib:exec([ ++ {send, #ssh_msg_ignore{data = Data}}, ++ {match, disconnect(), receive_msg} ++ ], AfterAuthState). ++ ++%%%-------------------------------------------------------------------- ++decompression_bomb_server(Config) -> ++ {ok, InitialState} = ssh_trpt_test_lib:exec(listen), ++ HostPort = ssh_trpt_test_lib:server_host_port(InitialState), ++ %% See explanation in decompression_bomb_client ++ Data = binary:copy(<<0>>, ?SSH_MAX_PACKET_SIZE - 9), ++ ServerPid = ++ spawn_link( ++ fun() -> ++ {ok, _} = ++ ssh_trpt_test_lib:exec( ++ [{set_options, [print_ops, print_messages]}, ++ {accept, [{system_dir, system_dir(Config)}, ++ {user_dir, user_dir(Config)}, ++ {preferred_algorithms,[{kex, [?DEFAULT_KEX]}, ++ {cipher, ?DEFAULT_CIPHERS}, ++ {compression, ['zlib']}]}]}, ++ receive_hello, ++ {send, hello}, ++ {send, ssh_msg_kexinit}, ++ {match, #ssh_msg_kexinit{_='_'}, receive_msg}, ++ {match, #ssh_msg_kexdh_init{_='_'}, receive_msg}, ++ {send, ssh_msg_kexdh_reply}, ++ {send, #ssh_msg_newkeys{}}, ++ {match, #ssh_msg_newkeys{_='_'}, receive_msg}, ++ {send, #ssh_msg_ignore{data = Data}}, ++ {match, disconnect(), receive_msg} ++ ], InitialState) ++ end), ++ Ref = monitor(process, ServerPid), ++ {error, "Protocol error"} = ++ std_connect(HostPort, Config, ++ [{silently_accept_hosts, true}, ++ {user_dir, user_dir(Config)}, ++ {user_interaction, false}, ++ {preferred_algorithms, [{compression,['zlib']}]}]), ++ receive ++ {'DOWN', Ref, process, ServerPid, normal} -> ok ++ end. ++ ++%%%-------------------------------------------------------------------- ++decompression_bomb_server_after_auth(Config) -> ++ {ok, InitialState} = ssh_trpt_test_lib:exec(listen), ++ HostPort = ssh_trpt_test_lib:server_host_port(InitialState), ++ %% See explanation in decompression_bomb_client ++ Data = binary:copy(<<0>>, ?SSH_MAX_PACKET_SIZE - 9), ++ ServerPid = ++ spawn_link( ++ fun() -> ++ {ok ,_} = ++ ssh_trpt_test_lib:exec( ++ [{set_options, [print_ops, print_messages]}, ++ {accept, [{system_dir, system_dir(Config)}, ++ {user_dir, user_dir(Config)}, ++ {preferred_algorithms,[{kex, [?DEFAULT_KEX]}, ++ {cipher, ?DEFAULT_CIPHERS}, ++ {compression, ['zlib@openssh.com']}]}]}, ++ receive_hello, ++ {send, hello}, ++ {send, ssh_msg_kexinit}, ++ {match, #ssh_msg_kexinit{_='_'}, receive_msg}, ++ {match, #ssh_msg_kexdh_init{_='_'}, receive_msg}, ++ {send, ssh_msg_kexdh_reply}, ++ {send, #ssh_msg_newkeys{}}, ++ {match, #ssh_msg_newkeys{_='_'}, receive_msg}, ++ {match, #ssh_msg_service_request{name="ssh-userauth"}, receive_msg}, ++ {send, #ssh_msg_service_accept{name="ssh-userauth"}}, ++ {match, #ssh_msg_userauth_request{service="ssh-connection", ++ method="none", ++ _='_'}, receive_msg}, ++ {send, #ssh_msg_userauth_success{}}, ++ {send, #ssh_msg_ignore{data = Data}}, ++ {match, disconnect(), receive_msg} ++ ], InitialState) ++ end), ++ Ref = monitor(process, ServerPid), ++ {ok, _} = ++ std_connect(HostPort, Config, ++ [{silently_accept_hosts, true}, ++ {user_dir, user_dir(Config)}, ++ {user_interaction, false}, ++ {preferred_algorithms, [{compression, ['zlib@openssh.com']}]}]), ++ receive ++ {'DOWN', Ref, process, ServerPid, normal} -> ok ++ end. ++ ++%%%-------------------------------------------------------------------- + service_name_length_too_large(Config) -> bad_service_name_length(Config, +4). + + service_name_length_too_short(Config) -> bad_service_name_length(Config, -4). +@@ -1406,12 +1550,14 @@ + connect_and_kex(Config, ssh_trpt_test_lib:exec([]) ). + + connect_and_kex(Config, InitialState) -> ++ ClientAlgs = [{kex,[?DEFAULT_KEX]}, {cipher,?DEFAULT_CIPHERS}], ++ connect_and_kex(Config, InitialState, ClientAlgs, dh). ++ ++connect_and_kex(Config, InitialState, ClientAlgs, Variant) -> + ssh_trpt_test_lib:exec( + [{connect, + server_host(Config),server_port(Config), +- [{preferred_algorithms,[{kex,[?DEFAULT_KEX]}, +- {cipher,?DEFAULT_CIPHERS} +- ]}, ++ [{preferred_algorithms,ClientAlgs}, + {silently_accept_hosts, true}, + {recv_ext_info, false}, + {user_dir, user_dir(Config)}, +@@ -1421,14 +1567,20 @@ + receive_hello, + {send, hello}, + {send, ssh_msg_kexinit}, +- {match, #ssh_msg_kexinit{_='_'}, receive_msg}, +- {send, ssh_msg_kexdh_init}, +- {match,# ssh_msg_kexdh_reply{_='_'}, receive_msg}, +- {send, #ssh_msg_newkeys{}}, +- {match, #ssh_msg_newkeys{_='_'}, receive_msg} +- ], ++ {match, #ssh_msg_kexinit{_='_'}, receive_msg}] ++ ++ get_kex_variant_ops(Variant) ++ ++ [{send, #ssh_msg_newkeys{}}, ++ {match, #ssh_msg_newkeys{_='_'}, receive_msg} ++ ], + InitialState). + ++get_kex_variant_ops(dh) -> ++ [{send, ssh_msg_kexdh_init}, ++ {match, #ssh_msg_kexdh_reply{_='_'}, receive_msg}]; ++get_kex_variant_ops(ecdh) -> ++ [{send, ssh_msg_kex_ecdh_init}, ++ {match, #ssh_msg_kex_ecdh_reply{_='_'}, receive_msg}]. ++ + %%%---------------------------------------------------------------- + + %%% For matching peer disconnection +--- a/lib/ssh/test/ssh_trpt_test_lib.erl ++++ b/lib/ssh/test/ssh_trpt_test_lib.erl +@@ -445,7 +445,13 @@ + fun(X) when X==true;X==detail -> {"Send~n~s~n",[format_msg(Msg)]} end), + {ok, Packet, C} = ssh_transport:new_keys_message(S#s.ssh), + send_bytes(Packet, S#s{ssh = C}); +- ++ ++send(S0, #ssh_msg_userauth_success{} = Msg) -> ++ S = opt(print_messages, S0, ++ fun(X) when X==true;X==detail -> {"Send~n~s~n",[format_msg(Msg)]} end), ++ {Packet, C} = ssh_transport:ssh_packet(Msg, S#s.ssh), ++ send_bytes(Packet, S#s{ssh = C#ssh{authenticated = true}, return_value = Msg}); ++ + send(S0, Msg) when is_tuple(Msg) -> + S = opt(print_messages, S0, + fun(X) when X==true;X==detail -> {"Send~n~s~n",[format_msg(Msg)]} end), +@@ -511,6 +517,9 @@ + #ssh_msg_newkeys{} -> + {ok, C} = ssh_transport:handle_new_keys(PeerMsg, S#s.ssh), + S#s{ssh=C}; ++ #ssh_msg_userauth_success{} -> % Always the client ++ C = S#s.ssh, ++ S#s{ssh = C#ssh{authenticated = true}}; + _ -> + S + end diff -Nru erlang-25.2.3+dfsg/debian/patches/series erlang-25.2.3+dfsg/debian/patches/series --- erlang-25.2.3+dfsg/debian/patches/series 2025-08-31 10:57:31.000000000 +0000 +++ erlang-25.2.3+dfsg/debian/patches/series 2026-04-07 10:54:55.000000000 +0000 @@ -16,3 +16,11 @@ ssh-strict-KEX-exchange-hardening.patch zip-sanitize-paths.patch xslt-for-each.patch +CVE-2025-48038.patch +CVE-2025-48039.patch +CVE-2025-48040.patch +CVE-2025-48041.patch +CVE-2026-23941.patch +CVE-2026-23942.patch +CVE-2026-23943.patch +CVE-2026-21620.patch diff -Nru erlang-25.2.3+dfsg/debian/salsa-ci.yml erlang-25.2.3+dfsg/debian/salsa-ci.yml --- erlang-25.2.3+dfsg/debian/salsa-ci.yml 1970-01-01 00:00:00.000000000 +0000 +++ erlang-25.2.3+dfsg/debian/salsa-ci.yml 2026-04-07 10:54:55.000000000 +0000 @@ -0,0 +1,6 @@ +--- +include: + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/recipes/debian.yml + +variables: + RELEASE: 'bookworm'