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

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_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 必须是对象")

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):
raise HTTPException(status_code=403, detail="access denied")
return actual_path
return resolved_path
async def save_thread_artifact_to_workspace_view(

View File

@ -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():

View File

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

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 = "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" },

View File

@ -37,6 +37,7 @@
- 调整 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 测试,保证同用户跨线程共享、不同用户隔离。
- 调整输入框 `@` 提及中的文件搜索交互:无查询内容时不再直接展示文件列表,改为提示“输入相关内容以搜索文件”,避免未过滤结果干扰选择。
- 收紧文件系统安全边界viewer/chat 下载与删除路径统一基于解析后的真实路径做允许目录校验,阻止通过软链接逃逸工作区/线程目录;同时将密码哈希默认实现升级为 Argon2并移除 skill frontmatter 解析中的正则回溯风险。
历史版本发布记录已迁移到 [版本变更记录](./changelog.md)。