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