fix: 修复路径遍历安全漏洞并添加文件名校验
- 前端:在 ExtensionsView.vue 上传前添加 beforeUpload 校验, 仅允许 .zip 或 SKILL.md 文件,其他 .md 文件给出明确错误提示 - 后端:import_skill_dir 添加路径限制检查(is_relative_to), 确保来源目录在系统临时目录内,防止路径遍历 - 后端:install_remote_skill 改为扫描目录查找安装结果, 避免直接用用户输入构造文件路径(消除 CodeQL 告警) - 后端:_run_skills_cli 错误输出清洗 ANSI 控制字符并截断到 500 字符 - 后端:install_remote_skill 返回类型从 object 修正为 Skill Agent-Logs-Url: https://github.com/xerrors/Yuxi/sessions/0145c647-72bc-41f9-b07b-04d4acf51932 Co-authored-by: xerrors <35524243+xerrors@users.noreply.github.com>
This commit is contained in:
parent
cf0f0afd85
commit
037872924b
@ -6,10 +6,14 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from yuxi.services.skill_service import import_skill_dir, is_valid_skill_slug
|
from yuxi.services.skill_service import import_skill_dir, is_valid_skill_slug
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from yuxi.storage.postgres.models_business import Skill
|
||||||
|
|
||||||
ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
|
ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
|
||||||
CONTROL_SEQUENCE_RE = re.compile(r"\x1B\][^\x07]*(?:\x07|\x1B\\)|\x1B[\(\)][A-Za-z0-9]")
|
CONTROL_SEQUENCE_RE = re.compile(r"\x1B\][^\x07]*(?:\x07|\x1B\\)|\x1B[\(\)][A-Za-z0-9]")
|
||||||
CLI_TIMEOUT_SECONDS = 300
|
CLI_TIMEOUT_SECONDS = 300
|
||||||
@ -111,7 +115,9 @@ async def _run_skills_cli(
|
|||||||
error_output = (stderr or b"").decode("utf-8", errors="replace")
|
error_output = (stderr or b"").decode("utf-8", errors="replace")
|
||||||
combined = "\n".join(part for part in [output.strip(), error_output.strip()] if part)
|
combined = "\n".join(part for part in [output.strip(), error_output.strip()] if part)
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
raise ValueError(combined or "skills CLI 执行失败")
|
cleaned_lines = _clean_cli_output(combined)
|
||||||
|
error_msg = "\n".join(line for line in cleaned_lines if line)[:500]
|
||||||
|
raise ValueError(error_msg or "skills CLI 执行失败")
|
||||||
return combined
|
return combined
|
||||||
|
|
||||||
|
|
||||||
@ -149,7 +155,7 @@ async def install_remote_skill(
|
|||||||
source: str,
|
source: str,
|
||||||
skill: str,
|
skill: str,
|
||||||
created_by: str | None,
|
created_by: str | None,
|
||||||
) -> object:
|
) -> Skill:
|
||||||
normalized_source = _normalize_source(source)
|
normalized_source = _normalize_source(source)
|
||||||
normalized_skill = _normalize_skill_name(skill)
|
normalized_skill = _normalize_skill_name(skill)
|
||||||
|
|
||||||
@ -183,8 +189,17 @@ async def install_remote_skill(
|
|||||||
cwd=workdir,
|
cwd=workdir,
|
||||||
)
|
)
|
||||||
|
|
||||||
installed_dir = Path(temp_home) / ".agents" / "skills" / normalized_skill
|
base_dir = Path(temp_home).resolve()
|
||||||
if not installed_dir.exists() or not installed_dir.is_dir():
|
skills_dir = base_dir / ".agents" / "skills"
|
||||||
|
# Scan for the installed skill directory rather than constructing the path
|
||||||
|
# from user input, to avoid path traversal concerns
|
||||||
|
installed_dir = None
|
||||||
|
if skills_dir.is_dir():
|
||||||
|
for candidate in skills_dir.iterdir():
|
||||||
|
if candidate.name == normalized_skill and candidate.is_dir():
|
||||||
|
installed_dir = candidate
|
||||||
|
break
|
||||||
|
if installed_dir is None:
|
||||||
raise ValueError("skills CLI 未生成预期的技能目录")
|
raise ValueError("skills CLI 未生成预期的技能目录")
|
||||||
|
|
||||||
return await import_skill_dir(
|
return await import_skill_dir(
|
||||||
|
|||||||
@ -594,6 +594,10 @@ async def import_skill_dir(
|
|||||||
created_by: str | None,
|
created_by: str | None,
|
||||||
) -> Skill:
|
) -> Skill:
|
||||||
source_skill_dir = Path(source_dir).resolve()
|
source_skill_dir = Path(source_dir).resolve()
|
||||||
|
# Confine to the system temp directory to prevent path traversal
|
||||||
|
tmp_root = Path(tempfile.gettempdir()).resolve()
|
||||||
|
if not source_skill_dir.is_relative_to(tmp_root):
|
||||||
|
raise ValueError("技能目录路径不合法")
|
||||||
if not source_skill_dir.exists() or not source_skill_dir.is_dir():
|
if not source_skill_dir.exists() or not source_skill_dir.is_dir():
|
||||||
raise ValueError("技能目录不存在")
|
raise ValueError("技能目录不存在")
|
||||||
return await _import_skill_dir_impl(
|
return await _import_skill_dir_impl(
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
accept=".zip,.md"
|
accept=".zip,.md"
|
||||||
:show-upload-list="false"
|
:show-upload-list="false"
|
||||||
:custom-request="handleImportUpload"
|
:custom-request="handleImportUpload"
|
||||||
|
:before-upload="beforeSkillUpload"
|
||||||
:disabled="skillsLoading || skillsImporting"
|
:disabled="skillsLoading || skillsImporting"
|
||||||
>
|
>
|
||||||
<a-button type="primary" :loading="skillsImporting" class="lucide-icon-btn">
|
<a-button type="primary" :loading="skillsImporting" class="lucide-icon-btn">
|
||||||
@ -98,6 +99,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
import { Upload, RotateCw, Plus, Computer } from 'lucide-vue-next'
|
import { Upload, RotateCw, Plus, Computer } from 'lucide-vue-next'
|
||||||
import SkillsManagerComponent from '@/components/SkillsManagerComponent.vue'
|
import SkillsManagerComponent from '@/components/SkillsManagerComponent.vue'
|
||||||
import ToolsManagerComponent from '@/components/ToolsManagerComponent.vue'
|
import ToolsManagerComponent from '@/components/ToolsManagerComponent.vue'
|
||||||
@ -210,6 +212,16 @@ const handleSubagentRefresh = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 上传前校验文件名:仅允许 .zip 或 SKILL.md
|
||||||
|
const beforeSkillUpload = (file) => {
|
||||||
|
const lower = file.name.toLowerCase()
|
||||||
|
if (!lower.endsWith('.zip') && lower !== 'skill.md') {
|
||||||
|
message.error('仅支持上传 .zip 文件或 SKILL.md 文件')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// 处理导入上传
|
// 处理导入上传
|
||||||
const handleImportUpload = async ({ file, onSuccess, onError }) => {
|
const handleImportUpload = async ({ file, onSuccess, onError }) => {
|
||||||
if (skillsRef.value?.handleImportUpload) {
|
if (skillsRef.value?.handleImportUpload) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user