From bf4fdf13776eb64b490df263143a183c4f30c480 Mon Sep 17 00:00:00 2001 From: dnslin Date: Fri, 12 Dec 2025 16:42:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=BC=8F=E6=B4=9E=E5=92=8C=E4=BB=A3=E7=A0=81=E8=B4=A8=E9=87=8F?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全修复: - 修复路径遍历检查,使用 Path.relative_to() 替代字符串前缀检查 - 修复 Zip Slip 漏洞,添加符号链接检查和路径验证 - 隐藏 RPC 密钥显示,防止敏感信息泄露 - 设置配置文件权限为 0o600 Bug 修复: - 修复 HTTP 状态码检查(resp.status → resp.code) - 修复 OneDrive 认证 flow 参数类型 - 修复 RPC 请求缺少状态码验证 - 修复配置文件渲染会替换注释行的问题 代码改进: - 添加 subprocess 超时处理,防止进程挂起 - 修复异步代码问题(get_event_loop → get_running_loop) - 使用 asyncio.to_thread 避免阻塞事件循环 - 添加 httpx 超时和状态码异常处理 - 移除无用的 ONEDRIVE_CLIENT_SECRET 配置 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .env.example | 5 ++--- src/aria2/installer.py | 21 +++++++++++++++++++-- src/aria2/rpc.py | 9 ++++++++- src/aria2/service.py | 13 +++++++++++-- src/cloud/onedrive.py | 12 ++++++++---- src/core/config.py | 6 ++---- src/core/system.py | 9 ++++++--- src/telegram/handlers.py | 22 +++++++++++----------- 8 files changed, 67 insertions(+), 30 deletions(-) diff --git a/.env.example b/.env.example index 70192c8..6e19f5c 100644 --- a/.env.example +++ b/.env.example @@ -17,12 +17,11 @@ ARIA2_RPC_SECRET= # 启用 OneDrive 云存储功能 ONEDRIVE_ENABLED=false -# Microsoft Azure 应用凭证 +# Microsoft Azure 应用凭证(使用公共客户端认证,不需要 client_secret) # 在 Azure Portal 创建应用:https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade # 需要添加 API 权限:Files.ReadWrite, offline_access +# 注意:应用类型选择"公共客户端/本机",不需要配置客户端密码 ONEDRIVE_CLIENT_ID= -# 不再需要 -ONEDRIVE_CLIENT_SECRET= # 租户 ID(个人账户使用 common,组织账户使用具体租户 ID) ONEDRIVE_TENANT_ID=common diff --git a/src/aria2/installer.py b/src/aria2/installer.py index d05d631..4f0ce66 100644 --- a/src/aria2/installer.py +++ b/src/aria2/installer.py @@ -197,6 +197,10 @@ class Aria2Installer: new_lines: list[str] = [] for line in content.splitlines(): stripped = line.lstrip() + # 跳过注释行,不进行替换 + if stripped.startswith("#"): + new_lines.append(line) + continue replaced = False for key, value in replacements.items(): if stripped.startswith(key): @@ -209,7 +213,10 @@ class Aria2Installer: try: ARIA2_CONF.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + # 设置配置文件权限为仅所有者可读写(包含敏感的 RPC 密钥) + ARIA2_CONF.chmod(0o600) ARIA2_SESSION.touch(exist_ok=True) + ARIA2_SESSION.chmod(0o600) self.config.download_dir.mkdir(parents=True, exist_ok=True) ARIA2_LOG.touch(exist_ok=True) logger.info(f"配置文件已保存: {ARIA2_CONF}") @@ -276,8 +283,10 @@ class Aria2Installer: req = request.Request(url, headers={"User-Agent": "aria2-installer"}) try: with request.urlopen(req, timeout=30) as resp: - if getattr(resp, "status", 200) >= 400: - raise DownloadError(f"HTTP {resp.status} for {url}") + # 检查 HTTP 状态码(urllib 使用 code 属性) + status_code = getattr(resp, "code", 200) + if status_code >= 400: + raise DownloadError(f"HTTP {status_code} for {url}") return resp.read() except (error.HTTPError, error.URLError) as exc: raise DownloadError(f"Network error for {url}: {exc}") from exc @@ -292,8 +301,16 @@ class Aria2Installer: with tarfile.open(archive_path, "r:gz") as tar: # 安全检查:验证所有成员路径,防止 Zip Slip 攻击 for member in tar.getmembers(): + # 检查符号链接 + if member.issym() or member.islnk(): + raise DownloadError(f"不安全的 tar 成员(符号链接): {member.name}") + # 检查路径遍历 if member.name.startswith('/') or '..' in member.name: raise DownloadError(f"不安全的 tar 成员: {member.name}") + # 验证解压后的路径 + member_path = (extract_dir / member.name).resolve() + if not str(member_path).startswith(str(extract_dir.resolve())): + raise DownloadError(f"不安全的 tar 成员(路径遍历): {member.name}") tar.extractall(extract_dir) for candidate in extract_dir.rglob("aria2c"): if candidate.is_file(): diff --git a/src/aria2/rpc.py b/src/aria2/rpc.py index 564570c..aa73396 100644 --- a/src/aria2/rpc.py +++ b/src/aria2/rpc.py @@ -86,9 +86,14 @@ class Aria2RpcClient: try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.post(self.url, json=payload) + resp.raise_for_status() data = resp.json() except httpx.ConnectError: raise RpcError("aria2 服务可能未运行,请先使用 /start 命令启动服务") from None + except httpx.TimeoutException: + raise RpcError("RPC 请求超时,aria2 服务响应缓慢") from None + except httpx.HTTPStatusError as e: + raise RpcError(f"RPC 请求失败,HTTP 状态码: {e.response.status_code}") from e except httpx.RequestError as e: raise RpcError(f"RPC 请求失败: {e}") from e except json.JSONDecodeError as e: @@ -184,7 +189,9 @@ class Aria2RpcClient: # 安全检查:验证路径在下载目录内,防止路径遍历攻击 from src.core.constants import DOWNLOAD_DIR download_dir = DOWNLOAD_DIR.resolve() - if not str(file_path).startswith(str(download_dir) + "/"): + try: + file_path.relative_to(download_dir) + except ValueError: logger.error(f"路径遍历尝试被阻止: {file_path}") return False if file_path.exists(): diff --git a/src/aria2/service.py b/src/aria2/service.py index efe7067..43752f6 100644 --- a/src/aria2/service.py +++ b/src/aria2/service.py @@ -48,9 +48,12 @@ class Aria2ServiceManager: capture_output=True, text=True, check=True, + timeout=30, ) except FileNotFoundError as exc: raise ServiceError("systemctl command not found") from exc + except subprocess.TimeoutExpired as exc: + raise ServiceError(f"systemctl 命令超时: {args}") from exc except subprocess.CalledProcessError as exc: output = exc.stderr.strip() or exc.stdout.strip() or str(exc) raise ServiceError(output) from exc @@ -102,15 +105,19 @@ class Aria2ServiceManager: capture_output=True, text=True, check=False, + timeout=10, ) enabled_proc = subprocess.run( ["systemctl", "--user", "is-enabled", "aria2"], capture_output=True, text=True, check=False, + timeout=10, ) except FileNotFoundError as exc: raise ServiceError("systemctl command not found") from exc + except subprocess.TimeoutExpired as exc: + raise ServiceError("获取服务状态超时") from exc running = active_proc.returncode == 0 enabled = enabled_proc.returncode == 0 @@ -130,8 +137,9 @@ class Aria2ServiceManager: capture_output=True, text=True, check=False, + timeout=5, ) - except FileNotFoundError: + except (FileNotFoundError, subprocess.TimeoutExpired): result = None if result and result.returncode == 0: @@ -146,8 +154,9 @@ class Aria2ServiceManager: capture_output=True, text=True, check=False, + timeout=5, ) - except FileNotFoundError: + except (FileNotFoundError, subprocess.TimeoutExpired): return None for line in ps_result.stdout.splitlines(): diff --git a/src/cloud/onedrive.py b/src/cloud/onedrive.py index 3fffed8..c419289 100644 --- a/src/cloud/onedrive.py +++ b/src/cloud/onedrive.py @@ -99,18 +99,22 @@ class OneDriveClient(CloudStorageBase): account = self._get_account() return account.is_authenticated - async def get_auth_url(self) -> tuple[str, str]: - """获取认证 URL""" + async def get_auth_url(self) -> tuple[str, dict]: + """获取认证 URL + + 返回: + tuple[str, dict]: (认证 URL, flow 字典用于后续认证) + """ account = self._get_account() # MSAL 保留的 scope 不能传入,会自动处理 reserved = {"openid", "offline_access", "profile"} scopes = [s for s in self.SCOPES if s not in reserved] redirect_uri = "https://login.microsoftonline.com/common/oauth2/nativeclient" - url, state = account.con.get_authorization_url( + url, flow = account.con.get_authorization_url( requested_scopes=scopes, redirect_uri=redirect_uri ) - return url, state + return url, flow async def authenticate_with_code(self, callback_url: str, flow: dict | None = None) -> bool: """使用回调 URL 完成认证""" diff --git a/src/core/config.py b/src/core/config.py index 094f083..3fdffba 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -20,10 +20,9 @@ class Aria2Config: @dataclass class OneDriveConfig: - """OneDrive 配置""" + """OneDrive 配置(使用公共客户端认证,不需要 client_secret)""" enabled: bool = False client_id: str = "" - client_secret: str = "" tenant_id: str = "common" auto_upload: bool = False delete_after_upload: bool = False @@ -76,11 +75,10 @@ class BotConfig: rpc_secret=os.environ.get("ARIA2_RPC_SECRET", ""), ) - # 解析 OneDrive 配置 + # 解析 OneDrive 配置(使用公共客户端认证,不需要 client_secret) onedrive = OneDriveConfig( enabled=os.environ.get("ONEDRIVE_ENABLED", "").lower() == "true", client_id=os.environ.get("ONEDRIVE_CLIENT_ID", ""), - client_secret=os.environ.get("ONEDRIVE_CLIENT_SECRET", ""), tenant_id=os.environ.get("ONEDRIVE_TENANT_ID", "common"), auto_upload=os.environ.get("ONEDRIVE_AUTO_UPLOAD", "").lower() == "true", delete_after_upload=os.environ.get("ONEDRIVE_DELETE_AFTER_UPLOAD", "").lower() == "true", diff --git a/src/core/system.py b/src/core/system.py index 4b35e76..696b96c 100644 --- a/src/core/system.py +++ b/src/core/system.py @@ -75,9 +75,12 @@ def get_aria2_version() -> str | None: return None for cmd in candidates: - result = subprocess.run( - [str(cmd), "-v"], capture_output=True, text=True, check=False - ) + try: + result = subprocess.run( + [str(cmd), "-v"], capture_output=True, text=True, check=False, timeout=5 + ) + except subprocess.TimeoutExpired: + continue if result.returncode != 0: continue for line in result.stdout.splitlines(): diff --git a/src/telegram/handlers.py b/src/telegram/handlers.py index 51654ff..05f54d3 100644 --- a/src/telegram/handlers.py +++ b/src/telegram/handlers.py @@ -203,7 +203,7 @@ class Aria2BotAPI: f"配置目录:{result.get('config_dir')}", f"配置文件:{result.get('config')}", f"RPC 端口:{rpc_port}", - f"RPC 密钥:{rpc_secret}", + f"RPC 密钥:{rpc_secret[:4]}****{rpc_secret[-4:] if len(rpc_secret) > 8 else '****'}", ] ), ) @@ -305,7 +305,7 @@ class Aria2BotAPI: f"- PID:`{info.get('pid') or 'N/A'}`\n" f"- 版本:`{version}`\n" f"- RPC 端口:`{rpc_port}`\n" - f"- RPC 密钥:`{rpc_secret}`" + f"- RPC 密钥:`{rpc_secret[:4]}****{rpc_secret[-4:] if len(rpc_secret) > 8 else '****'}`" ) await self._reply(update, context, text, parse_mode="Markdown") logger.info(f"/status 命令执行成功 - {_get_user_info(update)}") @@ -358,7 +358,7 @@ class Aria2BotAPI: self.service.update_rpc_secret(new_secret) self.config.rpc_secret = new_secret self.service.restart() - await self._reply(update, context, f"RPC 密钥已更新并重启服务 ✅\n新密钥: `{new_secret}`", parse_mode="Markdown") + await self._reply(update, context, f"RPC 密钥已更新并重启服务 ✅\n新密钥: `{new_secret[:4]}****{new_secret[-4:]}`", parse_mode="Markdown") logger.info(f"/set_secret 命令执行成功 - {_get_user_info(update)}") except ConfigError as exc: logger.error(f"/set_secret 命令执行失败: {exc} - {_get_user_info(update)}") @@ -378,7 +378,7 @@ class Aria2BotAPI: self.service.update_rpc_secret(new_secret) self.config.rpc_secret = new_secret self.service.restart() - await self._reply(update, context, f"RPC 密钥已重新生成并重启服务 ✅\n新密钥: `{new_secret}`", parse_mode="Markdown") + await self._reply(update, context, f"RPC 密钥已重新生成并重启服务 ✅\n新密钥: `{new_secret[:4]}****{new_secret[-4:]}`", parse_mode="Markdown") logger.info(f"/reset_secret 命令执行成功 - {_get_user_info(update)}") except ConfigError as exc: logger.error(f"/reset_secret 命令执行失败: {exc} - {_get_user_info(update)}") @@ -452,7 +452,7 @@ class Aria2BotAPI: await self._reply(update, context, "✅ OneDrive 已认证") return - url, state = await client.get_auth_url() + url, flow = await client.get_auth_url() user_id = update.effective_user.id auth_message = await self._reply( @@ -464,7 +464,7 @@ class Aria2BotAPI: f"[点击认证]({url})", parse_mode="Markdown" ) - self._pending_auth[user_id] = {"state": state, "message": auth_message} + self._pending_auth[user_id] = {"flow": flow, "message": auth_message} async def handle_auth_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """处理用户发送的认证回调 URL""" @@ -482,7 +482,7 @@ class Aria2BotAPI: user_message = update.message # 保存用户消息引用 pending = self._pending_auth[user_id] - flow = pending["state"] + flow = pending["flow"] auth_message = pending.get("message") # 认证指引消息 if await client.authenticate_with_code(text, flow=flow): @@ -583,7 +583,7 @@ class Aria2BotAPI: """后台执行上传任务""" import shutil - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() # 进度回调函数 async def update_progress(progress: UploadProgress): @@ -693,7 +693,7 @@ class Aria2BotAPI: logger.error(f"自动上传失败:发送消息失败 GID={gid}: {e}") return - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() # 进度回调函数 async def update_progress(progress): @@ -1040,10 +1040,10 @@ class Aria2BotAPI: except RpcError: pass - # 如果需要删除文件 + # 如果需要删除文件(使用 asyncio.to_thread 避免阻塞事件循环) file_deleted = False if delete_file == "1" and task: - file_deleted = rpc.delete_files(task) + file_deleted = await asyncio.to_thread(rpc.delete_files, task) msg = f"🗑️ 任务已删除\n🆔 GID: `{gid}`" if delete_file == "1":