feat: 增加 Argon2 密码哈希支持,优化文件系统安全性,修复 frontmatter 解析
This commit is contained in:
parent
efee3c743a
commit
1f792fa914
@ -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",
|
||||
|
||||
@ -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 必须是对象")
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
44
backend/test/unit/services/test_thread_files_service.py
Normal file
44
backend/test/unit/services/test_thread_files_service.py
Normal 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",
|
||||
)
|
||||
20
backend/test/unit/test_auth_utils.py
Normal file
20
backend/test/unit/test_auth_utils.py
Normal 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
|
||||
@ -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" },
|
||||
|
||||
@ -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)。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user