feat: 增加 Argon2 密码哈希支持,优化文件系统安全性,修复 frontmatter 解析

This commit is contained in:
Wenjie Zhang 2026-04-02 16:03:01 +08:00
parent efee3c743a
commit 1f792fa914
10 changed files with 190 additions and 31 deletions

View File

@ -10,6 +10,7 @@ dependencies = [
"aiofiles>=24.1.0", "aiofiles>=24.1.0",
"aiohttp>=3.9.0", "aiohttp>=3.9.0",
"aiosqlite>=0.20.0", "aiosqlite>=0.20.0",
"argon2-cffi>=25.1.0",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"chardet>=5.0.0", "chardet>=5.0.0",
"colorlog>=6.9.0", "colorlog>=6.9.0",

View File

@ -21,7 +21,6 @@ from yuxi.utils.logging_config import logger
SKILL_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") SKILL_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
SKILL_NAME_PATTERN = SKILL_SLUG_PATTERN SKILL_NAME_PATTERN = SKILL_SLUG_PATTERN
FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
TEXT_FILE_EXTENSIONS = { TEXT_FILE_EXTENSIONS = {
".md", ".md",
@ -357,12 +356,31 @@ def _validate_skill_name(name: str) -> str:
return name return name
def _parse_skill_markdown(content: str) -> tuple[str, str, dict[str, Any]]: def _split_frontmatter(content: str) -> tuple[str, str]:
match = FRONTMATTER_PATTERN.match(content) if not content.startswith("---"):
if not match:
raise ValueError("SKILL.md 缺少有效 frontmatter--- ... ---") 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: try:
data = yaml.safe_load(frontmatter_raw) data = yaml.safe_load(frontmatter_raw)
except yaml.YAMLError as e: 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: def _rewrite_frontmatter_name(content: str, new_name: str) -> str:
match = FRONTMATTER_PATTERN.match(content) frontmatter_raw, body = _split_frontmatter(content)
if not match:
raise ValueError("SKILL.md 缺少有效 frontmatter--- ... ---")
frontmatter_raw = match.group(1)
body = content[match.end() :]
data = yaml.safe_load(frontmatter_raw) data = yaml.safe_load(frontmatter_raw)
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("SKILL.md frontmatter 必须是对象") raise ValueError("SKILL.md frontmatter 必须是对象")

View File

@ -228,7 +228,7 @@ async def resolve_thread_artifact_view(
if not any(_is_path_within(resolved_path, root) for root in allowed_roots): if not any(_is_path_within(resolved_path, root) for root in allowed_roots):
raise HTTPException(status_code=403, detail="access denied") raise HTTPException(status_code=403, detail="access denied")
return actual_path return resolved_path
async def save_thread_artifact_to_workspace_view( async def save_thread_artifact_to_workspace_view(

View File

@ -4,7 +4,7 @@ import asyncio
import io import io
import mimetypes import mimetypes
import shutil import shutil
from pathlib import PurePosixPath from pathlib import Path, PurePosixPath
from urllib.parse import quote from urllib.parse import quote
from fastapi import HTTPException 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 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: def _is_user_data_path(path: str) -> bool:
return path == USER_DATA_PATH or path.startswith(f"{USER_DATA_PATH}/") 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: if normalized_path == USER_DATA_PATH:
entries = await asyncio.to_thread(_list_user_data_root_entries, thread_id, user_id) entries = await asyncio.to_thread(_list_user_data_root_entries, thread_id, user_id)
return {"entries": _sort_entries(entries)} 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(): if not actual_path.exists():
return {"entries": []} return {"entries": []}
if not actual_path.is_dir(): if not actual_path.is_dir():
@ -379,7 +399,7 @@ async def read_viewer_file_content(
try: try:
if _is_user_data_path(normalized_path): 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(): if not actual_path.exists():
raise HTTPException(status_code=404, detail="文件不存在") raise HTTPException(status_code=404, detail="文件不存在")
if not actual_path.is_file(): if not actual_path.is_file():
@ -472,7 +492,7 @@ async def download_viewer_file(
try: try:
if _is_user_data_path(normalized_path): 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(): if not actual_path.exists():
raise HTTPException(status_code=404, detail="文件不存在") raise HTTPException(status_code=404, detail="文件不存在")
if not actual_path.is_file(): if not actual_path.is_file():
@ -546,7 +566,7 @@ async def delete_viewer_file(
raise HTTPException(status_code=400, detail="当前目录不允许删除") raise HTTPException(status_code=400, detail="当前目录不允许删除")
try: 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(): if not actual_path.exists():
raise HTTPException(status_code=404, detail="文件不存在") raise HTTPException(status_code=404, detail="文件不存在")
if actual_path.is_dir(): if actual_path.is_dir():

View File

@ -1,9 +1,12 @@
import hashlib import hashlib
import hmac
import os import os
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any
import jwt import jwt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHash, VerificationError, VerifyMismatchError
from yuxi.utils.datetime_utils import utc_now 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_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "yuxi_know_secure_key")
JWT_ALGORITHM = "HS256" JWT_ALGORITHM = "HS256"
JWT_EXPIRATION = 7 * 24 * 60 * 60 # 7天过期 JWT_EXPIRATION = 7 * 24 * 60 * 60 # 7天过期
PASSWORD_HASHER = PasswordHasher()
class AuthUtils: class AuthUtils:
@ -18,28 +22,25 @@ class AuthUtils:
@staticmethod @staticmethod
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
"""使用SHA-256哈希密码""" """使用 Argon2 哈希密码"""
# 生成盐 return PASSWORD_HASHER.hash(password)
salt = os.urandom(32).hex()
# 哈希密码
hashed = hashlib.sha256((password + salt).encode()).hexdigest()
# 返回格式: "哈希值:盐"
return f"{hashed}:{salt}"
@staticmethod @staticmethod
def verify_password(stored_password: str, provided_password: str) -> bool: 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: if ":" not in stored_password:
return False return False
hashed, salt = stored_password.split(":") hashed, salt = stored_password.split(":", 1)
# 使用相同的盐哈希提供的密码
check_hash = hashlib.sha256((provided_password + salt).encode()).hexdigest() check_hash = hashlib.sha256((provided_password + salt).encode()).hexdigest()
return hmac.compare_digest(hashed, check_hash)
# 比较哈希值
return hashed == check_hash
@staticmethod @staticmethod
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:

View File

@ -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

View File

@ -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",
)

View File

@ -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

View File

@ -5713,6 +5713,7 @@ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "aiosqlite" }, { name = "aiosqlite" },
{ name = "argon2-cffi" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "chardet" }, { name = "chardet" },
{ name = "colorlog" }, { name = "colorlog" },
@ -5785,6 +5786,7 @@ requires-dist = [
{ name = "aiofiles", specifier = ">=24.1.0" }, { name = "aiofiles", specifier = ">=24.1.0" },
{ name = "aiohttp", specifier = ">=3.9.0" }, { name = "aiohttp", specifier = ">=3.9.0" },
{ name = "aiosqlite", specifier = ">=0.20.0" }, { name = "aiosqlite", specifier = ">=0.20.0" },
{ name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "asyncpg", specifier = ">=0.30.0" }, { name = "asyncpg", specifier = ">=0.30.0" },
{ name = "chardet", specifier = ">=5.0.0" }, { name = "chardet", specifier = ">=5.0.0" },
{ name = "colorlog", specifier = ">=6.9.0" }, { name = "colorlog", specifier = ">=6.9.0" },

View File

@ -37,6 +37,7 @@
- 调整 backend Python 工作区依赖边界:将 `backend/package/yuxi` 明确为承载核心运行依赖的业务包,根 `backend/pyproject.toml` 仅保留工作区入口与开发/测试配置,减少依赖职责混淆。 - 调整 backend Python 工作区依赖边界:将 `backend/package/yuxi` 明确为承载核心运行依赖的业务包,根 `backend/pyproject.toml` 仅保留工作区入口与开发/测试配置,减少依赖职责混淆。
- 修复沙盒 `workspace` 隔离粒度:宿主机目录从共享 `saves/threads/shared/workspace` 收敛为用户级 `saves/threads/shared/<user_id>/workspace`,并同步传递 `user_id` 到 sandbox 路径解析、provisioner 挂载与 viewer/chat 测试,保证同用户跨线程共享、不同用户隔离。 - 修复沙盒 `workspace` 隔离粒度:宿主机目录从共享 `saves/threads/shared/workspace` 收敛为用户级 `saves/threads/shared/<user_id>/workspace`,并同步传递 `user_id` 到 sandbox 路径解析、provisioner 挂载与 viewer/chat 测试,保证同用户跨线程共享、不同用户隔离。
- 调整输入框 `@` 提及中的文件搜索交互:无查询内容时不再直接展示文件列表,改为提示“输入相关内容以搜索文件”,避免未过滤结果干扰选择。 - 调整输入框 `@` 提及中的文件搜索交互:无查询内容时不再直接展示文件列表,改为提示“输入相关内容以搜索文件”,避免未过滤结果干扰选择。
- 收紧文件系统安全边界viewer/chat 下载与删除路径统一基于解析后的真实路径做允许目录校验,阻止通过软链接逃逸工作区/线程目录;同时将密码哈希默认实现升级为 Argon2并移除 skill frontmatter 解析中的正则回溯风险。
历史版本发布记录已迁移到 [版本变更记录](./changelog.md)。 历史版本发布记录已迁移到 [版本变更记录](./changelog.md)。