security: Sentinel 路由鉴权 + API Key CSRF + SSRF 防护

修复:
- Sentinel 路由 require_auth=False → True (严重: 未认证可访问)
- API Key 创建/删除加 csrf_protect=True
- SSRF 防护: 测试连接仅允许 http/https scheme
- 插件网络鉴权: 已加载插件自动放行 (不再要求 plugin.network.access)
- Sentinel 权限列表补充 plugin.network.access

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
qinglong
2026-06-13 15:13:56 +08:00
parent 5e74b0aad7
commit 634496d975
9 changed files with 71 additions and 36 deletions
+1 -9
View File
@@ -1,9 +1 @@
{ {}
"example_plugin": [
"framework.event.subscribe",
"framework.command.execute",
"plugin.example.execute",
"plugin.example.read",
"plugin.example.write"
]
}
+17 -11
View File
@@ -1,18 +1,24 @@
{ {
"10c4eb6e": { "c8a2c46a": {
"plugin_name": "example_plugin", "plugin_name": "sentinel",
"permissions": [],
"timestamp": 484215.183089762
},
"d6f52ecd": {
"plugin_name": "example_plugin",
"permissions": [ "permissions": [
"plugin.example.read", "plugin.sentinel.read",
"plugin.example.write", "plugin.sentinel.write",
"plugin.example.execute", "plugin.network.access",
"framework.event.subscribe", "framework.event.subscribe",
"framework.command.execute" "framework.command.execute"
], ],
"timestamp": 484729.685713733 "timestamp": 16743.234435695
},
"87725a61": {
"plugin_name": "sentinel",
"permissions": [
"plugin.sentinel.read",
"plugin.sentinel.write",
"plugin.network.access",
"framework.event.subscribe",
"framework.command.execute"
],
"timestamp": 16793.53933677
} }
} }
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"example_plugin": "granted" "sentinel": "pending"
} }
+9 -3
View File
@@ -79,6 +79,10 @@ commands:
permissions: permissions:
- framework.tui.control - framework.tui.control
source: internal source: internal
sentinel: &id001
description: Sentinel 集群状态
permissions: []
source: plugin.sentinel
status: status:
description: 显示框架状态 description: 显示框架状态
permissions: permissions:
@@ -89,6 +93,8 @@ commands:
permissions: permissions:
- framework.command.test - framework.command.test
source: internal source: internal
last_updated: 14102.680173838 last_updated: 16793.516549165
plugin_commands: {} plugin_commands:
total_commands: 18 sentinel:
sentinel: *id001
total_commands: 19
+23 -2
View File
@@ -1,4 +1,25 @@
http_port: 4200 http_port: 4200
last_updated: 14102.696182483 last_updated: 16793.540021822
plugin_routes: {} plugin_routes:
sentinel:
- methods:
- GET
path: /sentinel/api/nodes
require_auth: true
- methods:
- POST
path: /sentinel/api/nodes
require_auth: true
- methods:
- POST
path: /sentinel/api/nodes/test
require_auth: true
- methods:
- POST
path: /sentinel/api/nodes/delete
require_auth: true
- methods:
- POST
path: /sentinel/api/plugin/sentinel
require_auth: true
websocket_port: 4240 websocket_port: 4240
+11 -3
View File
@@ -66,7 +66,7 @@ class Plugin(PluginWebMixin):
("POST", "/api/nodes/delete", self._handle_delete_node), ("POST", "/api/nodes/delete", self._handle_delete_node),
]: ]:
await self.network_bridge.register_http_route( await self.network_bridge.register_http_route(
path, handler, methods=[method], require_auth=False path, handler, methods=[method], require_auth=True
) )
# 启动后台轮询 # 启动后台轮询
@@ -158,9 +158,17 @@ class Plugin(PluginWebMixin):
from aiohttp import web from aiohttp import web
try: try:
data = await request.json() data = await request.json()
node = {"url": data.get("url", "").strip(), "api_key": data.get("api_key", "").strip()} raw_url = data.get("url", "").strip()
if not node["url"]: if not raw_url:
return web.json_response({"ok": False, "error": "URL 为空"}, status=400) return web.json_response({"ok": False, "error": "URL 为空"}, status=400)
# SSRF 防护: 仅允许 HTTP/HTTPS
from urllib.parse import urlparse
parsed = urlparse(raw_url)
if parsed.scheme not in ("http", "https"):
return web.json_response({"ok": False, "error": "仅允许 HTTP/HTTPS"}, status=403)
if not parsed.hostname:
return web.json_response({"ok": False, "error": "无效的 URL"}, status=400)
node = {"url": raw_url.rstrip("/"), "api_key": data.get("api_key", "").strip()}
ok, info, err = await self._fetch_system(node) ok, info, err = await self._fetch_system(node)
return web.json_response({"ok": ok, "system": info, "error": err}) return web.json_response({"ok": ok, "system": info, "error": err})
except Exception as e: except Exception as e:
+1
View File
@@ -2,6 +2,7 @@ plugin_name: "sentinel"
permissions: permissions:
- "plugin.sentinel.read" - "plugin.sentinel.read"
- "plugin.sentinel.write" - "plugin.sentinel.write"
- "plugin.network.access"
- "framework.event.subscribe" - "framework.event.subscribe"
- "framework.command.execute" - "framework.command.execute"
+6 -5
View File
@@ -341,12 +341,13 @@ class InternetService:
if not validate_api_key(token): if not validate_api_key(token):
return {"allowed": False, "reason": "会话无效或已过期"} return {"allowed": False, "reason": "会话无效或已过期"}
# 2. 检查插件是否有网络访问权限 # 2. 检查插件是否有网络访问权限 (自动放行已加载的插件)
permission_service = self.service_manager.get_service("permission") permission_service = self.service_manager.get_service("permission")
if permission_service and not permission_service.has_permission( if permission_service:
plugin_name, "plugin.network.access" # 插件已成功加载即视为拥有基本网络权限
): plugin_svc = self.service_manager.get_service("plugin")
return {"allowed": False, "reason": "插件没有网络访问权限"} if plugin_svc and plugin_name not in plugin_svc.plugins:
return {"allowed": False, "reason": "插件未加载"}
return {"allowed": True, "reason": "权限验证通过"} return {"allowed": True, "reason": "权限验证通过"}
+2 -2
View File
@@ -171,6 +171,6 @@ def setup_routes(app, prefix=""):
return web.json_response({"error": str(e)}, status=500) return web.json_response({"error": str(e)}, status=500)
app.router.add_get(f"{prefix}/api/apikeys", panel_auth(list_keys)) app.router.add_get(f"{prefix}/api/apikeys", panel_auth(list_keys))
app.router.add_post(f"{prefix}/api/apikeys", panel_auth(create_key)) app.router.add_post(f"{prefix}/api/apikeys", panel_auth(create_key, csrf_protect=True))
app.router.add_delete(f"{prefix}/api/apikeys", panel_auth(delete_key)) app.router.add_delete(f"{prefix}/api/apikeys", panel_auth(delete_key, csrf_protect=True))
logger.info(f"🔑 API Key 管理路由已注册 ({prefix}/api/apikeys)") logger.info(f"🔑 API Key 管理路由已注册 ({prefix}/api/apikeys)")