mirror of
https://github.com/dnslin/aria2bot.git
synced 2026-01-11 04:02:20 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 完成认证"""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user