Skip to content

Commit dc1139a

Browse files
authored
fix: add safe_extract_zip to prevent Zip Slip (#2812)
1 parent 6f32c29 commit dc1139a

4 files changed

Lines changed: 204 additions & 7 deletions

File tree

apiserver/paasng/paasng/platform/sourcectl/controllers/github.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from os import PathLike, path, walk
2121
from pathlib import Path
2222
from typing import Dict, List, Optional, Tuple
23-
from zipfile import ZipFile
2423

2524
import arrow
2625
from django.utils.functional import cached_property
@@ -39,6 +38,7 @@
3938
from paasng.platform.sourcectl.repo_controller import BaseGitRepoController
4039
from paasng.platform.sourcectl.source_types import get_sourcectl_names
4140
from paasng.platform.sourcectl.utils import generate_temp_file
41+
from paasng.utils.archive import safe_extract_zip
4242

4343
logger = logging.getLogger(__name__)
4444

@@ -106,7 +106,7 @@ def export(self, local_path: PathLike, version_info: VersionInfo | None = None,
106106

107107
with generate_temp_file(suffix=".zip") as zip_file:
108108
self.api_client.repo_archive(self.project, zip_file, ref=ref)
109-
ZipFile(zip_file, "r").extractall(local_path)
109+
safe_extract_zip(zip_file, local_path)
110110

111111
# Github 下载的 zip 包比较特殊,外层有格式为 {username}-{proj_name}-{ref} 的目录,需要平铺开
112112
for root, dirs, _ in walk(str(local_path)):

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from paasng.platform.sourcectl.models import SourcePackage
3636
from paasng.platform.sourcectl.package.downloader import download_file_via_url
3737
from paasng.platform.sourcectl.utils import generate_temp_dir, uncompress_directory
38+
from paasng.utils.archive import safe_extract_zip
3839
from paasng.utils.text import remove_prefix
3940

4041
logger = logging.getLogger(__name__)
@@ -269,11 +270,9 @@ def read_file(self, file_path: str) -> bytes:
269270
return self.zip_.read(info)
270271

271272
def export(self, local_path: str):
272-
self.zip_.extractall(local_path) # noqa: S202
273-
274-
# About symbolic links security check, the zip file module does not support symbolic links
275-
# at this moment so no extra checking is needed.
276-
# See https://bugs.python.org/issue37921 for more details
273+
# 使用 safe_extract_zip 来解压 zip 包, 以防止 Zip Slip 漏洞,
274+
# safe_extract_zip 会对每个 zip 成员进行路径校验, 如果发现不安全的成员路径会抛出 UnsafeArchiveError 异常
275+
safe_extract_zip(self.zip_, local_path)
277276

278277
def close(self):
279278
self.zip_.close()
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# -*- coding: utf-8 -*-
2+
# TencentBlueKing is pleased to support the open source community by making
3+
# 蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available.
4+
# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved.
5+
# Licensed under the MIT License (the "License"); you may not use this file except
6+
# in compliance with the License. You may obtain a copy of the License at
7+
#
8+
# http://opensource.org/licenses/MIT
9+
#
10+
# Unless required by applicable law or agreed to in writing, software distributed under
11+
# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
12+
# either express or implied. See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# We undertake not to change the open source license (MIT license) applicable
16+
# to the current version of the project delivered to anyone in the future.
17+
18+
"""安全解压归档文件的工具
19+
20+
提供 `safe_extract_zip` 函数, 用于安全地解压 ZIP 文件, 防止 Zip Slip (CWE-22) 漏洞.
21+
"""
22+
23+
import contextlib
24+
import logging
25+
import os
26+
from pathlib import Path
27+
from typing import Union
28+
from zipfile import ZipFile, ZipInfo
29+
30+
logger = logging.getLogger(__name__)
31+
32+
33+
class UnsafeArchiveError(Exception):
34+
"""当归档文件中包含可能逃逸目标目录的成员时抛出.
35+
36+
包括但不限于: 绝对路径, 包含 `..` 的相对路径穿越,
37+
解析后落在目标目录之外的路径, 指向目录外部的符号链接等
38+
"""
39+
40+
41+
def safe_extract_zip(zip_file: Union[str, Path, ZipFile], local_path: Union[str, os.PathLike]) -> None:
42+
"""安全解压 zip 归档文件到指定目录, 在写出每个成员之前进行路径检查, 防止 Zip Slip 漏洞.
43+
44+
:param zip_file: ZIP 文件路径或已打开的 ZipFile 对象
45+
:param local_path: 解压目标目录
46+
:raises UnsafeArchiveError: 如果归档文件中包含不安全的成员路径
47+
"""
48+
base = Path(local_path).resolve()
49+
base.mkdir(parents=True, exist_ok=True)
50+
51+
with contextlib.ExitStack() as stack:
52+
if isinstance(zip_file, ZipFile):
53+
zf = zip_file
54+
else:
55+
zf = stack.enter_context(ZipFile(str(zip_file), "r"))
56+
57+
members = zf.infolist()
58+
for info in members:
59+
_validate_zip_info(info, base)
60+
# 所有成员校验通过, 执行解压
61+
for info in members:
62+
zf.extract(info, path=str(base))
63+
64+
65+
def _validate_zip_info(info: ZipInfo, base: Path) -> None:
66+
"""校验单个 zip 成员的安全性
67+
68+
:param info: ZipInfo 对象
69+
:param base: 解压目录的绝对路径 (已 resolve)
70+
:raises UnsafeArchiveError: 如果成员路径不安全
71+
"""
72+
# 统一路径分隔符为 "/"
73+
name = info.filename.replace("\\", "/")
74+
75+
# 拒绝空成员名
76+
if not name or name in (".", "./"):
77+
logger.warning("Zip archive contains an empty member name, extraction denied")
78+
raise UnsafeArchiveError("Zip member has an empty name, which is not allowed")
79+
80+
# 拒绝绝对路径
81+
if os.path.isabs(name):
82+
logger.warning("Zip archive contains a member with absolute path: %s, extraction denied", name)
83+
raise UnsafeArchiveError(f"Zip member '{name}' has an absolute path, which is not allowed")
84+
85+
# 归一化后校验最终路径是否在 base 目录下
86+
target_path = os.path.realpath(os.path.join(base, name))
87+
if os.path.commonpath([base, target_path]) != str(base):
88+
logger.warning(
89+
"Zip archive contains a path traversal member: %s (resolves to %s), extraction denied", name, target_path
90+
)
91+
raise UnsafeArchiveError(
92+
f"Zip member '{name}' resolves to '{target_path}', which is outside the target directory '{base}'"
93+
)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# -*- coding: utf-8 -*-
2+
# TencentBlueKing is pleased to support the open source community by making
3+
# 蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available.
4+
# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved.
5+
# Licensed under the MIT License (the "License"); you may not use this file except
6+
# in compliance with the License. You may obtain a copy of the License at
7+
#
8+
# http://opensource.org/licenses/MIT
9+
#
10+
# Unless required by applicable law or agreed to in writing, software distributed under
11+
# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
12+
# either express or implied. See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# We undertake not to change the open source license (MIT license) applicable
16+
# to the current version of the project delivered to anyone in the future.
17+
18+
import zipfile
19+
from pathlib import Path
20+
21+
import pytest
22+
23+
from paasng.utils.archive import UnsafeArchiveError, safe_extract_zip
24+
25+
26+
class TestSafeExtractZip:
27+
@pytest.fixture
28+
def dest_dir(self, tmp_path: Path) -> Path:
29+
"""解压目标目录"""
30+
return tmp_path / "dest"
31+
32+
@staticmethod
33+
def _make_zip(zip_path: Path, members: dict) -> Path:
34+
"""根据 {成员名: 内容} 创建一个 zip 文件, 内容为 None 时表示目录"""
35+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
36+
for name, content in members.items():
37+
if content is None:
38+
# 目录成员, 以 "/" 结尾
39+
zi = zipfile.ZipInfo(name if name.endswith("/") else name + "/")
40+
zf.writestr(zi, b"")
41+
else:
42+
zf.writestr(name, content)
43+
return zip_path
44+
45+
def test_extract_valid_zip(self, tmp_path: Path, dest_dir: Path):
46+
"""测试正常的 zip 文件能够正确解压"""
47+
members = {
48+
"file1.txt": b"Hello, World!",
49+
"./file2.txt": b"File with dot",
50+
"dir/": None,
51+
"dir/file3.txt": b"Another file",
52+
"dir/./file4.txt": b"File with dot in dir",
53+
}
54+
zip_path = self._make_zip(tmp_path / "test.zip", members)
55+
safe_extract_zip(zip_path, dest_dir)
56+
57+
# 验证解压后的文件内容
58+
assert (dest_dir / "file1.txt").read_bytes() == b"Hello, World!"
59+
assert (dest_dir / "file2.txt").read_bytes() == b"File with dot"
60+
assert (dest_dir / "dir").is_dir()
61+
assert (dest_dir / "dir/file3.txt").read_bytes() == b"Another file"
62+
assert (dest_dir / "dir/file4.txt").read_bytes() == b"File with dot in dir"
63+
64+
def test_reject_absolute_path_member(self, tmp_path: Path, dest_dir: Path):
65+
"""测试包含绝对路径成员的 zip 文件会被拒绝"""
66+
members = {
67+
"/absolute.txt": b"Should not be extracted",
68+
}
69+
zip_path = self._make_zip(tmp_path / "test.zip", members)
70+
with pytest.raises(UnsafeArchiveError):
71+
safe_extract_zip(zip_path, dest_dir)
72+
73+
def test_reject_parent_traversal_member(self, tmp_path: Path, dest_dir: Path):
74+
"""测试包含路径穿越成员的 zip 文件会被拒绝"""
75+
members = {
76+
"../evil1.txt": b"Should not be extracted",
77+
}
78+
zip_path = self._make_zip(tmp_path / "test.zip", members)
79+
with pytest.raises(UnsafeArchiveError):
80+
safe_extract_zip(zip_path, dest_dir)
81+
82+
def test_reject_complex_traversal_member(self, tmp_path: Path, dest_dir: Path):
83+
"""测试包含复杂路径穿越成员的 zip 文件会被拒绝"""
84+
members = {
85+
"dir/../../evil2.txt": b"Should not be extracted",
86+
}
87+
zip_path = self._make_zip(tmp_path / "test.zip", members)
88+
with pytest.raises(UnsafeArchiveError):
89+
safe_extract_zip(zip_path, dest_dir)
90+
91+
def test_no_partial_extraction_on_unsafe_member(self, tmp_path: Path, dest_dir: Path):
92+
"""测试当 zip 文件中包含不安全成员时,不会解压任何成员"""
93+
members = {
94+
"good.txt": b"Good file",
95+
"../evil.txt": b"Should not be extracted",
96+
}
97+
zip_path = self._make_zip(tmp_path / "test.zip", members)
98+
with pytest.raises(UnsafeArchiveError):
99+
safe_extract_zip(zip_path, dest_dir)
100+
101+
# dest_dir 应该保持为空, 没有任何成员被解压
102+
assert dest_dir.is_dir()
103+
assert not any(dest_dir.iterdir())
104+
# 父目录中也不应该有 evil.txt
105+
assert not (dest_dir.parent / "evil.txt").exists()

0 commit comments

Comments
 (0)