Skip to content

Commit b3a7b37

Browse files
committed
fix: disallow symlink-based includes outside template root
1 parent 66271ab commit b3a7b37

2 files changed

Lines changed: 92 additions & 1 deletion

File tree

copier/_main.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,20 @@ def _render_template(self) -> None:
718718
dst_root = self.dst_path.resolve()
719719
for src in scantree(str(self.template_copy_root), follow_symlinks):
720720
src_abspath = Path(src.path)
721+
# If the source is a symlink, we are not preserving symlinks, and the
722+
# symlink target is outside the template root, this means that we are
723+
# copying a file/directory from outside the template, which is
724+
# forbidden, so raise an error.
725+
if (
726+
src_abspath.is_symlink()
727+
and not self.template.preserve_symlinks
728+
and not (src_abspath.resolve()).is_relative_to(
729+
self.template.local_abspath
730+
)
731+
):
732+
raise ForbiddenPathError(
733+
path=src_abspath.relative_to(self.template_copy_root)
734+
)
721735
src_relpath = Path(src_abspath).relative_to(self.template.local_abspath)
722736
dst_relpaths_ctxs = self._render_path(
723737
Path(src_abspath).relative_to(self.template_copy_root)

tests/test_symlinks.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import os
2+
import re
23
from pathlib import Path
4+
from tempfile import gettempdir
35

46
import pytest
57
from plumbum import local
68

79
from copier import run_copy, run_update
8-
from copier.errors import DirtyLocalWarning
10+
from copier.errors import DirtyLocalWarning, ForbiddenPathError
911

1012
from .helpers import build_file_tree, git
1113

@@ -569,3 +571,78 @@ def test_symlinked_to_outside_destination_relative(
569571
assert (dst / "symlink_dir" / "outside.txt").read_text() == "outside"
570572
assert (dst / "a_symlink.txt").is_symlink()
571573
assert (dst / "a_symlink.txt").read_text() == "outside"
574+
575+
576+
def test_resolve_relative_symlink_outside_template_root_raises_error(
577+
tmp_path_factory: pytest.TempPathFactory,
578+
) -> None:
579+
src, dst, other = map(tmp_path_factory.mktemp, ("src", "dst", "other"))
580+
581+
build_file_tree(
582+
{
583+
src / ".copier-answers.yml.jinja": """\
584+
# Changes here will be overwritten by Copier
585+
{{ _copier_answers|to_nice_yaml }}
586+
""",
587+
src / "copier.yaml": """\
588+
_preserve_symlinks: false
589+
""",
590+
src / "symlink.txt": Path(
591+
# HACK: This is the path to `outside.txt` relative to the location the
592+
# symlink file in the template's local clone location. To construct
593+
# this path dynamically, we rely on internal knowledge where Copier
594+
# clones the template.
595+
os.path.relpath(other, start=Path(gettempdir(), "template-clone")),
596+
"outside.txt",
597+
),
598+
other / "outside.txt": "outside",
599+
}
600+
)
601+
602+
with local.cwd(src):
603+
git("init")
604+
git("add", "-A")
605+
git("commit", "-m", "init")
606+
607+
with pytest.raises(
608+
ForbiddenPathError,
609+
match=re.escape('"symlink.txt" is forbidden'),
610+
):
611+
run_copy(str(src), dst, defaults=True, overwrite=True, cleanup_on_error=False)
612+
613+
assert not (dst / "symlink.txt").exists()
614+
615+
616+
@pytest.mark.skipif(
617+
os.name == "nt", reason="Absolute paths not created as symlinks on Windows"
618+
)
619+
def test_resolve_absolute_symlink_outside_template_root_raises_error(
620+
tmp_path_factory: pytest.TempPathFactory,
621+
) -> None:
622+
src, dst, other = map(tmp_path_factory.mktemp, ("src", "dst", "other"))
623+
build_file_tree(
624+
{
625+
src / ".copier-answers.yml.jinja": """\
626+
# Changes here will be overwritten by Copier
627+
{{ _copier_answers|to_nice_yaml }}
628+
""",
629+
src / "copier.yaml": """\
630+
_preserve_symlinks: false
631+
""",
632+
src / "symlink.txt": other / "outside.txt",
633+
other / "outside.txt": "outside",
634+
}
635+
)
636+
637+
with local.cwd(src):
638+
git("init")
639+
git("add", "-A")
640+
git("commit", "-m", "init")
641+
642+
with pytest.raises(
643+
ForbiddenPathError,
644+
match=re.escape('"symlink.txt" is forbidden'),
645+
):
646+
run_copy(str(src), dst, defaults=True, overwrite=True)
647+
648+
assert not (dst / "symlink.txt").exists()

0 commit comments

Comments
 (0)