File 1811-ssh-Fix-path-traversal-vulnerability-in-ssh_sftpd-ro.patch of Package erlang

From 621dbd10d8e90d8250d7cbd8b8dc19e127b9de5e Mon Sep 17 00:00:00 2001
From: Jakub Witczak <kuba@erlang.org>
Date: Fri, 27 Feb 2026 12:24:47 +0100
Subject: [PATCH] ssh: Fix path traversal vulnerability in ssh_sftpd root
 directory validation

The is_within_root/2 function used string prefix matching via
lists:prefix/2, which allowed access to sibling directories with
matching name prefixes (e.g., /tmp/root2/ when root is /tmp/root/).

Changed to use path component-based validation with filename:split/1
to ensure proper directory containment checking.

Added test cases for sibling directory bypass attempts in
access_outside_root/1 test case.

Security impact: Prevents authenticated SFTP users from escaping
their configured root directory jail via sibling directory access.
---
 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(-)

diff --git a/lib/ssh/doc/src/hardening.xml b/lib/ssh/doc/src/hardening.xml
index 6af69745b1..7376e06b85 100644
--- a/lib/ssh/doc/src/hardening.xml
+++ b/lib/ssh/doc/src/hardening.xml
@@ -293,4 +293,25 @@ end.
     </p>
   </section>
 
+  <section>
+    <title>SFTP Security</title>
+    <section>
+      <title>Root Directory Isolation</title>
+      <p>The <c>root</c> option (see <c>m:ssh_sftpd</c>) restricts SFTP users to a
+      specific directory tree, preventing access to files outside that directory.</p>
+      <p>Example:</p>
+      <code>ssh:daemon(Port, [
+{subsystems, [ssh_sftpd:subsystem_spec([{root, "/home/sftpuser"}])]},
+...
+]).</code>
+    <p>Important: The <c>root</c> 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).</p>
+    <p>Defense-in-depth: For high-security deployments, combine the <c>root</c> option
+    with OS-level isolation mechanisms such as chroot jails, containers,
+    or mandatory access control (SELinux, AppArmor).</p>
+    </section>
+  </section>
+
 </chapter>
diff --git a/lib/ssh/doc/src/ssh_sftpd.xml b/lib/ssh/doc/src/ssh_sftpd.xml
index 307f96b502..e3ff1385e6 100644
--- a/lib/ssh/doc/src/ssh_sftpd.xml
+++ b/lib/ssh/doc/src/ssh_sftpd.xml
@@ -79,11 +79,15 @@
 	  </item>
 	  <tag><c>root</c></tag>
 	  <item>
-	    <p>Sets the SFTP root directory. Then the user cannot see any files
-	    above this root. If, for example, the root directory is set to <c>/tmp</c>,
-	    then the user sees this directory as <c>/</c>. If the user then writes
-	    <c>cd /etc</c>, the user moves to <c>/tmp/etc</c>.
-	    </p>
+	    <p>Sets the SFTP root directory.
+	    The user cannot access files outside this directory tree.
+	    If, for example, the root directory is set to <c>/tmp</c>,
+	    then the user sees this directory as <c>/</c>.
+	    If the user then writes <c>cd /etc</c>, the user moves to <c>/tmp/etc</c>.</p>
+	    <p>Note: This provides application-level isolation. For additional security,
+	    consider using OS-level chroot or similar mechanisms.
+	    See the <seeguide marker="hardening#sftp-security">SFTP Security</seeguide>
+	    section in the Hardening guide for deployment recommendations.</p>
 	  </item>
 	  <tag><c>sftpd_vsn</c></tag>
 	  <item>
diff --git a/lib/ssh/src/ssh_sftpd.erl b/lib/ssh/src/ssh_sftpd.erl
index 307f96b502..e3ff1385e6 100644
--- a/lib/ssh/src/ssh_sftpd.erl
+++ b/lib/ssh/src/ssh_sftpd.erl
@@ -928,7 +933,17 @@ relate_file_name(File, #state{cwd = CWD, root = Root}, Canonicalize) ->
     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)
diff --git a/lib/ssh/test/ssh_sftpd_SUITE.erl b/lib/ssh/test/ssh_sftpd_SUITE.erl
index 284e666a63..81ebb75816 100644
--- a/lib/ssh/test/ssh_sftpd_SUITE.erl
+++ b/lib/ssh/test/ssh_sftpd_SUITE.erl
@@ -35,8 +35,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,
@@ -163,7 +162,7 @@ init_per_testcase(TestCase, Config) ->
                           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]);
@@ -224,7 +223,12 @@ init_per_testcase(TestCase, Config) ->
     [{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),
@@ -691,33 +695,47 @@ ver6_basic(Config) when is_list(Config) ->
 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
-- 
2.51.0

openSUSE Build Service is sponsored by