File 4021-Fix-erl_tar-rejecting-safe-symlinks-with-.-in-target.patch of Package erlang

From 7677cd10c0e8a9e3ad480cdb0364370e59b90a3c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?=
 <eric.meadows.jonsson@gmail.com>
Date: Sat, 7 Mar 2026 14:03:53 +0100
Subject: [PATCH] Fix erl_tar rejecting safe symlinks with .. in target

Join symlink targets with the symlink's parent directory before
validating. Previously the raw target was validated in isolation,
causing safe symlinks like dir/link -> ../file to be incorrectly
rejected as unsafe.
---
 lib/stdlib/src/erl_tar.erl    |  8 ++--
 lib/stdlib/test/tar_SUITE.erl | 76 ++++++++++++++++++++++++++++++++++-
 2 files changed, 79 insertions(+), 5 deletions(-)

diff --git a/lib/stdlib/src/erl_tar.erl b/lib/stdlib/src/erl_tar.erl
index 78a2b236e4..58f498463b 100644
--- a/lib/stdlib/src/erl_tar.erl
+++ b/lib/stdlib/src/erl_tar.erl
@@ -2093,10 +2093,12 @@ make_safe_path(Path0, #read_opts{cwd=Cwd}) ->
         Path -> filename:absname(Path, Cwd)
     end.
 
-safe_link_name(#tar_header{linkname=Path0},#read_opts{cwd=Cwd} ) ->
-    case filelib:safe_relative_path(Path0, Cwd) of
+safe_link_name(#tar_header{name=Name,linkname=Path0},#read_opts{cwd=Cwd} ) ->
+    ParentDir = filename:dirname(Name),
+    ResolvedTarget = filename:join(ParentDir, Path0),
+    case filelib:safe_relative_path(ResolvedTarget, Cwd) of
         unsafe -> throw({error,{Path0,unsafe_symlink}});
-        Path -> Path
+        _Path -> Path0
     end.
 
 create_regular(Name, NameInArchive, Bin, Opts) ->
diff --git a/lib/stdlib/test/tar_SUITE.erl b/lib/stdlib/test/tar_SUITE.erl
index 89e0b3a5d5..b535773274 100644
--- a/lib/stdlib/test/tar_SUITE.erl
+++ b/lib/stdlib/test/tar_SUITE.erl
@@ -32,7 +32,7 @@
          sparse/1, init/1, leading_slash/1, dotdot/1,
          roundtrip_metadata/1, apply_file_info_opts/1,
          incompatible_options/1, table_absolute_names/1,
-         streamed_extract/1]).
+         streamed_extract/1, symlink_parent_dir/1]).
 
 -include_lib("common_test/include/ct.hrl").
 -include_lib("kernel/include/file.hrl").
@@ -48,7 +48,7 @@ all() ->
      read_other_implementations, bsdtgz,
      sparse,init,leading_slash,dotdot,roundtrip_metadata,
      apply_file_info_opts,incompatible_options, table_absolute_names,
-     streamed_extract].
+     streamed_extract, symlink_parent_dir].
 
 groups() -> 
     [].
@@ -721,6 +721,78 @@ symlink_vulnerability(Dir) ->
 
     ok.
 
+symlink_parent_dir(Config) when is_list(Config) ->
+    PrivDir = proplists:get_value(priv_dir, Config),
+    Dir = filename:join(PrivDir, "symlink_parent_dir"),
+    ok = file:make_dir(Dir),
+    Res = case make_symlink("dummy_target", filename:join(Dir, "test_link")) of
+              {error, enotsup} ->
+                  {skip, "Symbolic links not supported on this platform"};
+              ok ->
+                  file:delete(filename:join(Dir, "test_link")),
+                  symlink_parent_dir_safe(Dir),
+                  symlink_parent_dir_unsafe(Dir)
+          end,
+    delete_files([Dir]),
+    verify_ports(Config),
+    Res.
+
+symlink_parent_dir_safe(Dir) ->
+    %% dir/link -> ../file is safe (resolves to file within extraction dir)
+    SafeDir1 = filename:join(Dir, "safe1"),
+    ok = file:make_dir(SafeDir1),
+    ok = file:set_cwd(SafeDir1),
+    ok = file:make_dir("dir"),
+    ok = file:write_file("file", <<"safe1">>),
+    ok = file:make_symlink("../file", filename:join("dir", "link")),
+    ok = erl_tar:create("test.tar", ["dir/link", "file"]),
+    ExtractDir1 = filename:join(SafeDir1, "extracted"),
+    ok = file:make_dir(ExtractDir1),
+    ok = erl_tar:extract("test.tar", [{cwd, ExtractDir1}]),
+    {ok, #file_info{type=symlink}} = file:read_link_info(filename:join([ExtractDir1, "dir", "link"])),
+    {ok, "../file"} = file:read_link(filename:join([ExtractDir1, "dir", "link"])),
+
+    %% a/b/link -> ../../file is safe (resolves to file within extraction dir)
+    SafeDir2 = filename:join(Dir, "safe2"),
+    ok = file:make_dir(SafeDir2),
+    ok = file:set_cwd(SafeDir2),
+    ok = filelib:ensure_dir(filename:join(["a", "b", "dummy"])),
+    ok = file:write_file("file", <<"safe2">>),
+    ok = file:make_symlink("../../file", filename:join(["a", "b", "link"])),
+    ok = erl_tar:create("test.tar", ["a/b/link", "file"]),
+    ExtractDir2 = filename:join(SafeDir2, "extracted"),
+    ok = file:make_dir(ExtractDir2),
+    ok = erl_tar:extract("test.tar", [{cwd, ExtractDir2}]),
+    {ok, #file_info{type=symlink}} = file:read_link_info(filename:join([ExtractDir2, "a", "b", "link"])),
+    {ok, "../../file"} = file:read_link(filename:join([ExtractDir2, "a", "b", "link"])),
+
+    ok.
+
+symlink_parent_dir_unsafe(Dir) ->
+    %% dir/link -> ../../escape is unsafe (escapes extraction dir)
+    UnsafeDir2 = filename:join(Dir, "unsafe2"),
+    ok = file:make_dir(UnsafeDir2),
+    ok = file:set_cwd(UnsafeDir2),
+    ok = file:make_dir("dir"),
+    ok = file:make_symlink("../../escape", filename:join("dir", "link")),
+    ok = erl_tar:create("test.tar", ["dir/link"]),
+    ExtractDir2 = filename:join(UnsafeDir2, "extracted"),
+    ok = file:make_dir(ExtractDir2),
+    {error,{"../../escape",unsafe_symlink}} = erl_tar:extract("test.tar", [{cwd, ExtractDir2}]),
+
+    %% dir/link -> /etc/passwd is unsafe (absolute path)
+    UnsafeDir3 = filename:join(Dir, "unsafe3"),
+    ok = file:make_dir(UnsafeDir3),
+    ok = file:set_cwd(UnsafeDir3),
+    ok = file:make_dir("dir"),
+    ok = file:make_symlink("/etc/passwd", filename:join("dir", "link")),
+    ok = erl_tar:create("test.tar", ["dir/link"]),
+    ExtractDir3 = filename:join(UnsafeDir3, "extracted"),
+    ok = file:make_dir(ExtractDir3),
+    {error,{"/etc/passwd",unsafe_symlink}} = erl_tar:extract("test.tar", [{cwd, ExtractDir3}]),
+
+    ok.
+
 init(Config) when is_list(Config) ->
     PrivDir = proplists:get_value(priv_dir, Config),
     ok = file:set_cwd(PrivDir),
-- 
2.51.0

openSUSE Build Service is sponsored by