Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions SPECS/erlang/CVE-2026-23941.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
From 223b6fef7a46b989d88256539e8731de4fa642e7 Mon Sep 17 00:00:00 2001
From: Konrad Pietrzak <konrad@erlang.org>
Date: Wed, 25 Feb 2026 18:09:38 +0100
Subject: [PATCH] Prevent httpd from parsing HTTP requests when multiple
Content-Length headers are present

Signed-off-by: Azure Linux Security Servicing Account <azurelinux-security@microsoft.com>
Upstream-reference: https://github.com/erlang/otp/commit/e775a332f623851385ab6ddb866d9b150612ddf6.patch
---
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 162b5a8..5c6b2d2 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(<<?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)
@@ -260,7 +260,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);
@@ -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 17733d7..d010c30 100644
--- a/lib/inets/src/http_server/httpd_request_handler.erl
+++ b/lib/inets/src/http_server/httpd_request_handler.erl
@@ -248,12 +248,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 7e94c7a..c221632 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]},
@@ -1881,6 +1881,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 -----------------------------------
%%--------------------------------------------------------------------
--
2.45.4

188 changes: 188 additions & 0 deletions SPECS/erlang/CVE-2026-23942.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
From ebf8ce14f3a42749982f8abbacbf27f8d16b3305 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.

Signed-off-by: Azure Linux Security Servicing Account <azurelinux-security@microsoft.com>
Upstream-reference: https://github.com/erlang/otp/commit/5ed603a1211b83b8be2d1fc06d3f3bf30c3c9759.patch
---
lib/ssh/doc/src/hardening.xml | 18 +++++++++++++++
lib/ssh/doc/src/ssh_sftpd.xml | 13 +++++++----
lib/ssh/src/ssh_sftpd.erl | 12 +++++++++-
lib/ssh/test/ssh_sftpd_SUITE.erl | 39 +++++++++++++++++++++++---------
4 files changed, 65 insertions(+), 17 deletions(-)

diff --git a/lib/ssh/doc/src/hardening.xml b/lib/ssh/doc/src/hardening.xml
index cc530ac..0869be8 100644
--- a/lib/ssh/doc/src/hardening.xml
+++ b/lib/ssh/doc/src/hardening.xml
@@ -293,4 +293,22 @@ end.
</p>
</section>

+ <section>
+ <title>SFTP Security</title>
+ <section>
+ <title>Root Directory Isolation</title>
+ <p>The <seeerl marker="ssh_sftpd"><c>root</c></seeerl>
+ option restricts SFTP users to a specific directory tree,
+ preventing access to files outside that directory. For 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 `root` 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 cbe015f..2bd4918 100644
--- a/lib/ssh/doc/src/ssh_sftpd.xml
+++ b/lib/ssh/doc/src/ssh_sftpd.xml
@@ -79,11 +79,14 @@
</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 d02ece3..77fa495 100644
--- a/lib/ssh/src/ssh_sftpd.erl
+++ b/lib/ssh/src/ssh_sftpd.erl
@@ -871,7 +871,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 01321ed..b4ceb02 100644
--- 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 @@ 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]);
@@ -221,7 +220,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),
@@ -687,25 +691,38 @@ 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
--
2.45.4

Loading
Loading