Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: [email protected]
Control: affects -1 + src:erlang
User: [email protected]
Usertags: pu
Hi, release team!
[ Reason ]
There was another set of vulnerabilities published for the
Erlang/OTP distribution, see [1] and [2]. The CVE affect
ithe tftp, inets and ssh Erlang applications. They do not warrant DSA
but I'd like to fix them in a stable point update. They are
already fixed in unstable and testing by uploading the
latest upstream release of Erlang 27.
[ Impact ]
1. Data access via TFTP or SFTP (if someone uses the tftp and/or ssh
sftpd Erlang module)
2. Incorrectly interpreted Content-Length header by the inets httpd
server may cause desynchronisation with a frontend (line Nginx)
if Erlang's inets is used as a backend.
3. Improper handling of highly compressed data may cause denial
of service for Erlang ssh implementation.
[ Tests ]
New Erlang package is manually tested, no new test errors are
detected. The newly introduced tests all went fine.
[ Risks ]
Low risk given that these patches add additional data checks
without changing core functionality.
[ Checklist ]
[x] *all* changes are documented in the d/changelog
[x] I reviewed all changes and I approve them
[x] attach debdiff against the package in (old)stable
[x] the issue is verified as fixed in unstable
[1] https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1128651
[2] https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1130912
--
Sergei Golovan
diff -Nru erlang-27.3.4.1+dfsg/debian/changelog
erlang-27.3.4.1+dfsg/debian/changelog
--- erlang-27.3.4.1+dfsg/debian/changelog 2025-07-08 10:27:28.000000000
+0300
+++ erlang-27.3.4.1+dfsg/debian/changelog 2026-03-30 13:26:03.000000000
+0300
@@ -1,5 +1,30 @@
+erlang (1:27.3.4.1+dfsg-1+deb13u2) trixie; urgency=medium
+
+ [ Lucas Kanashiro ]
+ * Fix CVE-2026-21620.
+ Relative Path Traversal, Improper Isolation or Compartmentalization
+ vulnerability in Erlang OTP (tftp_file modules). Closes: #1128651
+ * Fix CVE-2026-23941.
+ Inconsistent Interpretation of HTTP Requests ('HTTP Request Smuggling')
+ vulnerability in Erlang OTP (inets httpd module) allows HTTP Request
+ Smuggling.
+ - d/p/CVE-2026-23941.patch
+ * 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.
+ - d/p/CVE-2026-23942.patch
+ * 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.
+ - d/p/CVE-2026-23943.patch
+ Closes: #1130912
+
+ -- Sergei Golovan <[email protected]> Mon, 30 Mar 2026 13:26:03 +0300
+
erlang (1:27.3.4.1+dfsg-1+deb13u1) trixie; urgency=medium
* 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).
diff -Nru erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-21620.patch
erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-21620.patch
--- erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-21620.patch 1970-01-01
03:00:00.000000000 +0300
+++ erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-21620.patch 2026-03-30
13:26:03.000000000 +0300
@@ -0,0 +1,578 @@
+From: Erlang/OTP <[email protected]>
+Date: Thu, 19 Feb 2026 16:58:37 +0100
+Subject: Merge branch 'raimo/tftp/path-traversal-27/OTP-19981' into maint-27
+
+* raimo/tftp/path-traversal-27/OTP-19981:
+ Fix typos
+ Fix old timing sensitive test case
+ Document security considerations
+ Fix old timing sensitive test case
+ Test option root_dir
+ Rewrite old style catch
+ Validate initial options
+
+Origin: upstream,
https://github.com/erlang/otp/commit/3970738f687325138eb75f798054fa8960ac354e
+Bug-Debian: https://bugs.debian.org/1128651
+
+More info about this CVE:
https://github.com/erlang/otp/security/advisories/GHSA-hmrc-prh3-rpvp
+---
+ debian/patches/CVE-2026-21620.patch | 572 +++++++++++++++++++++++++++++++++
+ debian/patches/series | 1 +
+ lib/tftp/doc/guides/getting_started.md | 5 +-
+ lib/tftp/doc/guides/introduction.md | 15 +
+ lib/tftp/src/tftp.erl | 50 ++-
+ lib/tftp/src/tftp_file.erl | 121 +++----
+ lib/tftp/test/tftp_SUITE.erl | 119 +++++--
+ lib/tftp/test/tftp_test_lib.hrl | 5 +-
+ 8 files changed, 807 insertions(+), 81 deletions(-)
+ create mode 100644 debian/patches/CVE-2026-21620.patch
+
+diff --git a/lib/tftp/doc/guides/getting_started.md
b/lib/tftp/doc/guides/getting_started.md
+index e9112a8..fbdd203 100644
+--- a/lib/tftp/doc/guides/getting_started.md
++++ b/lib/tftp/doc/guides/getting_started.md
+@@ -30,13 +30,14 @@ a sample file using the TFTP client.
+ _Step 1._ Create a sample file to be used for the transfer:
+
+ ```text
+- $ echo "Erlang/OTP 21" > file.txt
++ $ echo "Erlang/OTP 21" > /tmp/file.txt
+ ```
+
+ _Step 2._ Start the TFTP server:
+
+ ```erlang
+- 1> {ok, Pid} = tftp:start([{port, 19999}]).
++ 1> Callback = {callback,{"",tftp_file,[{root_dir,"/tmp"}]}}.
++ 2> {ok, Pid} = tftp:start([{port, 19999}, Callback]).
+ {ok,<0.65.0>}
+ ```
+
+diff --git a/lib/tftp/doc/guides/introduction.md
b/lib/tftp/doc/guides/introduction.md
+index 55d35bd..d86f676 100644
+--- a/lib/tftp/doc/guides/introduction.md
++++ b/lib/tftp/doc/guides/introduction.md
+@@ -42,3 +42,18 @@ file system. TFTP is often installed with controls such
that only
+ files that have public read access are available via TFTP and writing
+ files via TFTP is disallowed."
+
++This essentially means that any machine on the network
++that can reach the TFTP server is able to read and write,
++without authentication, any file on the machine that runs
++the TFTP server, that the user (or group) that runs the TFTP server
++(in this case the Erlang VM) is allowed to read or write.
++The machine configuration has to be prepared for that.
++
++> #### Warning {: .warning }
++>
++> The default behavior mentioned above is in general very risky,
++> and as a remedy, this TFTP application's default callback
++> `tftp_file` implements an initial state option
++> `{root_dir,Dir}` that restricts the callback's file accesses
++> to `Dir` and subdirectories. It is recommended
++> to use that option when starting start this TFTP server.
+diff --git a/lib/tftp/src/tftp.erl b/lib/tftp/src/tftp.erl
+index fdbe527..bd8c017 100644
+--- a/lib/tftp/src/tftp.erl
++++ b/lib/tftp/src/tftp.erl
+@@ -41,10 +41,11 @@ Interface module for the `tftp` application.
+ ## Overwiew
+
+ This is a complete implementation of the following IETF standards:
+- RFC 1350, The TFTP Protocol (revision 2).
+- RFC 2347, TFTP Option Extension.
+- RFC 2348, TFTP Blocksize Option.
+- RFC 2349, TFTP Timeout Interval and Transfer Size Options.
++
++* [RFC 1350][], The TFTP Protocol (revision 2).
++* [RFC 2347][], TFTP Option Extension.
++* [RFC 2348][], TFTP Blocksize Option.
++* [RFC 2349][], TFTP Timeout Interval and Transfer Size Options.
+
+ The only feature that not is implemented in this release is
+ the "netascii" transfer mode.
+@@ -60,6 +61,11 @@ with a TFTP daemon and performs the actual transfer of the
file.
+ Most of the options are common for both the client and the server
+ side, but some of them differs a little.
+
++[RFC 1350]: https://datatracker.ietf.org/doc/html/rfc1350
++[RFC 2347]: https://datatracker.ietf.org/doc/html/rfc2347
++[RFC 2348]: https://datatracker.ietf.org/doc/html/rfc2348
++[RFC 2349]: https://datatracker.ietf.org/doc/html/rfc2349
++
+ ## Callbacks
+
+ A `tftp` callback module is to be implemented as a `tftp` behavior and export
+@@ -197,7 +203,7 @@ All options most of them common to the client and server.
+ Controls which features to reject. This is mostly useful for the server as
it
+ can restrict the use of certain TFTP options or read/write access.
+
+-- **`{callback, {RegExp ::string(), Module::module(), State :: term()}}`**
++- **`{callback, {RegExp ::string(), Module::module(), InitialState ::
term()}}`**
+
+ Registration of a callback module. When a file is to be transferred, its
local
+ filename is matched to the regular expressions of the registered callbacks.
+@@ -207,6 +213,24 @@ All options most of them common to the client and server.
+ The callback module must implement the `tftp` behavior, see
+ [callbacks](`m:tftp#callbacks`).
+
++ At the end of the list of callbacks there are always the default callbacks
++ `tftp_file` and `tftp_binary` with the `RegExp = ""` and `InitialState =
[]`.
++
++ The `InitialState` should be an option list, and the empty list
++ should be accepted by any callback module. The `tftp_file`
++ callback module accepts an `InitialState = [{root_dir, Dir}]`
++ that restrict local file operations to files in `Dir` and subdirectories.
++ All file names received in protocol requests, relative or absolute,
++ are regarded as relative to this directory.
++
++ > #### Warning {: .warning }
++ >
++ > The default callback module configuration allows access to any file
++ > on any local filesystem that is readable or writable by the user
++ > running the Erlang VM. This can be a security vulnerability.
++ > It is therefore recommended to explicitly configure the `tftp_file`
++ > callback module to use the `root_dir` option.
++
+ - **`{logger, module()}`**
+
+ Callback module for customized logging of errors, warnings, and info
messages.
+@@ -390,6 +414,22 @@ Starts a daemon process listening for UDP packets on a
port.
+
+ When it receives a request for read or write, it spawns a temporary
+ server process handling the actual transfer of the (virtual) file.
++
++The request filename is matched against the regexps of the registered
++callback modules, and the first match selects the callback
++to handle the request.
++
++If there are no registered callback modules, `tftp_file` is used,
++with the initial state `[]`.
++
++> #### Warning {: .warning }
++>
++> The default callback module configuration allows access to any file
++> on any local filesystem that is readable or writable by the user
++> running the Erlang VM. This can be a security vulnerability.
++> See the [`{callback,_}` option](`t:connection_option/0`)
++> at the start of this module reference for a remedy.
++
+ """.
+
+ -spec start(Options) -> {ok, Pid} | {error, Reason} when
+diff --git a/lib/tftp/src/tftp_file.erl b/lib/tftp/src/tftp_file.erl
+index 27d2b9c..87f0c76 100644
+--- a/lib/tftp/src/tftp_file.erl
++++ b/lib/tftp/src/tftp_file.erl
+@@ -1,7 +1,7 @@
+ %%
+ %% %CopyrightBegin%
+ %%
+-%% Copyright Ericsson AB 2005-2024. All Rights Reserved.
++%% Copyright Ericsson AB 2005-2026. All Rights Reserved.
+ %%
+ %% Licensed under the Apache License, Version 2.0 (the "License");
+ %% you may not use this file except in compliance with the License.
+@@ -44,10 +44,6 @@
+
+ -include_lib("kernel/include/file.hrl").
+
+--record(initial,
+- {filename,
+- is_native_ascii}).
+-
+ -record(state,
+ {access,
+ filename,
+@@ -96,8 +92,8 @@
+
+ prepare(_Peer, Access, Filename, Mode, SuggestedOptions, Initial) when
is_list(Initial) ->
+ %% Client side
+- case catch handle_options(Access, Filename, Mode, SuggestedOptions,
Initial) of
+- {ok, Filename2, IsNativeAscii, IsNetworkAscii, AcceptedOptions} ->
++ try handle_options(Access, Filename, Mode, SuggestedOptions, Initial) of
++ {Filename2, IsNativeAscii, IsNetworkAscii, AcceptedOptions} ->
+ State = #state{access = Access,
+ filename = Filename2,
+ is_native_ascii = IsNativeAscii,
+@@ -106,9 +102,9 @@ prepare(_Peer, Access, Filename, Mode, SuggestedOptions,
Initial) when is_list(I
+ blksize = lookup_blksize(AcceptedOptions),
+ count = 0,
+ buffer = []},
+- {ok, AcceptedOptions, State};
+- {error, {Code, Text}} ->
+- {error, {Code, Text}}
++ {ok, AcceptedOptions, State}
++ catch throw : Error ->
++ {error, Error}
+ end.
+
+ %% ---------------------------------------------------------
+@@ -154,12 +150,12 @@ open(Peer, Access, Filename, Mode, SuggestedOptions,
Initial) when is_list(Initi
+ end;
+ open(_Peer, Access, Filename, Mode, NegotiatedOptions, State) when
is_record(State, state) ->
+ %% Both sides
+- case catch handle_options(Access, Filename, Mode, NegotiatedOptions,
State) of
+- {ok, _Filename2, _IsNativeAscii, _IsNetworkAscii, Options}
+- when Options =:= NegotiatedOptions ->
+- do_open(State);
+- {error, {Code, Text}} ->
+- {error, {Code, Text}}
++ try handle_options(Access, Filename, Mode, NegotiatedOptions, State) of
++ {_Filename2, _IsNativeAscii, _IsNetworkAscii, Options}
++ when Options =:= NegotiatedOptions ->
++ do_open(State)
++ catch throw : Error ->
++ {error, Error}
+ end;
+ open(Peer, Access, Filename, Mode, NegotiatedOptions, State) ->
+ %% Handle upgrade from old releases. Please, remove this clause in next
release.
+@@ -295,45 +291,62 @@ abort(_Code, _Text, #state{fd = Fd, access = Access} =
State) ->
+ %%-------------------------------------------------------------------
+
+ 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({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}})
++ {Filename2, IsNativeAscii, IsNetworkAscii, Options2}.
++
++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({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({badop, "Internal error. root_dir is not absolute"}),
++ filelib:is_dir(RootDir) orelse
++ throw({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({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" ->
+@@ -361,15 +374,15 @@ do_handle_options(_Access, _Filename, []) ->
+
+
+ handle_integer(Access, Filename, Key, Val, Options, Min, Max) ->
+- case catch list_to_integer(Val) of
+- {'EXIT', _} ->
+- do_handle_options(Access, Filename, Options);
++ try list_to_integer(Val) of
+ Int when Int >= Min, Int =< Max ->
+ [{Key, Val} | do_handle_options(Access, Filename, Options)];
+ Int when Int >= Min, Max =:= infinity ->
+ [{Key, Val} | do_handle_options(Access, Filename, Options)];
+ _Int ->
+- throw({error, {badopt, "Illegal " ++ Key ++ " value " ++ Val}})
++ throw({badopt, "Illegal " ++ Key ++ " value " ++ Val})
++ catch error : _ ->
++ do_handle_options(Access, Filename, Options)
+ end.
+
+ lookup_blksize(Options) ->
+diff --git a/lib/tftp/test/tftp_SUITE.erl b/lib/tftp/test/tftp_SUITE.erl
+index 7289242..655874c 100644
+--- 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 @@ suite() -> [{ct_hooks,[ts_install_cth]}].
+ all() ->
+ [
+ simple,
++ root_dir,
+ extra,
+ reuse_connection,
+ resend_client,
+@@ -126,7 +122,7 @@ simple(suite) ->
+ 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",
+@@ -152,6 +148,73 @@ simple(Config) when is_list(Config) ->
+ ?VERIFY(ok, application:stop(tftp)),
+ 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
+ %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+@@ -164,7 +227,7 @@ extra(Config) when is_list(Config) ->
+ ?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",
+@@ -298,7 +361,7 @@ resend_client(suite) ->
+ [];
+ 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)),
+@@ -418,6 +481,9 @@ resend_read_client(Host, Port, BlkSize) ->
+ Ack5Bin = <<0, 4, 0, 5>>,
+ ?VERIFY(ok, gen_udp:send(Socket, Host, NewPort, Ack5Bin)),
+
++ %% Recv ACK #6
++ ?VERIFY({udp, Socket, Host, NewPort, <<0,3,0,6>>}, recv(Timeout)),
++
+ %% Close socket
+ ?VERIFY(ok, gen_udp:close(Socket)),
+
+@@ -693,11 +759,16 @@ resend_read_server(Host, BlkSize) ->
+ Data6Bin = list_to_binary([0, 3, 0, 6 | Block6]),
+ ?VERIFY(ok, gen_udp:send(ServerSocket, Host, ClientPort, Data6Bin)),
+
++ %% Recv ACK #6
++ Ack6Bin = <<0, 4, 0, 6>>,
++ ?VERIFY({udp, ServerSocket, Host, ClientPort, Ack6Bin}, recv(Timeout)),
++
+ %% Close daemon and server sockets
+ ?VERIFY(ok, gen_udp:close(ServerSocket)),
+ ?VERIFY(ok, gen_udp:close(DaemonSocket)),
+
+- ?VERIFY({ClientPid, {tftp_client_reply, {ok, Blob}}}, recv(Timeout)),
++ ?VERIFY({ClientPid, {tftp_client_reply, {ok, Blob}}},
++ recv(2 * (Timeout + timer:seconds(1)))),
+
+ ?VERIFY(timeout, recv(Timeout)),
+ ok.
+@@ -859,7 +930,7 @@ reuse_connection(suite) ->
+ [];
+ 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,
+@@ -933,7 +1004,7 @@ large_file(suite) ->
+ 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",
+@@ -968,3 +1039,15 @@ recv(Timeout) ->
+ 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 --git a/lib/tftp/test/tftp_test_lib.hrl b/lib/tftp/test/tftp_test_lib.hrl
+index eb8ed77..743b9a5 100644
+--- a/lib/tftp/test/tftp_test_lib.hrl
++++ b/lib/tftp/test/tftp_test_lib.hrl
+@@ -1,7 +1,7 @@
+ %%
+ %% %CopyrightBegin%
+ %%
+-%% Copyright Ericsson AB 2007-2018. All Rights Reserved.
++%% Copyright Ericsson AB 2007-2026. All Rights Reserved.
+ %%
+ %% Licensed under the Apache License, Version 2.0 (the "License");
+ %% you may not use this file except in compliance with the License.
+@@ -24,7 +24,8 @@
+ tftp_test_lib:log(Format, Args, ?MODULE, ?LINE)).
+
+ -define(ERROR(Reason),
+- tftp_test_lib:error(Reason, ?MODULE, ?LINE)).
++ erlang:error({?MODULE,?LINE,?FUNCTION_NAME,(Reason)})).
++ %% tftp_test_lib:error(Reason, ?MODULE, ?LINE)).
+
+ -define(VERIFY(Expected, Expr),
+ fun() ->
diff -Nru erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23941.patch
erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23941.patch
--- erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23941.patch 1970-01-01
03:00:00.000000000 +0300
+++ erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23941.patch 2026-03-30
13:26:03.000000000 +0300
@@ -0,0 +1,159 @@
+From: Erlang/OTP <[email protected]>
+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 f5f582d..6229928 100644
+--- a/lib/inets/src/http_server/httpd_request.erl
++++ b/lib/inets/src/http_server/httpd_request.erl
+@@ -211,7 +211,7 @@ parse_headers(<<?CR,?LF,?CR,?LF,Body/binary>>, 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)
+@@ -261,7 +261,7 @@ parse_headers(<<?CR,?LF, Octet, Rest/binary>>, 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);
+@@ -430,23 +430,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 048e6c1..aeb4e2d 100644
+--- a/lib/inets/src/http_server/httpd_request_handler.erl
++++ b/lib/inets/src/http_server/httpd_request_handler.erl
+@@ -260,12 +260,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 d91b8e1..0ee9fcc 100644
+--- a/lib/inets/test/httpd_SUITE.erl
++++ b/lib/inets/test/httpd_SUITE.erl
+@@ -126,7 +126,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]},
+@@ -2027,6 +2027,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-27.3.4.1+dfsg/debian/patches/CVE-2026-23942.patch
erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23942.patch
--- erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23942.patch 1970-01-01
03:00:00.000000000 +0300
+++ erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23942.patch 2026-03-30
13:26:03.000000000 +0300
@@ -0,0 +1,196 @@
+From: Erlang/OTP <[email protected]>
+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: upstream,
https://github.com/erlang/otp/commit/9e0ac85d3485e7898e0da88a14be0ee2310a3b28
+Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1130912
+---
+ lib/ssh/doc/guides/hardening.md | 25 +++++++++++++++++++++++
+ lib/ssh/src/ssh_sftpd.erl | 25 ++++++++++++++++++-----
+ lib/ssh/test/ssh_sftpd_SUITE.erl | 44 ++++++++++++++++++++++++++++------------
+ 3 files changed, 76 insertions(+), 18 deletions(-)
+
+--- a/lib/ssh/doc/guides/hardening.md
++++ b/lib/ssh/doc/guides/hardening.md
+@@ -241,3 +241,28 @@
+ The negotiation (session setup time) time can be limited with the _parameter_
+ `NegotiationTimeout` in a call establishing an ssh session, for example
+ `ssh:connect/3`.
++
++## SFTP Security
++
++### Root Directory Isolation
++
++The [`root`](`m:ssh_sftpd`) option restricts SFTP users to a
++specific directory tree, preventing access to files outside that directory.
++
++**Example:**
++
++```erlang
++ssh:daemon(Port, [
++ {subsystems, [ssh_sftpd:subsystem_spec([{root, "/home/sftpuser"}])]},
++ ...
++]).
++```
++
++**Important:** The `root` option is configured per daemon, not per user. All
++users connecting to the same daemon share the same root directory. For
per-user
++isolation, consider running separate daemon instances on different ports or
++using OS-level mechanisms (PAM chroot, containers, file permissions).
++
++**Defense-in-depth:** For high-security deployments, combine the `root` option
++with OS-level isolation mechanisms such as chroot jails, containers, or
++mandatory access control (SELinux, AppArmor).
+--- a/lib/ssh/src/ssh_sftpd.erl
++++ b/lib/ssh/src/ssh_sftpd.erl
+@@ -100,10 +100,15 @@
+ 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
+- above this root. If, for example, the root directory is set to `/tmp`, then
+- the user sees this directory as `/`. If the user then writes `cd /etc`, the
+- user moves to `/tmp/etc`.
++- **`root`** - Sets the SFTP root directory. The user cannot access files
++ outside this directory tree. If, for example, the root directory is set to
++ `/tmp`, then the user sees this directory as `/`. If the user then writes
++ `cd /etc`, the user moves to `/tmp/etc`.
++
++ Note: This provides application-level isolation. For additional security,
++ consider using OS-level chroot or similar mechanisms. See the
++ [SFTP Security](hardening.md#sftp-security) section in the Hardening guide
++ for deployment recommendations.
+
+ - **`sftpd_vsn`** - Sets the SFTP version to use. Defaults to 5. Version 6 is
+ under development and limited.
+@@ -922,7 +927,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
++ |-- access_outside_root (BaseDir)
++ | |-- a (RootDir folder)
++ | | +-- b (CWD folder)
++ | |-- a2 (sibling folder with name prefix equal to RootDir)
++ | | +-- secret.txt
++ | +-- bad.txt
++ """,
++ ?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, <<?SSH_FXP_HANDLE, ?UINT32(ReqId), _Handle0/binary>>, _} ->
++ ?CT_LOG("Got the unexpected ?SSH_FXP_HANDLE",[]),
+ ct:fail("Could open a file outside the root tree!");
+ {ok, <<?SSH_FXP_STATUS, ?UINT32(ReqId), ?UINT32(Code), Rest/binary>>,
<<>>} ->
+ 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-27.3.4.1+dfsg/debian/patches/CVE-2026-23943.patch
erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23943.patch
--- erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23943.patch 1970-01-01
03:00:00.000000000 +0300
+++ erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23943.patch 2026-03-30
13:26:03.000000000 +0300
@@ -0,0 +1,619 @@
+From: Erlang/OTP <[email protected]>
+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: upstream,
https://github.com/erlang/otp/commit/93073c3bd338c60cd2bae715ce6a1d4ffc1a8fd3
+Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1130912
+---
+ lib/ssh/doc/guides/configurations.md | 8 +-
+ lib/ssh/doc/guides/configure_algos.md | 24 +++---
+ lib/ssh/doc/guides/hardening.md | 20 +++++
+ lib/ssh/doc/ssh_app.md | 9 +-
+ 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 ++-
+ 9 files changed, 299 insertions(+), 56 deletions(-)
+
+--- a/lib/ssh/doc/guides/configurations.md
++++ b/lib/ssh/doc/guides/configurations.md
+@@ -185,8 +185,8 @@
+ 'hmac-sha1']},
+ {server2client,['hmac-sha2-256','hmac-sha2-512',
+ 'hmac-sha1']}]},
+- {compression,[{client2server,[none,'[email protected]',zlib]},
+- {server2client,[none,'[email protected]',zlib]}]}]
++ {compression,[{client2server,[none,'[email protected]']},
++ {server2client,[none,'[email protected]']}]}]
+ ```
+
+ Note that the algorithms in the file `ex2.config` is not yet applied. They
will
+@@ -202,8 +202,8 @@
+ {server2client,['aes192-ctr']}]},
+ {mac,[{client2server,['hmac-sha1']},
+ {server2client,['hmac-sha1']}]},
+- {compression,[{client2server,[none,'[email protected]',zlib]},
+- {server2client,[none,'[email protected]',zlib]}]}]
++ {compression,[{client2server,[none,'[email protected]']},
++ {server2client,[none,'[email protected]']}]}]
+ 4>
+ ```
+
+--- a/lib/ssh/doc/guides/configure_algos.md
++++ b/lib/ssh/doc/guides/configure_algos.md
+@@ -78,7 +78,9 @@
+ This list is also divided into two for the both directions
+
+ - **`compression`** - If and how to compress the message. Examples are `none`,
+- that is, no compression and `zlib`.
++ that is, no compression,
++ `zlib` for pre-authentication compression (disabled by default),
++ and `'[email protected]'` for post-authentication compression.
+
+ This list is also divided into two for the both directions
+
+@@ -120,8 +122,8 @@
+ 'hmac-sha1']},
+ {server2client,['hmac-sha2-256','hmac-sha2-512',
+ 'hmac-sha1']}]},
+- {compression,[{client2server,[none,'[email protected]',zlib]},
+- {server2client,[none,'[email protected]',zlib]}]}]
++ {compression,[{client2server,[none,'[email protected]']},
++ {server2client,[none,'[email protected]']}]}]
+ ```
+
+ {: #example_default_algorithms }
+@@ -174,8 +176,8 @@
+ 'hmac-sha1']},
+ {server2client,['hmac-sha2-256','hmac-sha2-512',
+ 'hmac-sha1']}]},
+- {compression,[{client2server,[none,'[email protected]',zlib]},
+- {server2client,[none,'[email protected]',zlib]}]}]
++ {compression,[{client2server,[none,'[email protected]']},
++ {server2client,[none,'[email protected]']}]}]
+ ```
+
+ Note that the unmentioned lists (`public_key`, `cipher`, `mac` and
+@@ -209,8 +211,8 @@
+ 'hmac-sha1']},
+ {server2client,['hmac-sha2-256','hmac-sha2-512',
+ 'hmac-sha1']}]},
+- {compression,[{client2server,[none,'[email protected]',zlib]},
+- {server2client,[none,'[email protected]',zlib]}]}]
++ {compression,[{client2server,[none,'[email protected]']},
++ {server2client,[none,'[email protected]']}]}]
+ ```
+
+ Note that both lists in `cipher` has been changed to the provided value
+@@ -246,8 +248,8 @@
+ 'hmac-sha1']},
+ {server2client,['hmac-sha2-256','hmac-sha2-512',
+ 'hmac-sha1']}]},
+- {compression,[{client2server,[none,'[email protected]',zlib]},
+- {server2client,[none,'[email protected]',zlib]}]}]
++ {compression,[{client2server,[none,'[email protected]']},
++ {server2client,[none,'[email protected]']}]}]
+ ```
+
+ ### Example 4
+@@ -341,8 +343,8 @@
+ 'hmac-sha1']},
+ {server2client,['hmac-sha2-256','hmac-sha2-512',
+ 'hmac-sha1']}]},
+- {compression,[{client2server,[none,'[email protected]',zlib]},
+- {server2client,[none,'[email protected]',zlib]}]}]
++ {compression,[{client2server,[none,'[email protected]']},
++ {server2client,[none,'[email protected]']}]}]
+ ```
+
+ And the result shows that the Diffie-Hellman Group1 is added at the head of
the
+--- a/lib/ssh/doc/guides/hardening.md
++++ b/lib/ssh/doc/guides/hardening.md
+@@ -93,6 +93,26 @@
+
+ 
+
++### Resilience to compression-based attacks
++
++SSH supports compression of the data stream.
++
++Reasonable finite
[max_sessions](`m:ssh#hardening_daemon_options-max_sessions`)
++option is highly recommended if compression is used to prevent excessive
resource
++usage by the compression library.
++See [Counters and parallelism](#counters-and-parallelism).
++
++The `'[email protected]'` algorithm is recommended because it only activates
++after successful authentication.
++
++The `'zlib'` algorithm is not recommended because it activates before
++authentication completes, allowing unauthenticated clients to expose potential
++vulnerabilities in compression libraries, and increases attack surface of
++compression-based side-channel and traffic-analysis attacks.
++
++In both algorithms decompression is protected by a size limit that prevents
++excessive memory consumption.
++
+ ## Verifying the remote daemon (server) in an SSH client
+
+ Every SSH server presents a public key - the _host key_ \- to the client while
+--- a/lib/ssh/doc/ssh_app.md
++++ b/lib/ssh/doc/ssh_app.md
+@@ -231,7 +231,14 @@
+ **Compression algorithms**
+ - none
+ - [email protected]
+- - zlib
++
++The following compression algorithm is disabled by default:
++
++- (zlib)
++
++It can be enabled with the
++[preferred_algorithms](`t:ssh:preferred_algorithms_common_option/0`) or
++[modify_algorithms](`t:ssh:modify_algorithms_common_option/0`) options.
+
+ ## Unicode support
+
+--- a/lib/ssh/src/ssh_connection_handler.erl
++++ b/lib/ssh/src/ssh_connection_handler.erl
+@@ -1228,6 +1228,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
+@@ -193,6 +193,9 @@
+ 'ssh-dss'
+ ]);
+
++default_algorithms1(compression) ->
++ supported_algorithms(compression, same(['zlib']));
++
+ default_algorithms1(Alg) ->
+ supported_algorithms(Alg, []).
+
+@@ -1449,8 +1452,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.
+@@ -1966,15 +1973,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 = '[email protected]', authenticated = false} =
Ssh, Data) ->
+- {Ssh, Data};
++ {ok, Ssh, Data};
+ decompress(#ssh{decompress = '[email protected]', 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, '[email protected]').
++
++%%--------------------------------------------------------------------
++%%% 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
+@@ -51,6 +51,10 @@
+ client_handles_keyboard_interactive_0_pwds/1,
+ client_handles_banner_keyboard_interactive/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,
+@@ -138,7 +142,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,
+@@ -231,6 +239,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).
+
+@@ -246,6 +256,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).
+
+@@ -683,6 +695,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,
['[email protected]']}], 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 = <<?BOOLEAN(?FALSE),
++
?STRING(unicode:characters_to_binary(Pwd))>>
++ }},
++ {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,
['[email protected]']}]}]},
++ 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,
['[email protected]']}]}]),
++ 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).
+@@ -1528,12 +1672,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)},
+@@ -1543,14 +1689,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}].
++
+ channel_close_timeout(Config) ->
+ {User,_Pwd} = server_user_password(Config),
+ %% Create a listening socket as server socket:
+--- a/lib/ssh/test/ssh_trpt_test_lib.erl
++++ b/lib/ssh/test/ssh_trpt_test_lib.erl
+@@ -446,7 +446,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),
+@@ -512,6 +518,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-27.3.4.1+dfsg/debian/patches/series
erlang-27.3.4.1+dfsg/debian/patches/series
--- erlang-27.3.4.1+dfsg/debian/patches/series 2025-07-08 10:27:28.000000000
+0300
+++ erlang-27.3.4.1+dfsg/debian/patches/series 2026-03-30 13:26:03.000000000
+0300
@@ -9,3 +9,7 @@
CVE-2025-48039.patch
CVE-2025-48040.patch
CVE-2025-48041.patch
+CVE-2026-21620.patch
+CVE-2026-23941.patch
+CVE-2026-23942.patch
+CVE-2026-23943.patch