Skip to content

Commit d6f095f

Browse files
authored
feat: support mount-symlinks option in extract_tarfile (aws#8721)
* feat: support mount-symlinks option in extract_tarfile Pass mount_symlinks through to extract_tarfile so that symlinks pointing outside the extraction directory are preserved when the --mount-symlinks CLI option is enabled. Update Container.copy() to forward the flag from the container configuration. * chore: pin Python 3.10.12 in CI for tarfile data_filter support * chore: replace actions/setup-python with uv python install in CI
1 parent a7447aa commit d6f095f

8 files changed

Lines changed: 200 additions & 42 deletions

File tree

.github/workflows/build.yml

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,13 @@ jobs:
6565
echo "TEMP=D:\\Temp" >> $env:GITHUB_ENV
6666
if: ${{ matrix.os == 'windows-latest' }}
6767
- uses: actions/checkout@v6
68-
- uses: actions/setup-python@v6
68+
- uses: astral-sh/setup-uv@v7
6969
with:
7070
python-version: ${{ matrix.python }}
71-
- uses: astral-sh/setup-uv@v7
71+
cache-python: false
72+
- name: Install and activate Python
73+
run: bash tests/setup-python-uv.sh ${{ matrix.python }}
74+
shell: bash
7275
- run: test -f "./.github/ISSUE_TEMPLATE/Bug_report.md" # prevent Bug_report.md from being renamed or deleted
7376
- run: make pr
7477

@@ -81,10 +84,12 @@ jobs:
8184
runs-on: ubuntu-latest
8285
steps:
8386
- uses: actions/checkout@v6
84-
- uses: actions/setup-python@v6
85-
name: Install Python 3.11
87+
- uses: astral-sh/setup-uv@v7
8688
with:
87-
python-version: 3.11
89+
python-version: "3.11"
90+
cache-python: false
91+
- name: Install and activate Python
92+
run: bash tests/setup-python-uv.sh 3.11
8893
- run: make init
8994
- run: |
9095
diff <( cat schema/samcli.json ) <( python -m schema.make_schema && cat schema/samcli.json ) && \
@@ -152,18 +157,12 @@ jobs:
152157
mkdir "D:\\Temp"
153158
echo "TEMP=D:\\Temp" >> $env:GITHUB_ENV
154159
if: ${{ matrix.os == 'windows-latest' }}
155-
- uses: actions/setup-python@v6
160+
- uses: astral-sh/setup-uv@v7
156161
with:
157-
# set last version as the one in matrix to make it default
158-
python-version: |
159-
3.9
160-
3.10
161-
3.11
162-
3.12
163-
3.13
164-
3.14
165-
${{ matrix.python }}
166-
cache: 'pip'
162+
python-version: ${{ matrix.python }}
163+
cache-python: false
164+
- name: Install Python versions
165+
run: bash tests/setup-python-uv.sh 3.9 3.10 3.11 3.12 3.13 3.14 ${{ matrix.python }}
167166
- uses: actions/setup-go@v6
168167
with:
169168
go-version: '1.19'
@@ -188,11 +187,11 @@ jobs:
188187
# Install and configure Rust & Cargo Lambda
189188
- name: Install Rust toolchain and cargo-lambda
190189
if: ${{ matrix.os == 'ubuntu-latest' }}
191-
run: bash tests/install-rust.sh
190+
run: bash tests/install-rust.sh --uv
192191
- name: Init samdev
193192
run: make init
194193
- name: uv install setuptools in Python3.12
195-
run: uv pip install --system --python python3.12 --upgrade pip setuptools
194+
run: uv pip install --break-system-packages --python "$(uv python find 3.12)" --upgrade pip setuptools
196195
- name: Run integration tests for ${{ matrix.tests_config.name }}
197196
run: pytest -vv ${{ matrix.tests_config.params }}
198197
env:
@@ -239,10 +238,12 @@ jobs:
239238
mkdir "D:\\Temp"
240239
echo "TEMP=D:\\Temp" >> $env:GITHUB_ENV
241240
if: ${{ matrix.os == 'windows-latest' }}
242-
- uses: actions/setup-python@v6
241+
- uses: astral-sh/setup-uv@v7
243242
with:
244243
python-version: ${{ matrix.python }}
245-
cache: 'pip'
244+
cache-python: false
245+
- name: Install and activate Python
246+
run: bash tests/setup-python-uv.sh ${{ matrix.python }}
246247
- name: Init samdev
247248
run: make init
248249
- name: Run ${{ matrix.tests_config.name }}
@@ -269,16 +270,13 @@ jobs:
269270
mkdir "D:\\Temp"
270271
echo "TEMP=D:\\Temp" >> $env:GITHUB_ENV
271272
if: ${{ matrix.os == 'windows-latest' }}
272-
- uses: actions/setup-python@v6
273+
- uses: astral-sh/setup-uv@v7
273274
with:
274-
# These are the versions of Python that correspond to the supported Lambda runtimes
275-
python-version: |
276-
3.14
277-
3.9
278-
3.10
279-
3.11
280-
3.12
281-
3.13
275+
python-version: "3.10"
276+
cache-python: false
277+
- name: Install Python versions
278+
shell: bash
279+
run: bash tests/setup-python-uv.sh 3.9 3.10 3.11 3.12 3.13 3.14
282280
- name: Stop Docker Linux
283281
if: ${{ matrix.os == 'ubuntu-latest' }}
284282
run: |
@@ -292,7 +290,7 @@ jobs:
292290
- name: Init samdev
293291
run: make init
294292
- name: uv install setuptools in Python3.12
295-
run: uv pip install --system --python python3.12 --upgrade pip setuptools
293+
run: uv pip install --break-system-packages --python "$(uv python find 3.12)" --upgrade pip setuptools
296294
- name: Check Docker not Running
297295
run: docker info
298296
id: run-docker-info

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ SAM_CLI_TELEMETRY ?= 0
88
init:
99
@if [ "$$GITHUB_ACTIONS" = "true" ]; then \
1010
command -v uv >/dev/null 2>&1 || pip install uv==0.9.1; \
11-
SAM_CLI_DEV=1 uv pip install --system -e '.[dev]'; \
11+
SAM_CLI_DEV=1 uv pip install --system --break-system-packages --python "$$(uv python find $$UV_PYTHON)" -e '.[dev]'; \
1212
else \
1313
SAM_CLI_DEV=1 pip install -e '.[dev]'; \
1414
fi

samcli/lib/utils/tar.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ def extract_tarfile(
115115
tarfile_path: Union[str, os.PathLike] = "",
116116
file_obj: Optional[IO[bytes]] = None,
117117
unpack_dir: Union[str, os.PathLike] = "",
118+
mount_symlinks: bool = False,
118119
) -> None:
119120
"""
120121
Extracts a tarfile using the provided parameters. If file_obj is specified,
@@ -130,6 +131,10 @@ def extract_tarfile(
130131
131132
unpack_dir Union[str, os.PathLike]
132133
The directory where the tarfile members will be extracted.
134+
135+
mount_symlinks bool
136+
If True, symlinks pointing outside the extraction directory are allowed.
137+
This corresponds to the --mount-symlinks CLI option.
133138
"""
134139
with tarfile.open(name=tarfile_path, fileobj=file_obj, mode="r") as tar:
135140
# Makes sure the tar file is sanitized and is free of directory traversal vulnerability
@@ -139,4 +144,18 @@ def extract_tarfile(
139144
if not _is_within_directory(unpack_dir, member_path):
140145
raise tarfile.ExtractError("Attempted Path Traversal in Tar File")
141146

142-
tar.extractall(unpack_dir)
147+
# When mount_symlinks is disabled, use filter='data' to let Python's
148+
# built-in extraction filter handle symlink safety (rejects symlinks
149+
# pointing outside the extraction directory, among other protections).
150+
# When mount_symlinks is enabled, skip the filter so outside-pointing
151+
# symlinks are preserved.
152+
if not mount_symlinks:
153+
if hasattr(tarfile, "data_filter"):
154+
tar.extractall(path=unpack_dir, filter="data")
155+
else:
156+
raise tarfile.ExtractError(
157+
"Your Python version does not support tarfile extraction filters. "
158+
"Please update to Python 3.9.17+ or newer for safe tarfile extraction."
159+
)
160+
else:
161+
tar.extractall(path=unpack_dir)

samcli/local/docker/container.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -657,7 +657,7 @@ def copy(self, from_container_path, to_host_path) -> None:
657657
# Seek the handle back to start of file for tarfile to use
658658
fp.seek(0)
659659

660-
extract_tarfile(file_obj=fp, unpack_dir=to_host_path)
660+
extract_tarfile(file_obj=fp, unpack_dir=to_host_path, mount_symlinks=bool(self._mount_symlinks))
661661

662662
@staticmethod
663663
def _write_container_output(

tests/install-rust.sh

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
#!/bin/bash
22
# Install Rust toolchain and cargo-lambda for SAM CLI integration tests.
3-
# Usage: ./tests/install-rust.sh [CARGO_LAMBDA_VERSION]
3+
# Usage: ./tests/install-rust.sh [--uv] [CARGO_LAMBDA_VERSION]
4+
# --uv Use uv-managed Python 3.11 (for setup-uv workflows)
45
# CARGO_LAMBDA_VERSION defaults to env var or "v0.17.1"
56
set -euo pipefail
67

8+
USE_UV=false
9+
if [ "${1:-}" = "--uv" ]; then
10+
USE_UV=true
11+
shift
12+
fi
13+
714
CARGO_LAMBDA_VERSION="${1:-${CARGO_LAMBDA_VERSION:-v0.17.1}}"
815

916
# Install rustup if not present
@@ -21,10 +28,20 @@ rustup target add x86_64-unknown-linux-gnu --toolchain stable
2128
rustup target add aarch64-unknown-linux-gnu --toolchain stable
2229

2330
# Install cargo-lambda and ziglang
24-
python3.11 -m pip install "cargo-lambda==$CARGO_LAMBDA_VERSION"
31+
if [ "$USE_UV" = true ]; then
32+
PYTHON311="$(uv python find 3.11)"
33+
PYTHON311_BIN="$(dirname "$PYTHON311")"
34+
uv pip install --break-system-packages --python "$PYTHON311" "cargo-lambda==$CARGO_LAMBDA_VERSION"
35+
if [ -n "${GITHUB_PATH:-}" ]; then
36+
echo "$PYTHON311_BIN" >> "$GITHUB_PATH"
37+
fi
38+
else
39+
python3.11 -m pip install "cargo-lambda==$CARGO_LAMBDA_VERSION"
40+
PYTHON311="python3.11"
41+
fi
2542

2643
# Create a zig wrapper so SAM CLI's cargo-lambda can find it
27-
printf '#!/bin/bash\nexec python3.11 -m ziglang "$@"\n' | sudo tee /usr/local/bin/zig > /dev/null
44+
printf '#!/bin/bash\nexec %s -m ziglang "$@"\n' "$PYTHON311" | sudo tee /usr/local/bin/zig > /dev/null
2845
sudo chmod +x /usr/local/bin/zig
2946

3047
rustc -V

tests/setup-python-uv.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/bin/bash
2+
# Install Python version(s) via uv and add them to GITHUB_PATH.
3+
# Usage: bash tests/setup-python-uv.sh <version> [version...]
4+
# Example:
5+
# bash tests/setup-python-uv.sh 3.10
6+
# bash tests/setup-python-uv.sh 3.9 3.10 3.11 3.12 3.13 3.14
7+
set -euo pipefail
8+
9+
if [ $# -eq 0 ]; then
10+
echo "Usage: $0 <python-version> [python-version...]"
11+
exit 1
12+
fi
13+
14+
uv python install "$@"
15+
16+
for ver in "$@"; do
17+
PYTHON_DIR=$(dirname "$(uv python find "$ver")")
18+
echo "$PYTHON_DIR" >> "$GITHUB_PATH"
19+
# On Windows, pip-installed scripts go into Scripts/ subdirectory
20+
if [ -d "$PYTHON_DIR/Scripts" ]; then
21+
echo "$PYTHON_DIR/Scripts" >> "$GITHUB_PATH"
22+
fi
23+
done

tests/unit/lib/utils/test_tar.py

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,7 @@ def test_extract_tarfile_file_name(self, is_within_directory_patch, tarfile_open
108108
extract_tarfile(tarfile_path=tarfile_path, unpack_dir=unpack_dir)
109109

110110
is_within_directory_patch.assert_called_once()
111-
tarfile_file_mock.getmembers.assert_called_once()
112-
tarfile_file_mock.extractall.assert_called_once_with(unpack_dir)
111+
tarfile_file_mock.extractall.assert_called_once_with(path=unpack_dir, filter="data")
113112

114113
@patch("samcli.lib.utils.tar.tarfile.open")
115114
@patch("samcli.lib.utils.tar._is_within_directory")
@@ -118,8 +117,8 @@ def test_extract_tarfile_fileobj(self, is_within_directory_patch, tarfile_open_p
118117
unpack_dir = "/test_unpack_dir/"
119118
is_within_directory_patch.return_value = True
120119

121-
tarfile_file_mock = Mock() # Mock tarfile
122-
tar_file_obj_mock = Mock() # Mock member inside tarfile
120+
tarfile_file_mock = Mock()
121+
tar_file_obj_mock = Mock()
123122
tar_file_obj_mock.name = "obj_name"
124123
tarfile_file_mock.getmembers.return_value = [tar_file_obj_mock]
125124
tarfile_open_patch.return_value.__enter__.return_value = tarfile_file_mock
@@ -128,7 +127,7 @@ def test_extract_tarfile_fileobj(self, is_within_directory_patch, tarfile_open_p
128127

129128
is_within_directory_patch.assert_called_once()
130129
tarfile_file_mock.getmembers.assert_called_once()
131-
tarfile_file_mock.extractall.assert_called_once_with(unpack_dir)
130+
tarfile_file_mock.extractall.assert_called_once_with(path=unpack_dir, filter="data")
132131

133132
@patch("samcli.lib.utils.tar.tarfile.open")
134133
@patch("samcli.lib.utils.tar._is_within_directory")
@@ -140,6 +139,7 @@ def test_extract_tarfile_obj_not_within_dir(self, is_within_directory_patch, tar
140139
tarfile_file_mock = Mock()
141140
tar_file_obj_mock = Mock()
142141
tar_file_obj_mock.name = "obj_name"
142+
tar_file_obj_mock.issym.return_value = False
143143
tarfile_file_mock.getmembers.return_value = [tar_file_obj_mock]
144144
tarfile_open_patch.return_value.__enter__.return_value = tarfile_file_mock
145145

@@ -231,3 +231,104 @@ def test_validating_symlinked_tar_path_directory(self, file_exists, path_mock):
231231
result = _validate_destinations_exists(["mock_folder"])
232232

233233
self.assertEqual(result, file_exists)
234+
235+
@patch("samcli.lib.utils.tar.tarfile.open")
236+
@patch("samcli.lib.utils.tar._is_within_directory")
237+
def test_extract_tarfile_allows_safe_symlinks(self, is_within_directory_patch, tarfile_open_patch):
238+
"""When mount_symlinks is False (default), filter='data' is used."""
239+
tarfile_path = "/test_tarfile_path/"
240+
unpack_dir = "/test_unpack_dir/"
241+
is_within_directory_patch.return_value = True
242+
243+
tarfile_file_mock = Mock()
244+
245+
regular_file_1 = Mock()
246+
regular_file_1.name = "regular_file_1.txt"
247+
248+
safe_symlink = Mock()
249+
safe_symlink.name = "safe_symlink"
250+
251+
tarfile_file_mock.getmembers.return_value = [regular_file_1, safe_symlink]
252+
tarfile_open_patch.return_value.__enter__.return_value = tarfile_file_mock
253+
254+
extract_tarfile(tarfile_path=tarfile_path, unpack_dir=unpack_dir)
255+
256+
tarfile_file_mock.extractall.assert_called_once_with(path=unpack_dir, filter="data")
257+
258+
@patch("samcli.lib.utils.tar.tarfile.open")
259+
@patch("samcli.lib.utils.tar._is_within_directory")
260+
def test_extract_tarfile_skips_unsafe_symlinks(self, is_within_directory_patch, tarfile_open_patch):
261+
"""When mount_symlinks is False (default), filter='data' handles symlink safety."""
262+
tarfile_path = "/test_tarfile_path/"
263+
unpack_dir = "/test_unpack_dir/"
264+
is_within_directory_patch.return_value = True
265+
266+
tarfile_file_mock = Mock()
267+
268+
regular_file_1 = Mock()
269+
regular_file_1.name = "regular_file_1.txt"
270+
271+
unsafe_symlink = Mock()
272+
unsafe_symlink.name = "unsafe_symlink"
273+
274+
tarfile_file_mock.getmembers.return_value = [regular_file_1, unsafe_symlink]
275+
tarfile_open_patch.return_value.__enter__.return_value = tarfile_file_mock
276+
277+
extract_tarfile(tarfile_path=tarfile_path, unpack_dir=unpack_dir)
278+
279+
# filter='data' is passed to extractall to handle symlink filtering
280+
tarfile_file_mock.extractall.assert_called_once_with(path=unpack_dir, filter="data")
281+
282+
@patch("samcli.lib.utils.tar.tarfile.open")
283+
@patch("samcli.lib.utils.tar._is_within_directory")
284+
def test_extract_tarfile_allows_outside_symlinks_when_mount_symlinks_enabled(
285+
self, is_within_directory_patch, tarfile_open_patch
286+
):
287+
"""When mount_symlinks is True, no filter is applied so symlinks are preserved."""
288+
tarfile_path = "/test_tarfile_path/"
289+
unpack_dir = "/test_unpack_dir/"
290+
is_within_directory_patch.return_value = True
291+
292+
tarfile_file_mock = Mock()
293+
294+
regular_file = Mock()
295+
regular_file.name = "regular_file.txt"
296+
297+
outside_symlink = Mock()
298+
outside_symlink.name = "outside_symlink"
299+
300+
tarfile_file_mock.getmembers.return_value = [regular_file, outside_symlink]
301+
tarfile_open_patch.return_value.__enter__.return_value = tarfile_file_mock
302+
303+
extract_tarfile(tarfile_path=tarfile_path, unpack_dir=unpack_dir, mount_symlinks=True)
304+
305+
# No filter applied — symlinks pointing outside are allowed
306+
tarfile_file_mock.extractall.assert_called_once_with(path=unpack_dir)
307+
308+
@patch("samcli.lib.utils.tar.tarfile.open")
309+
@patch("samcli.lib.utils.tar._is_within_directory")
310+
def test_extract_tarfile_raises_when_data_filter_unavailable(self, is_within_directory_patch, tarfile_open_patch):
311+
"""When data_filter is not available, raises ExtractError."""
312+
tarfile_path = "/test_tarfile_path/"
313+
unpack_dir = "/test_unpack_dir/"
314+
is_within_directory_patch.return_value = True
315+
316+
tarfile_file_mock = Mock()
317+
tar_file_obj_mock = Mock()
318+
tar_file_obj_mock.name = "obj_name"
319+
tarfile_file_mock.getmembers.return_value = [tar_file_obj_mock]
320+
tarfile_open_patch.return_value.__enter__.return_value = tarfile_file_mock
321+
322+
# Temporarily remove data_filter to simulate older Python
323+
data_filter = getattr(tarfile, "data_filter", None)
324+
try:
325+
if hasattr(tarfile, "data_filter"):
326+
delattr(tarfile, "data_filter")
327+
328+
with self.assertRaises(tarfile.ExtractError) as ctx:
329+
extract_tarfile(tarfile_path=tarfile_path, unpack_dir=unpack_dir)
330+
331+
self.assertIn("does not support tarfile extraction filters", str(ctx.exception))
332+
finally:
333+
if data_filter is not None:
334+
tarfile.data_filter = data_filter

tests/unit/local/docker/test_container.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1360,7 +1360,7 @@ def test_must_copy_files_from_container(self, extract_tarfile_mock, tempfile_moc
13601360
# Verify get_archive was called with container ID and source path
13611361
self.mock_client.get_archive.assert_called_with("containerid", source)
13621362

1363-
extract_tarfile_mock.assert_called_with(file_obj=fp_mock, unpack_dir=dest)
1363+
extract_tarfile_mock.assert_called_with(file_obj=fp_mock, unpack_dir=dest, mount_symlinks=False)
13641364

13651365
# Make sure archive data is written to the file
13661366
fp_mock.write.assert_has_calls([call(x) for x in tar_stream], any_order=False)

0 commit comments

Comments
 (0)