Skip to content

Commit 31d7b55

Browse files
authored
feat: add link checking for BinaryTar (#2280)
1 parent 722a79d commit 31d7b55

3 files changed

Lines changed: 121 additions & 17 deletions

File tree

apiserver/paasng/paasng/platform/sourcectl/package/client.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import abc
1919
import logging
2020
import os
21+
import os.path
2122
import re
2223
import subprocess
2324
import tarfile
@@ -97,7 +98,7 @@ def __init__(
9798
raise ValueError("nothing to open")
9899
if file_path and file_obj:
99100
raise ValueError("file_path and file_obj cannot be provided at the same time")
100-
self.tar = tarfile.open(name=file_path, fileobj=file_obj, mode=mode)
101+
self.tar = tarfile.open(name=file_path, fileobj=file_obj, mode=mode) # noqa: SIM115
101102
self.relative_path = relative_path
102103

103104
def read_file(self, file_path: str) -> bytes:
@@ -130,8 +131,8 @@ class BinaryTarClient(BasePackageClient):
130131
When handling big tarball(>=100mb), will faster than tarfile 10 seconds in the special testcase.
131132
"""
132133

133-
def __init__(self, filepath: Union[str, Path]):
134-
self.filepath = Path(filepath)
134+
def __init__(self, file_path: Union[str, Path]):
135+
self.filepath = Path(file_path)
135136

136137
def read_file(self, filename) -> bytes:
137138
"""Extract a filename from the archive as bytes.
@@ -140,6 +141,7 @@ def read_file(self, filename) -> bytes:
140141
:return: bytes contents of the file.
141142
:raises InvalidPackageFileFormatError: The file is not a valid tar file, it's content
142143
might be corrupt.
144+
:raise RuntimeError: Raised if unexpected errors occur.
143145
"""
144146
with generate_temp_dir() as temp_dir:
145147
p = subprocess.Popen(
@@ -157,14 +159,32 @@ def read_file(self, filename) -> bytes:
157159
raise FileDoesNotExistError(f"Failed to extractfile from the tarball, error: {stderr!r}")
158160
else:
159161
raise RuntimeError(f"Failed to extractfile from the tarball, error: {stderr!r}")
160-
return (temp_dir / filename).read_bytes()
162+
163+
filepath = temp_dir / filename
164+
165+
# Check if the file is a symbolic link and it's inside the directory
166+
real_path = os.path.realpath(filepath)
167+
if os.path.commonpath([temp_dir, real_path]) != str(temp_dir):
168+
raise RuntimeError(f"Extracted file {filepath} is outside the target directory.")
169+
return filepath.read_bytes()
161170

162171
def export(self, local_path: str):
163172
"""Extract all members from the archive to the current working directory
173+
164174
:param working_dir: working directory
175+
:raise RuntimeError: Raised if unexpected errors occur.
165176
"""
166177
uncompress_directory(source_path=self.filepath, target_path=local_path)
167178

179+
# Security check: traverse the directory to check if any link files is outside the target directory
180+
for dirpath, dirnames, filenames in os.walk(local_path):
181+
for name in dirnames + filenames:
182+
if not Path(os.path.join(dirpath, name)).is_symlink():
183+
continue
184+
real_path = os.path.realpath(os.path.join(dirpath, name))
185+
if os.path.commonpath([local_path, real_path]) != str(local_path):
186+
raise RuntimeError(f"Extracted file {real_path} is outside the target directory.")
187+
168188
def close(self):
169189
"""Nothing need to close."""
170190

apiserver/paasng/tests/paasng/platform/sourcectl/packages/test_client.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,75 @@ def test_export(self, client_cls, archive_maker, contents):
155155
assert {str(child.relative_to(working_dir)) for child in working_dir.iterdir()} == set(contents.keys())
156156

157157

158+
@pytest.mark.parametrize(
159+
("client_cls", "archive_maker"),
160+
[
161+
(TarClient, gen_tar),
162+
(GenericLocalClient, gen_tar),
163+
],
164+
)
165+
class TestTarClientsShouldNotReadOutside:
166+
@pytest.mark.parametrize(
167+
("symbolic_links", "filename"),
168+
[
169+
({"passwd": "/etc/passwd"}, "./passwd"),
170+
({"passwd": "../../../../../../../../../etc/passwd"}, "./passwd"),
171+
],
172+
)
173+
def test_read_file_should_fail(self, client_cls, archive_maker, symbolic_links, filename):
174+
with generate_temp_file() as file_path:
175+
archive_maker(file_path, symbolic_links=symbolic_links)
176+
cli: BasePackageClient = client_cls(file_path=file_path)
177+
with pytest.raises(KeyError, match="linkname .* not found"):
178+
cli.read_file(filename)
179+
180+
@pytest.mark.parametrize(
181+
"symbolic_links",
182+
[
183+
({"passwd": "/etc"}),
184+
({"passwd": "../../../../../../../../../etc/passwd"}),
185+
],
186+
)
187+
def test_export_should_fail(self, client_cls, archive_maker, symbolic_links):
188+
with generate_temp_file() as file_path, generate_temp_dir() as working_dir:
189+
archive_maker(file_path, symbolic_links=symbolic_links)
190+
cli = client_cls(file_path)
191+
with pytest.raises(tarfile.FilterError): # type: ignore[attr-defined]
192+
cli.export(working_dir)
193+
194+
# TODO: Add malformed tar file which uses bad names
195+
196+
197+
class TestBinaryTarClientsShouldNotReadOutside:
198+
@pytest.mark.parametrize(
199+
("symbolic_links", "filename"),
200+
[
201+
({"passwd": "/etc/passwd"}, "./passwd"),
202+
({"passwd": "../../../../../../../../../etc/passwd"}, "./passwd"),
203+
],
204+
)
205+
def test_read_file_should_fail(self, symbolic_links, filename):
206+
with generate_temp_file() as file_path:
207+
gen_tar(file_path, symbolic_links=symbolic_links)
208+
cli = BinaryTarClient(file_path=file_path)
209+
with pytest.raises(RuntimeError, match=".*outside the target directory."):
210+
cli.read_file(filename)
211+
212+
@pytest.mark.parametrize(
213+
"symbolic_links",
214+
[
215+
({"passwd": "/etc"}),
216+
({"passwd": "../../../../../../../../../etc/passwd"}),
217+
],
218+
)
219+
def test_export_should_fail(self, symbolic_links):
220+
with generate_temp_file() as file_path, generate_temp_dir() as working_dir:
221+
gen_tar(file_path, symbolic_links=symbolic_links)
222+
cli = BinaryTarClient(file_path)
223+
with pytest.raises(RuntimeError):
224+
cli.export(str(working_dir))
225+
226+
158227
@pytest.mark.parametrize("archive_maker", [gen_tar, gen_zip])
159228
class TestGenericRemoteClient:
160229
@pytest.mark.parametrize(

apiserver/paasng/tests/paasng/platform/sourcectl/packages/utils.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,30 +57,45 @@ def ensure_parent_exists(path: Path):
5757

5858

5959
@contextmanager
60-
def dump_contents_to_fs(contents: Dict[str, Union[str, bytes]]):
60+
def dump_contents_to_fs(
61+
contents: Dict[str, Union[str, bytes]] | None = None,
62+
symbolic_links: Dict[str, str] | None = None,
63+
):
6164
"""a helper to dumps contents fo file-system
6265
6366
:param contents Dict[str, str]: a dict, the key is filename, and value is the content.
67+
:param symbolic_links: a dict, format: {link_name: target_file}.
6468
"""
6569
with generate_temp_dir() as source_dir:
66-
for file_name, content in contents.items():
67-
target = source_dir / file_name
68-
ensure_parent_exists(target)
69-
70-
if isinstance(content, bytes):
71-
target.write_bytes(content)
72-
else:
73-
target.write_text(content)
70+
if contents:
71+
for file_name, content in contents.items():
72+
target = source_dir / file_name
73+
ensure_parent_exists(target)
74+
75+
if isinstance(content, bytes):
76+
target.write_bytes(content)
77+
else:
78+
target.write_text(content)
79+
80+
if symbolic_links:
81+
for link_name, target_file in symbolic_links.items():
82+
target = source_dir / link_name
83+
ensure_parent_exists(target)
84+
target.symlink_to(target_file, target_is_directory=Path(target_file).is_dir())
7485
yield source_dir
7586

7687

77-
def gen_tar(target_path, contents: Dict[str, Union[str, bytes]]):
78-
with dump_contents_to_fs(contents) as source_dir:
88+
def gen_tar(
89+
target_path, contents: Dict[str, Union[str, bytes]] | None = None, symbolic_links: Dict[str, str] | None = None
90+
):
91+
with dump_contents_to_fs(contents=contents, symbolic_links=symbolic_links) as source_dir:
7992
compress_directory(source_dir, target_path)
8093

8194

82-
def gen_zip(target_path, contents: Dict[str, Union[str, bytes]]):
83-
with dump_contents_to_fs(contents) as source_dir:
95+
def gen_zip(
96+
target_path, contents: Dict[str, Union[str, bytes]] | None = None, symbolic_links: Dict[str, str] | None = None
97+
):
98+
with dump_contents_to_fs(contents=contents, symbolic_links=symbolic_links) as source_dir:
8499
filename = shutil.make_archive(target_path, format="zip", root_dir=source_dir)
85100
if filename != target_path:
86101
shutil.move(filename, target_path)

0 commit comments

Comments
 (0)