From 4a9b2e1b399cdf0c58dfdc7149c7fd4bb692f09c Mon Sep 17 00:00:00 2001 From: Joshua Medvinsky Date: Sat, 25 Jul 2026 08:12:06 -0700 Subject: [PATCH] fix: restrict remote skill sources to GitHub --- .../yuxi/agents/skills/remote_install.py | 22 +++++++++++++++++- .../unit/agents/skills/test_remote_install.py | 23 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/backend/package/yuxi/agents/skills/remote_install.py b/backend/package/yuxi/agents/skills/remote_install.py index 1b5a01e41..ca3875963 100644 --- a/backend/package/yuxi/agents/skills/remote_install.py +++ b/backend/package/yuxi/agents/skills/remote_install.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING +from urllib.parse import urlparse from sqlalchemy.ext.asyncio import AsyncSession @@ -19,6 +20,9 @@ ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") CONTROL_SEQUENCE_RE = re.compile(r"\x1B\][^\x07]*(?:\x07|\x1B\\)|\x1B[\(\)][A-Za-z0-9]") CLI_TIMEOUT_SECONDS = 300 +GITHUB_REPO_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +GITHUB_ALLOWED_HOSTS = {"github.com", "www.github.com"} +INVALID_SOURCE_MESSAGE = "source 仅支持 GitHub owner/repo 或 https://github.com/OWNER/REPO(.git) 格式" @dataclass(slots=True) @@ -37,7 +41,23 @@ def _normalize_source(source: str) -> str: raise ValueError("source 不能为空") if any(ch in value for ch in ("\n", "\r", "\x00")): raise ValueError("source 包含非法字符") - return value + if GITHUB_REPO_PATTERN.fullmatch(value): + return value + + parsed = urlparse(value) + if parsed.scheme != "https" or parsed.hostname not in GITHUB_ALLOWED_HOSTS: + raise ValueError(INVALID_SOURCE_MESSAGE) + if parsed.username or parsed.password or parsed.port or parsed.query or parsed.fragment: + raise ValueError(INVALID_SOURCE_MESSAGE) + + repo_path = parsed.path.strip("/") + if repo_path.endswith(".git"): + repo_path = repo_path[:-4] + if not GITHUB_REPO_PATTERN.fullmatch(repo_path): + raise ValueError(INVALID_SOURCE_MESSAGE) + + owner, repo = repo_path.split("/", 1) + return f"https://github.com/{owner}/{repo}" def _normalize_skill_name(skill: str) -> str: diff --git a/backend/test/unit/agents/skills/test_remote_install.py b/backend/test/unit/agents/skills/test_remote_install.py index 7a8cd81a2..74906274a 100644 --- a/backend/test/unit/agents/skills/test_remote_install.py +++ b/backend/test/unit/agents/skills/test_remote_install.py @@ -8,6 +8,29 @@ from yuxi.agents.skills import remote_install as svc +def test_normalize_source_accepts_owner_repo() -> None: + assert svc._normalize_source("anthropics/skills") == "anthropics/skills" + + +def test_normalize_source_accepts_canonical_github_url() -> None: + assert svc._normalize_source("https://www.github.com/anthropics/skills.git") == "https://github.com/anthropics/skills" + + +@pytest.mark.parametrize( + "source", + [ + "http://127.0.0.1:9/repo.git", + "https://example.com/owner/repo", + "git@github.com:anthropics/skills.git", + "file:///tmp/skills", + "https://github.com/anthropics/skills?tab=readme", + ], +) +def test_normalize_source_rejects_non_github_or_unsafe_url(source: str) -> None: + with pytest.raises(ValueError, match="GitHub owner/repo"): + svc._normalize_source(source) + + def test_parse_available_skills_from_cli_output() -> None: output = """ \x1b[38;5;250m███████╗\x1b[0m