From 91db9e0422c0e2f48d9eb9c6a3263750c0b55b63 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com>
Date: Thu, 12 Mar 2026 18:50:27 +0900
Subject: [PATCH 1/9] fix: harden runtime and supply chain security
---
.github/workflows/build-docs.yml | 4 +-
.github/workflows/dashboard_ci.yml | 2 +-
.github/workflows/docker-image.yml | 20 +-
.github/workflows/release.yml | 2 +-
Dockerfile | 7 +
astrbot/core/computer/booters/local.py | 3 +-
astrbot/core/db/migration/sqlite_v3.py | 72 +-
.../platform/sources/misskey/misskey_api.py | 9 +-
.../platform/sources/satori/satori_adapter.py | 10 +-
.../platform/sources/websocket_security.py | 56 ++
astrbot/core/utils/t2i/network_strategy.py | 1 -
.../t2i/template/astrbot_powershell.html | 4 +-
astrbot/core/utils/t2i/template/base.html | 4 +-
dashboard/package.json | 20 +-
dashboard/pnpm-lock.yaml | 864 ++++++++++++------
.../message_list_comps/IPythonToolBlock.vue | 14 +-
.../src/components/shared/AstrBotConfigV4.vue | 3 +-
dashboard/src/components/shared/Logo.vue | 27 +-
.../theme/components/ArticleShare.vue | 32 +-
docs/en/platform/aiocqhttp/napcat.md | 4 +-
docs/scripts/upload_doc_images_to_r2.py | 2 +-
docs/tests/test_upload_doc_images_to_r2.py | 59 ++
docs/zh/platform/aiocqhttp/napcat.md | 4 +-
k8s/astrbot/02-deployment.yaml | 14 +-
k8s/astrbot_with_napcat/02-deployment.yaml | 26 +-
tests/unit/test_computer.py | 22 +
tests/unit/test_sqlite_v3.py | 75 ++
tests/unit/test_t2i_security.py | 32 +
tests/unit/test_websocket_security.py | 52 ++
29 files changed, 1072 insertions(+), 372 deletions(-)
create mode 100644 astrbot/core/platform/sources/websocket_security.py
create mode 100644 docs/tests/test_upload_doc_images_to_r2.py
create mode 100644 tests/unit/test_sqlite_v3.py
create mode 100644 tests/unit/test_t2i_security.py
create mode 100644 tests/unit/test_websocket_security.py
diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml
index 2464696803..3d28cef4db 100644
--- a/.github/workflows/build-docs.yml
+++ b/.github/workflows/build-docs.yml
@@ -23,7 +23,7 @@ jobs:
run: npm run docs:build
working-directory: './docs'
- name: scp
- uses: appleboy/scp-action@master
+ uses: appleboy/scp-action@7179e72a3fa4d4c33870a471708fda724fae7596
with:
host: ${{ secrets.HOST_NEKO }}
username: ${{ secrets.USERNAME }}
@@ -31,7 +31,7 @@ jobs:
source: 'docs/.vitepress/dist/*'
target: '/tmp/'
- name: script
- uses: appleboy/ssh-action@master
+ uses: appleboy/ssh-action@8743aa11bfbda97acb45c151ae7a2e0b203f1914
with:
host: ${{ secrets.HOST_NEKO }}
username: ${{ secrets.USERNAME }}
diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml
index 46d2fea735..11dadc9425 100644
--- a/.github/workflows/dashboard_ci.yml
+++ b/.github/workflows/dashboard_ci.yml
@@ -45,7 +45,7 @@ jobs:
- name: Create GitHub Release
if: github.event_name == 'push'
- uses: ncipollo/release-action@v1
+ uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b
with:
tag: release-${{ github.sha }}
owner: AstrBotDevs
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
index d79d628c3f..8a8ae70cdf 100644
--- a/.github/workflows/docker-image.yml
+++ b/.github/workflows/docker-image.yml
@@ -64,20 +64,20 @@ jobs:
echo "build_date=$build_date" >> $GITHUB_OUTPUT
- name: Set QEMU
- uses: docker/setup-qemu-action@v4
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a
- name: Set Docker Buildx
- uses: docker/setup-buildx-action@v4
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd
- name: Log in to DockerHub
- uses: docker/login-action@v4
+ uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
- uses: docker/login-action@v4
+ uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
@@ -98,7 +98,7 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT
- name: Build and Push Nightly Image
- uses: docker/build-push-action@v7
+ uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -163,27 +163,27 @@ jobs:
cp -r dashboard/dist data/
- name: Set QEMU
- uses: docker/setup-qemu-action@v4
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a
- name: Set Docker Buildx
- uses: docker/setup-buildx-action@v4
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd
- name: Log in to DockerHub
- uses: docker/login-action@v4
+ uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
- uses: docker/login-action@v4
+ uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
- name: Build and Push Release Image
- uses: docker/build-push-action@v7
+ uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294
with:
context: .
platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 41f59f0a61..61ec840345 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -50,7 +50,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
- uses: pnpm/action-setup@v4
+ uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
with:
version: 10.28.2
diff --git a/Dockerfile b/Dockerfile
index 992060d6ea..42e99e1eb7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -27,6 +27,13 @@ RUN python -m pip install uv \
&& uv pip install -r requirements.txt --no-cache-dir --system \
&& uv pip install socksio uv pilk --no-cache-dir --system
+RUN groupadd --system --gid 1000 astrbot \
+ && useradd --system --uid 1000 --gid astrbot --home-dir /AstrBot --shell /usr/sbin/nologin astrbot \
+ && mkdir -p /AstrBot/data \
+ && chown -R astrbot:astrbot /AstrBot
+
EXPOSE 6185
+USER astrbot
+
CMD ["python", "main.py"]
diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py
index a80ef0da28..5dfb52c411 100644
--- a/astrbot/core/computer/booters/local.py
+++ b/astrbot/core/computer/booters/local.py
@@ -112,10 +112,11 @@ async def exec(
def _run() -> dict[str, Any]:
try:
result = subprocess.run(
- [os.environ.get("PYTHON", sys.executable), "-c", code],
+ [sys.executable, "-c", code],
timeout=timeout,
capture_output=True,
text=True,
+ shell=False,
)
stdout = "" if silent else result.stdout
stderr = result.stderr if result.returncode != 0 else ""
diff --git a/astrbot/core/db/migration/sqlite_v3.py b/astrbot/core/db/migration/sqlite_v3.py
index b326ebb449..bbf65da7fa 100644
--- a/astrbot/core/db/migration/sqlite_v3.py
+++ b/astrbot/core/db/migration/sqlite_v3.py
@@ -164,7 +164,7 @@ def insert_llm_metrics(self, metrics: dict) -> None:
def get_base_stats(self, offset_sec: int = 86400) -> Stats:
"""获取 offset_sec 秒前到现在的基础统计数据"""
- where_clause = f" WHERE timestamp >= {int(time.time()) - offset_sec}"
+ min_timestamp = int(time.time()) - offset_sec
try:
c = self.conn.cursor()
@@ -174,8 +174,9 @@ def get_base_stats(self, offset_sec: int = 86400) -> Stats:
c.execute(
"""
SELECT * FROM platform
- """
- + where_clause,
+ WHERE timestamp >= :min_timestamp
+ """,
+ {"min_timestamp": min_timestamp},
)
platform = []
@@ -203,7 +204,7 @@ def get_total_message_count(self) -> int:
def get_grouped_base_stats(self, offset_sec: int = 86400) -> Stats:
"""获取 offset_sec 秒前到现在的基础统计数据(合并)"""
- where_clause = f" WHERE timestamp >= {int(time.time()) - offset_sec}"
+ min_timestamp = int(time.time()) - offset_sec
try:
c = self.conn.cursor()
@@ -213,9 +214,10 @@ def get_grouped_base_stats(self, offset_sec: int = 86400) -> Stats:
c.execute(
"""
SELECT name, SUM(count), timestamp FROM platform
- """
- + where_clause
- + " GROUP BY name",
+ WHERE timestamp >= :min_timestamp
+ GROUP BY name
+ """,
+ {"min_timestamp": min_timestamp},
)
platform = []
@@ -403,14 +405,15 @@ def get_filtered_conversations(
try:
# 构建查询条件
where_clauses = []
- params = []
+ params: dict[str, Any] = {}
# 平台筛选
if platforms and len(platforms) > 0:
platform_conditions = []
- for platform in platforms:
- platform_conditions.append("user_id LIKE ?")
- params.append(f"{platform}:%")
+ for index, platform in enumerate(platforms):
+ param_name = f"platform_{index}"
+ platform_conditions.append(f"user_id LIKE :{param_name}")
+ params[param_name] = f"{platform}:%"
if platform_conditions:
where_clauses.append(f"({' OR '.join(platform_conditions)})")
@@ -418,9 +421,10 @@ def get_filtered_conversations(
# 消息类型筛选
if message_types and len(message_types) > 0:
message_type_conditions = []
- for msg_type in message_types:
- message_type_conditions.append("user_id LIKE ?")
- params.append(f"%:{msg_type}:%")
+ for index, msg_type in enumerate(message_types):
+ param_name = f"message_type_{index}"
+ message_type_conditions.append(f"user_id LIKE :{param_name}")
+ params[param_name] = f"%:{msg_type}:%"
if message_type_conditions:
where_clauses.append(f"({' OR '.join(message_type_conditions)})")
@@ -429,28 +433,32 @@ def get_filtered_conversations(
if search_query:
search_query = search_query.encode("unicode_escape").decode("utf-8")
where_clauses.append(
- "(title LIKE ? OR user_id LIKE ? OR cid LIKE ? OR history LIKE ?)",
+ "("
+ "title LIKE :search_query OR user_id LIKE :search_query OR "
+ "cid LIKE :search_query OR history LIKE :search_query"
+ ")",
)
- search_param = f"%{search_query}%"
- params.extend([search_param, search_param, search_param, search_param])
+ params["search_query"] = f"%{search_query}%"
# 排除特定用户ID
if exclude_ids and len(exclude_ids) > 0:
- for exclude_id in exclude_ids:
- where_clauses.append("user_id NOT LIKE ?")
- params.append(f"{exclude_id}%")
+ for index, exclude_id in enumerate(exclude_ids):
+ param_name = f"exclude_id_{index}"
+ where_clauses.append(f"user_id NOT LIKE :{param_name}")
+ params[param_name] = f"{exclude_id}%"
# 排除特定平台
if exclude_platforms and len(exclude_platforms) > 0:
- for exclude_platform in exclude_platforms:
- where_clauses.append("user_id NOT LIKE ?")
- params.append(f"{exclude_platform}:%")
+ for index, exclude_platform in enumerate(exclude_platforms):
+ param_name = f"exclude_platform_{index}"
+ where_clauses.append(f"user_id NOT LIKE :{param_name}")
+ params[param_name] = f"{exclude_platform}:%"
# 构建完整的 WHERE 子句
where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# 构建计数查询
- count_sql = f"SELECT COUNT(*) FROM webchat_conversation{where_sql}"
+ count_sql = "SELECT COUNT(*) FROM webchat_conversation" + where_sql
# 获取总记录数
c.execute(count_sql, params)
@@ -460,14 +468,14 @@ def get_filtered_conversations(
offset = (page - 1) * page_size
# 构建分页数据查询
- data_sql = f"""
- SELECT user_id, cid, created_at, updated_at, title, persona_id
- FROM webchat_conversation
- {where_sql}
- ORDER BY updated_at DESC
- LIMIT ? OFFSET ?
- """
- query_params = params + [page_size, offset]
+ data_sql = (
+ "SELECT user_id, cid, created_at, updated_at, title, persona_id\n"
+ "FROM webchat_conversation"
+ f"{where_sql}\n"
+ "ORDER BY updated_at DESC\n"
+ "LIMIT :page_size OFFSET :offset"
+ )
+ query_params = {**params, "page_size": page_size, "offset": offset}
# 获取分页数据
c.execute(data_sql, query_params)
diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py
index 3e5eb9a90e..6757377ea9 100644
--- a/astrbot/core/platform/sources/misskey/misskey_api.py
+++ b/astrbot/core/platform/sources/misskey/misskey_api.py
@@ -15,6 +15,7 @@
from astrbot.api import logger
+from ..websocket_security import require_secure_transport_url, to_websocket_url
from .misskey_utils import FileIDExtractor
# Constants
@@ -56,10 +57,12 @@ def __init__(self, instance_url: str, access_token: str) -> None:
async def connect(self) -> bool:
try:
- ws_url = self.instance_url.replace("https://", "wss://").replace(
- "http://",
- "ws://",
+ require_secure_transport_url(
+ self.instance_url,
+ label="Misskey instance URL",
+ allowed_schemes={"http", "https", "ws", "wss"},
)
+ ws_url = to_websocket_url(self.instance_url)
ws_url += f"/streaming?i={self.access_token}"
self.websocket = await websockets.connect(
diff --git a/astrbot/core/platform/sources/satori/satori_adapter.py b/astrbot/core/platform/sources/satori/satori_adapter.py
index 5c2f7a37f3..6458a53658 100644
--- a/astrbot/core/platform/sources/satori/satori_adapter.py
+++ b/astrbot/core/platform/sources/satori/satori_adapter.py
@@ -27,6 +27,8 @@
)
from astrbot.core.platform.astr_message_event import MessageSession
+from ..websocket_security import require_secure_transport_url
+
@register_platform_adapter(
"satori", "Satori 协议适配器", support_streaming_message=False
@@ -137,9 +139,11 @@ async def connect_websocket(self) -> None:
logger.info(f"Satori 适配器正在连接到 WebSocket: {self.endpoint}")
logger.info(f"Satori 适配器 HTTP API 地址: {self.api_base_url}")
- if not self.endpoint.startswith(("ws://", "wss://")):
- logger.error(f"无效的WebSocket URL: {self.endpoint}")
- raise ValueError(f"WebSocket URL必须以ws://或wss://开头: {self.endpoint}")
+ require_secure_transport_url(
+ self.endpoint,
+ label="Satori WebSocket URL",
+ allowed_schemes={"ws", "wss"},
+ )
try:
websocket = await connect(
diff --git a/astrbot/core/platform/sources/websocket_security.py b/astrbot/core/platform/sources/websocket_security.py
new file mode 100644
index 0000000000..df66389d24
--- /dev/null
+++ b/astrbot/core/platform/sources/websocket_security.py
@@ -0,0 +1,56 @@
+import ipaddress
+from urllib.parse import SplitResult, urlsplit, urlunsplit
+
+_ALLOWED_INSECURE_SUFFIXES = (".local", ".internal")
+
+
+def _is_local_or_private_host(hostname: str | None) -> bool:
+ if not hostname:
+ return False
+
+ normalized = hostname.strip("[]").lower()
+ if normalized == "localhost" or "." not in normalized:
+ return True
+ if normalized.endswith(_ALLOWED_INSECURE_SUFFIXES):
+ return True
+
+ try:
+ address = ipaddress.ip_address(normalized)
+ except ValueError:
+ return False
+
+ return address.is_loopback or address.is_private or address.is_link_local
+
+
+def require_secure_transport_url(
+ url: str,
+ *,
+ label: str,
+ allowed_schemes: set[str],
+) -> SplitResult:
+ parsed = urlsplit(url)
+ if parsed.scheme not in allowed_schemes:
+ allowed = ", ".join(sorted(allowed_schemes))
+ raise ValueError(f"{label} must use one of: {allowed}")
+
+ if parsed.scheme in {"http", "ws"} and not _is_local_or_private_host(
+ parsed.hostname
+ ):
+ raise ValueError(
+ f"{label} must use wss:// or https:// for non-local endpoints: {url}",
+ )
+
+ return parsed
+
+
+def to_websocket_url(url: str) -> str:
+ parsed = urlsplit(url.rstrip("/"))
+ scheme_map = {
+ "http": "ws",
+ "https": "wss",
+ "ws": "ws",
+ "wss": "wss",
+ }
+ return urlunsplit(
+ parsed._replace(scheme=scheme_map[parsed.scheme]),
+ )
diff --git a/astrbot/core/utils/t2i/network_strategy.py b/astrbot/core/utils/t2i/network_strategy.py
index 53d9441fab..c6c54db17c 100644
--- a/astrbot/core/utils/t2i/network_strategy.py
+++ b/astrbot/core/utils/t2i/network_strategy.py
@@ -129,7 +129,6 @@ async def render(
if not template_name:
template_name = "base"
tmpl_str = await self.get_template(name=template_name)
- text = text.replace("`", "\\`")
return await self.render_custom_template(
tmpl_str,
{"text": text, "version": f"v{VERSION}"},
diff --git a/astrbot/core/utils/t2i/template/astrbot_powershell.html b/astrbot/core/utils/t2i/template/astrbot_powershell.html
index 9ed3e77a55..93237023b6 100644
--- a/astrbot/core/utils/t2i/template/astrbot_powershell.html
+++ b/astrbot/core/utils/t2i/template/astrbot_powershell.html
@@ -177,8 +177,8 @@