Skip to content

Commit f2b9231

Browse files
authored
Merge pull request #13550 from dkjsone/handle_symlink
Add a function to check the security of symbolic links.
2 parents 3d88026 + b154d06 commit f2b9231

File tree

3 files changed

+170
-0
lines changed

3 files changed

+170
-0
lines changed

news/13550.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
For Python versions that do not support PEP 706, pip will now raise an installation error for a
2+
source distribution when it includes a symlink that points outside the source distribution archive.

src/pip/_internal/utils/unpacking.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,13 +248,30 @@ def pip_filter(member: tarfile.TarInfo, path: str) -> tarfile.TarInfo:
248248
tar.close()
249249

250250

251+
def is_symlink_target_in_tar(tar: tarfile.TarFile, tarinfo: tarfile.TarInfo) -> bool:
252+
"""Check if the file pointed to by the symbolic link is in the tar archive"""
253+
linkname = os.path.join(os.path.dirname(tarinfo.name), tarinfo.linkname)
254+
255+
linkname = os.path.normpath(linkname)
256+
linkname = linkname.replace("\\", "/")
257+
258+
try:
259+
tar.getmember(linkname)
260+
return True
261+
except KeyError:
262+
return False
263+
264+
251265
def _untar_without_filter(
252266
filename: str,
253267
location: str,
254268
tar: tarfile.TarFile,
255269
leading: bool,
256270
) -> None:
257271
"""Fallback for Python without tarfile.data_filter"""
272+
# NOTE: This function can be removed once pip requires CPython ≥ 3.12.​
273+
# PEP 706 added tarfile.data_filter, made tarfile extraction operations more secure.
274+
# This feature is fully supported from CPython 3.12 onward.
258275
for member in tar.getmembers():
259276
fn = member.name
260277
if leading:
@@ -269,6 +286,14 @@ def _untar_without_filter(
269286
if member.isdir():
270287
ensure_dir(path)
271288
elif member.issym():
289+
if not is_symlink_target_in_tar(tar, member):
290+
message = (
291+
"The tar file ({}) has a file ({}) trying to install "
292+
"outside target directory ({})"
293+
)
294+
raise InstallationError(
295+
message.format(filename, member.name, member.linkname)
296+
)
272297
try:
273298
tar._extract_member(member, path)
274299
except Exception as exc:

tests/unit/test_utils_unpacking.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pathlib import Path
1111

1212
import pytest
13+
from _pytest.monkeypatch import MonkeyPatch
1314

1415
from pip._internal.exceptions import InstallationError
1516
from pip._internal.utils.unpacking import is_within_directory, untar_file, unzip_file
@@ -238,6 +239,148 @@ def test_unpack_tar_links(self, input_prefix: str, unpack_prefix: str) -> None:
238239
with open(os.path.join(unpack_dir, "symlink.txt"), "rb") as f:
239240
assert f.read() == content
240241

242+
def test_unpack_normal_tar_link1_no_data_filter(
243+
self, monkeypatch: MonkeyPatch
244+
) -> None:
245+
"""
246+
Test unpacking a normal tar with file containing soft links, but no data_filter
247+
"""
248+
if hasattr(tarfile, "data_filter"):
249+
monkeypatch.delattr("tarfile.data_filter")
250+
251+
tar_filename = "test_tar_links_no_data_filter.tar"
252+
tar_filepath = os.path.join(self.tempdir, tar_filename)
253+
254+
extract_path = os.path.join(self.tempdir, "extract_path")
255+
256+
with tarfile.open(tar_filepath, "w") as tar:
257+
file_data = io.BytesIO(b"normal\n")
258+
normal_file_tarinfo = tarfile.TarInfo(name="normal_file")
259+
normal_file_tarinfo.size = len(file_data.getbuffer())
260+
tar.addfile(normal_file_tarinfo, fileobj=file_data)
261+
262+
info = tarfile.TarInfo("normal_symlink")
263+
info.type = tarfile.SYMTYPE
264+
info.linkpath = "normal_file"
265+
tar.addfile(info)
266+
267+
untar_file(tar_filepath, extract_path)
268+
269+
assert os.path.islink(os.path.join(extract_path, "normal_symlink"))
270+
271+
link_path = os.readlink(os.path.join(extract_path, "normal_symlink"))
272+
assert link_path == "normal_file"
273+
274+
with open(os.path.join(extract_path, "normal_symlink"), "rb") as f:
275+
assert f.read() == b"normal\n"
276+
277+
def test_unpack_normal_tar_link2_no_data_filter(
278+
self, monkeypatch: MonkeyPatch
279+
) -> None:
280+
"""
281+
Test unpacking a normal tar with file containing soft links, but no data_filter
282+
"""
283+
if hasattr(tarfile, "data_filter"):
284+
monkeypatch.delattr("tarfile.data_filter")
285+
286+
tar_filename = "test_tar_links_no_data_filter.tar"
287+
tar_filepath = os.path.join(self.tempdir, tar_filename)
288+
289+
extract_path = os.path.join(self.tempdir, "extract_path")
290+
291+
with tarfile.open(tar_filepath, "w") as tar:
292+
file_data = io.BytesIO(b"normal\n")
293+
normal_file_tarinfo = tarfile.TarInfo(name="normal_file")
294+
normal_file_tarinfo.size = len(file_data.getbuffer())
295+
tar.addfile(normal_file_tarinfo, fileobj=file_data)
296+
297+
info = tarfile.TarInfo("sub/normal_symlink")
298+
info.type = tarfile.SYMTYPE
299+
info.linkpath = ".." + os.path.sep + "normal_file"
300+
tar.addfile(info)
301+
302+
untar_file(tar_filepath, extract_path)
303+
304+
assert os.path.islink(os.path.join(extract_path, "sub", "normal_symlink"))
305+
306+
link_path = os.readlink(os.path.join(extract_path, "sub", "normal_symlink"))
307+
assert link_path == ".." + os.path.sep + "normal_file"
308+
309+
with open(os.path.join(extract_path, "sub", "normal_symlink"), "rb") as f:
310+
assert f.read() == b"normal\n"
311+
312+
def test_unpack_evil_tar_link1_no_data_filter(
313+
self, monkeypatch: MonkeyPatch
314+
) -> None:
315+
"""
316+
Test unpacking a evil tar with file containing soft links, but no data_filter
317+
"""
318+
if hasattr(tarfile, "data_filter"):
319+
monkeypatch.delattr("tarfile.data_filter")
320+
321+
tar_filename = "test_tar_links_no_data_filter.tar"
322+
tar_filepath = os.path.join(self.tempdir, tar_filename)
323+
324+
import_filename = "import_file"
325+
import_filepath = os.path.join(self.tempdir, import_filename)
326+
open(import_filepath, "w").close()
327+
328+
extract_path = os.path.join(self.tempdir, "extract_path")
329+
330+
with tarfile.open(tar_filepath, "w") as tar:
331+
info = tarfile.TarInfo("evil_symlink")
332+
info.type = tarfile.SYMTYPE
333+
info.linkpath = import_filepath
334+
tar.addfile(info)
335+
336+
with pytest.raises(InstallationError) as e:
337+
untar_file(tar_filepath, extract_path)
338+
339+
msg = (
340+
"The tar file ({}) has a file ({}) trying to install outside "
341+
"target directory ({})"
342+
)
343+
assert msg.format(tar_filepath, "evil_symlink", import_filepath) in str(e.value)
344+
345+
assert not os.path.exists(os.path.join(extract_path, "evil_symlink"))
346+
347+
def test_unpack_evil_tar_link2_no_data_filter(
348+
self, monkeypatch: MonkeyPatch
349+
) -> None:
350+
"""
351+
Test unpacking a evil tar with file containing soft links, but no data_filter
352+
"""
353+
if hasattr(tarfile, "data_filter"):
354+
monkeypatch.delattr("tarfile.data_filter")
355+
356+
tar_filename = "test_tar_links_no_data_filter.tar"
357+
tar_filepath = os.path.join(self.tempdir, tar_filename)
358+
359+
import_filename = "import_file"
360+
import_filepath = os.path.join(self.tempdir, import_filename)
361+
open(import_filepath, "w").close()
362+
363+
extract_path = os.path.join(self.tempdir, "extract_path")
364+
365+
link_path = ".." + os.sep + import_filename
366+
367+
with tarfile.open(tar_filepath, "w") as tar:
368+
info = tarfile.TarInfo("evil_symlink")
369+
info.type = tarfile.SYMTYPE
370+
info.linkpath = link_path
371+
tar.addfile(info)
372+
373+
with pytest.raises(InstallationError) as e:
374+
untar_file(tar_filepath, extract_path)
375+
376+
msg = (
377+
"The tar file ({}) has a file ({}) trying to install outside "
378+
"target directory ({})"
379+
)
380+
assert msg.format(tar_filepath, "evil_symlink", link_path) in str(e.value)
381+
382+
assert not os.path.exists(os.path.join(extract_path, "evil_symlink"))
383+
241384

242385
def test_unpack_tar_unicode(tmpdir: Path) -> None:
243386
test_tar = tmpdir / "test.tar"

0 commit comments

Comments
 (0)