From 1f792fa9144d1ede3fc993ae9096bb9f6f1115fc Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Thu, 2 Apr 2026 16:03:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Argon2=20=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E5=93=88=E5=B8=8C=E6=94=AF=E6=8C=81=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=96=87=E4=BB=B6=E7=B3=BB=E7=BB=9F=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=80=A7=EF=BC=8C=E4=BF=AE=E5=A4=8D=20frontmatter=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package/yuxi/pyproject.toml | 1 + .../package/yuxi/services/skill_service.py | 35 ++++++++---- .../yuxi/services/thread_files_service.py | 2 +- .../services/viewer_filesystem_service.py | 30 ++++++++-- backend/server/utils/auth_utils.py | 29 +++++----- .../api/test_viewer_filesystem_security.py | 57 +++++++++++++++++++ .../services/test_thread_files_service.py | 44 ++++++++++++++ backend/test/unit/test_auth_utils.py | 20 +++++++ backend/uv.lock | 2 + docs/develop-guides/roadmap.md | 1 + 10 files changed, 190 insertions(+), 31 deletions(-) create mode 100644 backend/test/integration/api/test_viewer_filesystem_security.py create mode 100644 backend/test/unit/services/test_thread_files_service.py create mode 100644 backend/test/unit/test_auth_utils.py diff --git a/backend/package/yuxi/pyproject.toml b/backend/package/yuxi/pyproject.toml index f87b1469..3f4a90a1 100644 --- a/backend/package/yuxi/pyproject.toml +++ b/backend/package/yuxi/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "aiofiles>=24.1.0", "aiohttp>=3.9.0", "aiosqlite>=0.20.0", + "argon2-cffi>=25.1.0", "asyncpg>=0.30.0", "chardet>=5.0.0", "colorlog>=6.9.0", diff --git a/backend/package/yuxi/services/skill_service.py b/backend/package/yuxi/services/skill_service.py index 8e382c56..a0cd444c 100644 --- a/backend/package/yuxi/services/skill_service.py +++ b/backend/package/yuxi/services/skill_service.py @@ -21,7 +21,6 @@ from yuxi.utils.logging_config import logger SKILL_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") SKILL_NAME_PATTERN = SKILL_SLUG_PATTERN -FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) TEXT_FILE_EXTENSIONS = { ".md", @@ -357,12 +356,31 @@ def _validate_skill_name(name: str) -> str: return name -def _parse_skill_markdown(content: str) -> tuple[str, str, dict[str, Any]]: - match = FRONTMATTER_PATTERN.match(content) - if not match: +def _split_frontmatter(content: str) -> tuple[str, str]: + if not content.startswith("---"): raise ValueError("SKILL.md 缺少有效 frontmatter(--- ... ---)") - frontmatter_raw = match.group(1) + lines = content.splitlines(keepends=True) + if not lines or lines[0].strip() != "---": + raise ValueError("SKILL.md 缺少有效 frontmatter(--- ... ---)") + + frontmatter_lines: list[str] = [] + body_start = 0 + for index, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + body_start = index + 1 + break + frontmatter_lines.append(line) + else: + raise ValueError("SKILL.md 缺少有效 frontmatter(--- ... ---)") + + frontmatter_raw = "".join(frontmatter_lines) + body = "".join(lines[body_start:]) + return frontmatter_raw, body + + +def _parse_skill_markdown(content: str) -> tuple[str, str, dict[str, Any]]: + frontmatter_raw, _body = _split_frontmatter(content) try: data = yaml.safe_load(frontmatter_raw) except yaml.YAMLError as e: @@ -380,12 +398,7 @@ def _parse_skill_markdown(content: str) -> tuple[str, str, dict[str, Any]]: def _rewrite_frontmatter_name(content: str, new_name: str) -> str: - match = FRONTMATTER_PATTERN.match(content) - if not match: - raise ValueError("SKILL.md 缺少有效 frontmatter(--- ... ---)") - - frontmatter_raw = match.group(1) - body = content[match.end() :] + frontmatter_raw, body = _split_frontmatter(content) data = yaml.safe_load(frontmatter_raw) if not isinstance(data, dict): raise ValueError("SKILL.md frontmatter 必须是对象") diff --git a/backend/package/yuxi/services/thread_files_service.py b/backend/package/yuxi/services/thread_files_service.py index 3e04139a..44435b95 100644 --- a/backend/package/yuxi/services/thread_files_service.py +++ b/backend/package/yuxi/services/thread_files_service.py @@ -228,7 +228,7 @@ async def resolve_thread_artifact_view( if not any(_is_path_within(resolved_path, root) for root in allowed_roots): raise HTTPException(status_code=403, detail="access denied") - return actual_path + return resolved_path async def save_thread_artifact_to_workspace_view( diff --git a/backend/package/yuxi/services/viewer_filesystem_service.py b/backend/package/yuxi/services/viewer_filesystem_service.py index 031cbdfd..08dbec47 100644 --- a/backend/package/yuxi/services/viewer_filesystem_service.py +++ b/backend/package/yuxi/services/viewer_filesystem_service.py @@ -4,7 +4,7 @@ import asyncio import io import mimetypes import shutil -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath from urllib.parse import quote from fastapi import HTTPException @@ -140,6 +140,26 @@ def _normalize_path(path: str | None) -> str: return normalized.rstrip("/") if normalized not in {"/", KBS_PATH, SKILLS_PATH, USER_DATA_PATH} else normalized +def _is_path_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True + + +def _resolve_local_user_data_path(thread_id: str, user_id: str, path: str) -> Path: + actual_path = resolve_virtual_path(thread_id, path, user_id=user_id) + resolved_path = actual_path.resolve() + allowed_roots = ( + sandbox_user_data_dir(thread_id).resolve(), + sandbox_workspace_dir(thread_id, user_id).resolve(), + ) + if not any(_is_path_within(resolved_path, root) for root in allowed_roots): + raise HTTPException(status_code=403, detail="Access denied") + return resolved_path + + def _is_user_data_path(path: str) -> bool: return path == USER_DATA_PATH or path.startswith(f"{USER_DATA_PATH}/") @@ -332,7 +352,7 @@ async def list_viewer_filesystem_tree( if normalized_path == USER_DATA_PATH: entries = await asyncio.to_thread(_list_user_data_root_entries, thread_id, user_id) return {"entries": _sort_entries(entries)} - actual_path = resolve_virtual_path(thread_id, normalized_path, user_id=user_id) + actual_path = _resolve_local_user_data_path(thread_id, user_id, normalized_path) if not actual_path.exists(): return {"entries": []} if not actual_path.is_dir(): @@ -379,7 +399,7 @@ async def read_viewer_file_content( try: if _is_user_data_path(normalized_path): - actual_path = resolve_virtual_path(thread_id, normalized_path, user_id=str(current_user.id)) + actual_path = _resolve_local_user_data_path(thread_id, str(current_user.id), normalized_path) if not actual_path.exists(): raise HTTPException(status_code=404, detail="文件不存在") if not actual_path.is_file(): @@ -472,7 +492,7 @@ async def download_viewer_file( try: if _is_user_data_path(normalized_path): - actual_path = resolve_virtual_path(thread_id, normalized_path, user_id=str(current_user.id)) + actual_path = _resolve_local_user_data_path(thread_id, str(current_user.id), normalized_path) if not actual_path.exists(): raise HTTPException(status_code=404, detail="文件不存在") if not actual_path.is_file(): @@ -546,7 +566,7 @@ async def delete_viewer_file( raise HTTPException(status_code=400, detail="当前目录不允许删除") try: - actual_path = resolve_virtual_path(thread_id, normalized_path, user_id=str(current_user.id)) + actual_path = _resolve_local_user_data_path(thread_id, str(current_user.id), normalized_path) if not actual_path.exists(): raise HTTPException(status_code=404, detail="文件不存在") if actual_path.is_dir(): diff --git a/backend/server/utils/auth_utils.py b/backend/server/utils/auth_utils.py index e69cd47e..1a65b256 100644 --- a/backend/server/utils/auth_utils.py +++ b/backend/server/utils/auth_utils.py @@ -1,9 +1,12 @@ import hashlib +import hmac import os from datetime import timedelta from typing import Any import jwt +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHash, VerificationError, VerifyMismatchError from yuxi.utils.datetime_utils import utc_now @@ -11,6 +14,7 @@ from yuxi.utils.datetime_utils import utc_now JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "yuxi_know_secure_key") JWT_ALGORITHM = "HS256" JWT_EXPIRATION = 7 * 24 * 60 * 60 # 7天过期 +PASSWORD_HASHER = PasswordHasher() class AuthUtils: @@ -18,28 +22,25 @@ class AuthUtils: @staticmethod def hash_password(password: str) -> str: - """使用SHA-256哈希密码""" - # 生成盐 - salt = os.urandom(32).hex() - # 哈希密码 - hashed = hashlib.sha256((password + salt).encode()).hexdigest() - # 返回格式: "哈希值:盐" - return f"{hashed}:{salt}" + """使用 Argon2 哈希密码""" + return PASSWORD_HASHER.hash(password) @staticmethod def verify_password(stored_password: str, provided_password: str) -> bool: """验证密码""" - # 分离哈希值和盐 + if stored_password.startswith("$argon2"): + try: + return PASSWORD_HASHER.verify(stored_password, provided_password) + except (InvalidHash, VerifyMismatchError, VerificationError): + return False + + # 兼容历史 SHA-256:盐 格式,避免现有账号密码在升级后立即失效。 if ":" not in stored_password: return False - hashed, salt = stored_password.split(":") - - # 使用相同的盐哈希提供的密码 + hashed, salt = stored_password.split(":", 1) check_hash = hashlib.sha256((provided_password + salt).encode()).hexdigest() - - # 比较哈希值 - return hashed == check_hash + return hmac.compare_digest(hashed, check_hash) @staticmethod def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: diff --git a/backend/test/integration/api/test_viewer_filesystem_security.py b/backend/test/integration/api/test_viewer_filesystem_security.py new file mode 100644 index 00000000..f24c10b7 --- /dev/null +++ b/backend/test/integration/api/test_viewer_filesystem_security.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import uuid + +import pytest +from yuxi.agents.backends.sandbox import ensure_thread_dirs, sandbox_workspace_dir, virtual_path_for_thread_file + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +async def _create_thread_for_user(test_client, headers: dict[str, str]) -> str: + agents_resp = await test_client.get("/api/chat/agent", headers=headers) + assert agents_resp.status_code == 200, agents_resp.text + agents = agents_resp.json().get("agents", []) + if not agents: + pytest.skip("No agents available for viewer filesystem integration tests.") + + agent_id = agents[0].get("id") + if not agent_id: + pytest.skip("Agent payload missing id field.") + + create_resp = await test_client.post( + "/api/chat/thread", + json={ + "agent_id": agent_id, + "title": f"viewer-security-test-{uuid.uuid4().hex[:8]}", + "metadata": {}, + }, + headers=headers, + ) + assert create_resp.status_code == 200, create_resp.text + payload = create_resp.json() + thread_id = payload.get("thread_id") or payload.get("id") + assert thread_id + return thread_id + + +async def test_viewer_download_blocks_workspace_symlink_escape(test_client, standard_user, tmp_path): + headers = standard_user["headers"] + user_id = str(standard_user["user"]["id"]) + thread_id = await _create_thread_for_user(test_client, headers) + + ensure_thread_dirs(thread_id, user_id) + workspace_dir = sandbox_workspace_dir(thread_id, user_id) + outside_file = tmp_path / "outside.txt" + outside_file.write_text("outside", encoding="utf-8") + symlink_path = workspace_dir / "escape.txt" + symlink_path.symlink_to(outside_file) + file_path = virtual_path_for_thread_file(thread_id, symlink_path, user_id=user_id) + + response = await test_client.get( + "/api/viewer/filesystem/download", + params={"thread_id": thread_id, "path": file_path}, + headers=headers, + ) + + assert response.status_code == 403, response.text diff --git a/backend/test/unit/services/test_thread_files_service.py b/backend/test/unit/services/test_thread_files_service.py new file mode 100644 index 00000000..7d19e798 --- /dev/null +++ b/backend/test/unit/services/test_thread_files_service.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi import HTTPException + +from yuxi.services import thread_files_service as svc + + +@pytest.mark.asyncio +async def test_resolve_thread_artifact_view_blocks_symlink_escape(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + thread_root = tmp_path / "threads" / "thread-1" / "user-data" + uploads_dir = thread_root / "uploads" + uploads_dir.mkdir(parents=True, exist_ok=True) + outside_file = tmp_path / "outside.txt" + outside_file.write_text("secret", encoding="utf-8") + (uploads_dir / "escape.txt").symlink_to(outside_file) + + class _Conversation: + user_id = "user-1" + + async def _fake_require_user_conversation(_repo, _thread_id: str, _current_user_id: str): + return _Conversation() + + monkeypatch.setattr(svc, "require_user_conversation", _fake_require_user_conversation) + monkeypatch.setattr(svc, "ensure_thread_dirs", lambda _thread_id, _user_id: None) + monkeypatch.setattr( + svc, + "sandbox_workspace_dir", + lambda _thread_id, _user_id: tmp_path / "shared" / _user_id / "workspace", + ) + monkeypatch.setattr(svc, "sandbox_uploads_dir", lambda _thread_id: uploads_dir) + monkeypatch.setattr(svc, "sandbox_outputs_dir", lambda _thread_id: thread_root / "outputs") + monkeypatch.setattr(svc, "resolve_virtual_path", lambda _thread_id, _path, *, user_id: uploads_dir / "escape.txt") + monkeypatch.setattr(svc, "ConversationRepository", lambda _db: object()) + + with pytest.raises(HTTPException, match="access denied"): + await svc.resolve_thread_artifact_view( + thread_id="thread-1", + current_user_id="user-1", + db=None, + path="/home/gem/user-data/uploads/escape.txt", + ) diff --git a/backend/test/unit/test_auth_utils.py b/backend/test/unit/test_auth_utils.py new file mode 100644 index 00000000..267f9860 --- /dev/null +++ b/backend/test/unit/test_auth_utils.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import hashlib + +from server.utils.auth_utils import AuthUtils + + +def test_hash_password_uses_argon2(): + hashed = AuthUtils.hash_password("secret-password") + + assert hashed.startswith("$argon2") + assert AuthUtils.verify_password(hashed, "secret-password") is True + assert AuthUtils.verify_password(hashed, "wrong-password") is False + + +def test_verify_password_accepts_legacy_sha256_format(): + legacy_hash = hashlib.sha256(b"secret-passwordsalt").hexdigest() + + assert AuthUtils.verify_password(f"{legacy_hash}:salt", "secret-password") is True + assert AuthUtils.verify_password(f"{legacy_hash}:salt", "wrong-password") is False diff --git a/backend/uv.lock b/backend/uv.lock index 0e2c3cdf..1b27bc5b 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -5713,6 +5713,7 @@ dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, { name = "aiosqlite" }, + { name = "argon2-cffi" }, { name = "asyncpg" }, { name = "chardet" }, { name = "colorlog" }, @@ -5785,6 +5786,7 @@ requires-dist = [ { name = "aiofiles", specifier = ">=24.1.0" }, { name = "aiohttp", specifier = ">=3.9.0" }, { name = "aiosqlite", specifier = ">=0.20.0" }, + { name = "argon2-cffi", specifier = ">=25.1.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "chardet", specifier = ">=5.0.0" }, { name = "colorlog", specifier = ">=6.9.0" }, diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 20b98b1a..d48dfafc 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -37,6 +37,7 @@ - 调整 backend Python 工作区依赖边界:将 `backend/package/yuxi` 明确为承载核心运行依赖的业务包,根 `backend/pyproject.toml` 仅保留工作区入口与开发/测试配置,减少依赖职责混淆。 - 修复沙盒 `workspace` 隔离粒度:宿主机目录从共享 `saves/threads/shared/workspace` 收敛为用户级 `saves/threads/shared//workspace`,并同步传递 `user_id` 到 sandbox 路径解析、provisioner 挂载与 viewer/chat 测试,保证同用户跨线程共享、不同用户隔离。 - 调整输入框 `@` 提及中的文件搜索交互:无查询内容时不再直接展示文件列表,改为提示“输入相关内容以搜索文件”,避免未过滤结果干扰选择。 +- 收紧文件系统安全边界:viewer/chat 下载与删除路径统一基于解析后的真实路径做允许目录校验,阻止通过软链接逃逸工作区/线程目录;同时将密码哈希默认实现升级为 Argon2,并移除 skill frontmatter 解析中的正则回溯风险。 历史版本发布记录已迁移到 [版本变更记录](./changelog.md)。