diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index d4174ffb58..458deb2316 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -5,6 +5,7 @@ import uuid from contextlib import asynccontextmanager from copy import deepcopy +from pathlib import Path, PurePosixPath from typing import Any, cast from quart import Response as QuartResponse @@ -32,6 +33,16 @@ SSE_HEARTBEAT = ": heartbeat\n\n" +def _sanitize_upload_filename(filename: str | None) -> str: + if not filename: + return f"{uuid.uuid4()!s}" + normalized = filename.replace("\\", "/") + name = PurePosixPath(normalized).name.replace("\x00", "").strip() + if name in ("", ".", ".."): + return f"{uuid.uuid4()!s}" + return name + + @asynccontextmanager async def track_conversation(convs: dict, conv_id: str): convs[conv_id] = True @@ -333,7 +344,7 @@ async def post_file(self): return Response().error("Missing key: file").__dict__ file = post_data["file"] - filename = file.filename or f"{uuid.uuid4()!s}" + filename = _sanitize_upload_filename(file.filename) content_type = file.content_type or "application/octet-stream" # 根据 content_type 判断文件类型并添加扩展名 @@ -346,12 +357,16 @@ async def post_file(self): else: attach_type = "file" - path = os.path.join(self.attachments_dir, filename) - await file.save(path) + attachments_dir = Path(self.attachments_dir).resolve(strict=False) + file_path = (attachments_dir / filename).resolve(strict=False) + if not file_path.is_relative_to(attachments_dir): + return Response().error("Invalid filename").__dict__ + + await file.save(str(file_path)) # 创建 attachment 记录 attachment = await self.db.insert_attachment( - path=path, + path=str(file_path), type=attach_type, mime_type=content_type, ) diff --git a/tests/unit/test_upload_filename_sanitization.py b/tests/unit/test_upload_filename_sanitization.py new file mode 100644 index 0000000000..88374669ec --- /dev/null +++ b/tests/unit/test_upload_filename_sanitization.py @@ -0,0 +1,32 @@ +"""Tests for upload filename sanitization.""" + +from astrbot.dashboard.routes.chat import _sanitize_upload_filename + + +def test_sanitize_upload_filename_strips_posix_traversal(): + assert _sanitize_upload_filename("../../outside.txt") == "outside.txt" + + +def test_sanitize_upload_filename_strips_windows_traversal(): + assert _sanitize_upload_filename(r"..\\..\\outside.txt") == "outside.txt" + + +def test_sanitize_upload_filename_strips_fakepath(): + assert _sanitize_upload_filename(r"C:\\fakepath\\photo.png") == "photo.png" + + +def test_sanitize_upload_filename_falls_back_for_empty_values(): + generated = _sanitize_upload_filename("") + + assert generated + assert generated not in {".", ".."} + assert "/" not in generated + assert "\\" not in generated + + +def test_sanitize_upload_filename_removes_embedded_null_bytes(): + assert _sanitize_upload_filename("evil\x00.txt") == "evil.txt" + assert _sanitize_upload_filename("\x00leading.txt") == "leading.txt" + assert _sanitize_upload_filename("trailing\x00.txt\x00") == "trailing.txt" + assert _sanitize_upload_filename("mid\x00dle.txt") == "middle.txt" +