Skip to content

Commit 00f3abd

Browse files
committed
fix: patch CVE dependencies and path traversal risks
Upgrade aiohttp and related packages, harden local file and storage path checks, tighten password/JWT comparisons, and default Docker forwarded IP trust to empty.
1 parent c676458 commit 00f3abd

7 files changed

Lines changed: 200 additions & 37 deletions

File tree

Dockerfile

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,16 +57,24 @@ RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
5757
COPY --from=frontend-builder /build/fronted-2024/dist ./themes/2024
5858
COPY --from=frontend-builder /build/fronted-2023/dist ./themes/2023
5959

60-
# 安装 Python 依赖
61-
RUN pip install --no-cache-dir -r requirements.txt
60+
# 安装系统安全更新 + Python 依赖
61+
# 清理 apt 缓存,降低镜像噪音与扫描面
62+
RUN apt-get update \
63+
&& apt-get upgrade -y --no-install-recommends \
64+
&& rm -rf /var/lib/apt/lists/* \
65+
&& pip install --no-cache-dir -r requirements.txt \
66+
&& pip cache purge || true
6267

6368
# 环境变量配置
6469
ENV HOST="0.0.0.0" \
6570
PORT=12345 \
6671
WORKERS=1 \
67-
LOG_LEVEL="info"
72+
LOG_LEVEL="info" \
73+
FORWARDED_ALLOW_IPS=""
6874

6975
EXPOSE 12345
7076

7177
# 生产环境启动命令
72-
CMD ["sh", "-c", "exec uvicorn main:app --host \"$HOST\" --port \"$PORT\" --workers \"$WORKERS\" --log-level \"$LOG_LEVEL\" --proxy-headers --forwarded-allow-ips '*'"]
78+
# FORWARDED_ALLOW_IPS 默认为空:仅信任直连 IP,避免任意客户端伪造 X-Forwarded-*。
79+
# 若前面有反向代理,请显式设置为代理网段,例如 "10.0.0.0/8,172.16.0.0/12"。
80+
CMD ["sh", "-c", "exec uvicorn main:app --host \"$HOST\" --port \"$PORT\" --workers \"$WORKERS\" --log-level \"$LOG_LEVEL\" --proxy-headers --forwarded-allow-ips \"${FORWARDED_ALLOW_IPS:-}\""]

apps/admin/dependencies.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,20 @@ def create_token(data: dict, expires_in: int | None = None) -> str:
4848
token_lifetime = (
4949
get_admin_session_expire_seconds() if expires_in is None else expires_in
5050
)
51-
header = base64.b64encode(
52-
json.dumps({"alg": "HS256", "typ": "JWT"}).encode()
53-
).decode()
54-
payload = base64.b64encode(
55-
json.dumps({**data, "exp": int(time.time()) + token_lifetime}).encode()
56-
).decode()
57-
58-
signature = hmac.new(_get_jwt_secret(), f"{header}.{payload}".encode(), "sha256").digest()
59-
signature = base64.b64encode(signature).decode()
51+
header = base64.urlsafe_b64encode(
52+
json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()
53+
).decode().rstrip("=")
54+
payload = base64.urlsafe_b64encode(
55+
json.dumps(
56+
{**data, "exp": int(time.time()) + token_lifetime},
57+
separators=(",", ":"),
58+
).encode()
59+
).decode().rstrip("=")
60+
61+
signature = hmac.new(
62+
_get_jwt_secret(), f"{header}.{payload}".encode(), "sha256"
63+
).digest()
64+
signature = base64.urlsafe_b64encode(signature).decode().rstrip("=")
6065

6166
return f"{header}.{payload}.{signature}"
6267

@@ -76,13 +81,20 @@ def verify_token(token: str) -> dict:
7681
f"{header_b64}.{payload_b64}".encode(),
7782
"sha256",
7883
).digest()
79-
expected_signature_b64 = base64.b64encode(expected_signature).decode()
84+
expected_signature_b64 = (
85+
base64.urlsafe_b64encode(expected_signature).decode().rstrip("=")
86+
)
8087

8188
if not hmac.compare_digest(signature_b64, expected_signature_b64):
8289
raise ValueError("无效的签名")
8390

84-
# 解码payload
85-
payload = json.loads(base64.b64decode(payload_b64))
91+
# 解码payload(兼容历史标准 base64 与 urlsafe base64)
92+
padded = payload_b64 + "=" * (-len(payload_b64) % 4)
93+
try:
94+
payload_bytes = base64.urlsafe_b64decode(padded)
95+
except Exception:
96+
payload_bytes = base64.b64decode(padded)
97+
payload = json.loads(payload_bytes)
8698

8799
# 检查是否过期
88100
if payload.get("exp", 0) < time.time():

apps/admin/services.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import hashlib
2+
from pathlib import Path
23
import os
34
import time
45
import uuid
@@ -1627,9 +1628,35 @@ async def delete_file(self, filename: str):
16271628

16281629
class LocalFileClass:
16291630
def __init__(self, file):
1630-
self.file = file
1631-
self.path = data_root / "local" / file
1632-
if os.path.exists(self.path):
1631+
# 仅允许 data/local 目录下的单层文件名,阻断路径穿越与绝对路径访问。
1632+
raw_name = str(file or "")
1633+
normalized = Path(raw_name).as_posix()
1634+
# 输入本身不得包含路径分隔符或绝对路径形态。
1635+
if (
1636+
not raw_name
1637+
or raw_name in {".", ".."}
1638+
or normalized in {".", ".."}
1639+
or "/" in normalized
1640+
or normalized.startswith("~")
1641+
or Path(raw_name).is_absolute()
1642+
or Path(raw_name).name != raw_name
1643+
):
1644+
raise HTTPException(status_code=400, detail="非法文件名")
1645+
1646+
safe_name = Path(raw_name).name
1647+
if not safe_name or safe_name in {".", ".."}:
1648+
raise HTTPException(status_code=400, detail="非法文件名")
1649+
1650+
local_root = (data_root / "local").resolve()
1651+
candidate = (local_root / safe_name).resolve()
1652+
try:
1653+
candidate.relative_to(local_root)
1654+
except ValueError:
1655+
raise HTTPException(status_code=400, detail="非法文件路径")
1656+
1657+
self.file = safe_name
1658+
self.path = candidate
1659+
if self.path.is_file():
16331660
self.ctime = time.strftime(
16341661
"%Y-%m-%d %H:%M:%S", time.localtime(os.path.getctime(self.path))
16351662
)
@@ -1649,4 +1676,4 @@ async def delete(self):
16491676
os.remove(self.path)
16501677

16511678
async def exists(self):
1652-
return os.path.exists(self.path)
1679+
return self.path.is_file()

core/storage.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ def __init__(self):
111111
self.chunk_size = 256 * 1024
112112
self.root_path = data_root
113113

114+
def _resolve_safe_path(self, relative_path: str) -> Path:
115+
"""将相对路径解析到数据根目录内,阻止路径穿越。"""
116+
root = self.root_path.resolve()
117+
raw = str(relative_path or "").replace("\\", "/").lstrip("/")
118+
candidate = (root / raw).resolve()
119+
try:
120+
candidate.relative_to(root)
121+
except ValueError as exc:
122+
raise ValueError("非法文件路径") from exc
123+
return candidate
124+
114125
def _save(self, file, save_path):
115126
with open(save_path, "wb") as f:
116127
chunk = file.read(self.chunk_size)
@@ -119,27 +130,27 @@ def _save(self, file, save_path):
119130
chunk = file.read(self.chunk_size)
120131

121132
async def save_file(self, file: UploadFile, save_path: str):
122-
path_obj = Path(save_path)
123-
directory = str(path_obj.parent)
133+
path_obj = Path(str(save_path).replace("\\", "/"))
134+
directory = str(path_obj.parent).replace("\\", "/").lstrip("/")
124135
# 提取原始文件名并进行清理
125136
filename = await sanitize_filename(path_obj.name)
126137
# 构建安全的完整保存路径
127-
safe_save_path = self.root_path / directory / filename
138+
safe_save_path = self._resolve_safe_path(f"{directory}/{filename}" if directory not in {"", "."} else filename)
128139
# 确保目录存在
129140
if not safe_save_path.parent.exists():
130141
safe_save_path.parent.mkdir(parents=True)
131142
await asyncio.to_thread(self._save, file.file, safe_save_path)
132143

133144
async def delete_file(self, file_code: FileCodes):
134-
save_path = self.root_path / await file_code.get_file_path()
145+
save_path = self._resolve_safe_path(await file_code.get_file_path())
135146
if save_path.exists():
136147
save_path.unlink()
137148

138149
async def get_file_url(self, file_code: FileCodes):
139150
return await get_file_url(file_code.code)
140151

141152
async def get_file_response(self, file_code: FileCodes):
142-
file_path = self.root_path / await file_code.get_file_path()
153+
file_path = self._resolve_safe_path(await file_code.get_file_path())
143154
if not file_path.exists():
144155
return APIResponse(code=404, detail="文件已过期删除")
145156
filename = f"{file_code.prefix}{file_code.suffix}"
@@ -171,8 +182,11 @@ async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes,
171182
:param chunk_hash: 分片哈希值
172183
:param save_path: 文件保存路径
173184
"""
174-
chunk_dir = self.root_path / save_path
175-
chunk_path = chunk_dir.parent / 'chunks' / upload_id / f"{chunk_index}.part"
185+
# 先校验目标文件路径合法,再将分片落到同级 chunks 目录。
186+
self._resolve_safe_path(save_path)
187+
chunk_path = self._resolve_safe_path(
188+
str(Path(save_path).parent / "chunks" / upload_id / f"{chunk_index}.part")
189+
)
176190
if not chunk_path.parent.exists():
177191
chunk_path.parent.mkdir(parents=True, exist_ok=True)
178192
# 使用临时文件写入,确保原子性
@@ -195,9 +209,11 @@ async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path:
195209
:param save_path: 文件保存路径
196210
:return: (文件路径, 文件哈希值)
197211
"""
198-
output_path = self.root_path / save_path
212+
output_path = self._resolve_safe_path(save_path)
199213
output_path.parent.mkdir(parents=True, exist_ok=True)
200-
chunk_base_dir = output_path.parent / 'chunks' / upload_id
214+
chunk_base_dir = self._resolve_safe_path(
215+
str(Path(save_path).parent / "chunks" / upload_id)
216+
)
201217
file_sha256 = hashlib.sha256()
202218

203219
# 使用临时文件写入,确保原子性
@@ -233,7 +249,9 @@ async def clean_chunks(self, upload_id: str, save_path: str):
233249
:param upload_id: 上传会话ID
234250
:param save_path: 文件保存路径
235251
"""
236-
chunk_dir = (self.root_path / save_path).parent / 'chunks' / upload_id
252+
chunk_dir = self._resolve_safe_path(
253+
str(Path(save_path).parent / "chunks" / upload_id)
254+
)
237255
if chunk_dir.exists():
238256
try:
239257
shutil.rmtree(chunk_dir)
@@ -253,7 +271,10 @@ async def file_exists(self, save_path: str) -> bool:
253271
:param save_path: 文件路径
254272
:return: 文件是否存在
255273
"""
256-
file_path = self.root_path / save_path
274+
try:
275+
file_path = self._resolve_safe_path(save_path)
276+
except ValueError:
277+
return False
257278
return file_path.exists()
258279

259280

core/utils.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# @Software: PyCharm
55
import datetime
66
import hashlib
7+
import hmac
78
import os
89
import re
910
import secrets
@@ -124,10 +125,10 @@ def verify_password(password: str, hashed: str) -> bool:
124125
return False
125126
_, salt, stored_hash = parts
126127
password_hash = hashlib.sha256(f"{salt}{password}".encode()).hexdigest()
127-
return password_hash == stored_hash
128+
return hmac.compare_digest(password_hash, stored_hash)
128129

129130
# 旧格式: 明文比较 (兼容迁移前的数据)
130-
return password == hashed
131+
return hmac.compare_digest(str(password), str(hashed))
131132

132133

133134
def is_password_hashed(password: str) -> bool:

requirements.txt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
aioboto3==15.5.0
2-
aiohttp==3.13.3
2+
aiohttp==3.14.2
33
aiofiles==25.1.0
4-
fastapi==0.128.0
4+
fastapi==0.139.2
5+
starlette==1.3.1
56
pydantic==2.12.5
6-
uvicorn==0.40.0
7+
uvicorn==0.51.0
78
tortoise-orm==0.25.3
8-
python-multipart==0.0.21
9+
python-multipart==0.0.32

tests/test_security_hardening.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import asyncio
2+
import os
3+
import tempfile
4+
import unittest
5+
from pathlib import Path
6+
from unittest.mock import patch
7+
8+
from fastapi import HTTPException
9+
10+
from apps.admin import dependencies as admin_dependencies
11+
from apps.admin.dependencies import create_token, verify_token
12+
from apps.admin.services import LocalFileClass
13+
from core.settings import data_root, settings
14+
from core.storage import SystemFileStorage
15+
from core.utils import hash_password, verify_password
16+
17+
18+
class PasswordCompareTests(unittest.TestCase):
19+
def test_verify_password_accepts_valid_hash(self):
20+
hashed = hash_password("s3cret-pass")
21+
self.assertTrue(verify_password("s3cret-pass", hashed))
22+
self.assertFalse(verify_password("wrong-pass", hashed))
23+
24+
def test_verify_password_supports_legacy_plaintext(self):
25+
self.assertTrue(verify_password("legacy", "legacy"))
26+
self.assertFalse(verify_password("legacy", "other"))
27+
28+
29+
class LocalFilePathTraversalTests(unittest.TestCase):
30+
def setUp(self):
31+
self._tmpdir = tempfile.TemporaryDirectory()
32+
self.local_root = Path(self._tmpdir.name) / "local"
33+
self.local_root.mkdir(parents=True, exist_ok=True)
34+
(self.local_root / "safe.txt").write_text("ok", encoding="utf-8")
35+
self._data_root_patch = patch(
36+
"apps.admin.services.data_root", Path(self._tmpdir.name)
37+
)
38+
self._data_root_patch.start()
39+
40+
def tearDown(self):
41+
self._data_root_patch.stop()
42+
self._tmpdir.cleanup()
43+
44+
def test_rejects_dotdot_filename(self):
45+
with self.assertRaises(HTTPException) as ctx:
46+
LocalFileClass("../etc/passwd")
47+
self.assertEqual(ctx.exception.status_code, 400)
48+
49+
def test_rejects_absolute_filename(self):
50+
with self.assertRaises(HTTPException) as ctx:
51+
LocalFileClass("/etc/passwd")
52+
self.assertEqual(ctx.exception.status_code, 400)
53+
54+
def test_allows_basename_inside_local(self):
55+
local_file = LocalFileClass("safe.txt")
56+
self.assertTrue(asyncio.run(local_file.exists()))
57+
self.assertEqual(local_file.file, "safe.txt")
58+
self.assertEqual(local_file.path, (self.local_root / "safe.txt").resolve())
59+
60+
61+
class SystemStoragePathTests(unittest.TestCase):
62+
def setUp(self):
63+
self._tmpdir = tempfile.TemporaryDirectory()
64+
self.storage = SystemFileStorage()
65+
self.storage.root_path = Path(self._tmpdir.name)
66+
67+
def tearDown(self):
68+
self._tmpdir.cleanup()
69+
70+
def test_resolve_safe_path_blocks_escape(self):
71+
with self.assertRaises(ValueError):
72+
self.storage._resolve_safe_path("../etc/passwd")
73+
74+
def test_resolve_safe_path_allows_nested(self):
75+
target = self.storage._resolve_safe_path("share/data/a/b.txt")
76+
self.assertTrue(str(target).startswith(str(Path(self._tmpdir.name).resolve())))
77+
78+
79+
class AdminJwtUrlSafeTests(unittest.TestCase):
80+
def setUp(self):
81+
settings.jwt_secret = "j" * 48
82+
83+
def test_create_and_verify_roundtrip(self):
84+
token = create_token({"is_admin": True}, expires_in=60)
85+
# urlsafe token 不应依赖标准 base64 填充字符
86+
self.assertNotIn("+", token)
87+
self.assertNotIn("/", token)
88+
payload = verify_token(token)
89+
self.assertTrue(payload["is_admin"])
90+
91+
92+
if __name__ == "__main__":
93+
unittest.main()

0 commit comments

Comments
 (0)