fix: 修复安全漏洞和代码质量问题

安全修复:
- 修复路径遍历检查,使用 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 <noreply@anthropic.com>
This commit is contained in:
dnslin
2025-12-12 16:42:48 +08:00
parent 85f4c8a131
commit bf4fdf1377
8 changed files with 67 additions and 30 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 完成认证"""

View File

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

View File

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

View File

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