fix: prevent path traversal in file uploads (#7751)

* fix: prevent path traversal in uploads

* fix: remove embedded NUL bytes from upload filenames

---------

Co-authored-by: RC-CHN <1051989940@qq.com>
This commit is contained in:
bugkeep
2026-04-24 09:01:02 +08:00
committed by GitHub
co-authored by RC-CHN
parent 9f8ce24726
commit aaec41e505
2 changed files with 51 additions and 4 deletions
+19 -4
View File
@@ -5,6 +5,7 @@ import re
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 @@ from .route import Response, Route, RouteContext
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 @@ class ChatRoute(Route):
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 @@ class ChatRoute(Route):
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,
)
@@ -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"