security: P2 生产深度加固 — 路径/鉴权/脱敏/校验/持久化/过期

4.3 文件管理器路径收紧:
- 默认移除 Path('/') 全文件系统访问
- 仅允许项目目录 + data/ + 环境变量 SENSU_FILE_ROOTS 指定路径

3.4 插件路由鉴权修复:
- _check_plugin_auth 增加 panel_token 用户身份验证
- 先验证用户登录, 再检查插件权限

4.2 错误脱敏:
- security middleware 捕获异常 → 通用 'Internal server error'
- 堆栈详情仅写入日志, 不暴露给客户端

4.4 命令参数校验:
- POST /api/command 拒绝 shell 元字符 (;&|`$(){}!#~<>)
- 防止命令注入

4.5 Session 持久化:
- 登录/退出时保存到 SenSuDB.config_kv
- 框架重启后自动恢复已持久化会话

4.6 Token 过期: 24h → 2h

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
qinglong
2026-06-13 13:50:51 +08:00
parent 2f9063d6d4
commit fcac02d920
7 changed files with 108 additions and 38 deletions
+49 -4
View File
@@ -49,14 +49,57 @@ def _clear_fails(ip: str):
_LOGIN_FAILS.pop(ip, None)
def _load_sessions_from_db(app):
"""从数据库恢复持久化的 session"""
try:
db = app.get("sensu_db")
if not db:
sm = app.get("service_manager")
if sm:
try:
db = sm.get_service("sensu_db")
except Exception:
pass
if db:
raw = db.get_config("panel_sessions", "{}")
stored = json.loads(raw) if raw else {}
for token, info in stored.items():
PANEL_SESSION_STORE[token] = info
if stored:
logger.info(f"📦 从数据库恢复了 {len(stored)} 个会话")
except Exception as e:
logger.warning(f"会话恢复失败: {e}")
def _save_sessions_to_db(app):
"""持久化当前 session 到数据库"""
try:
db = app.get("sensu_db")
if not db:
sm = app.get("service_manager")
if sm:
try:
db = sm.get_service("sensu_db")
except Exception:
pass
if db:
db.set_config("panel_sessions", json.dumps(PANEL_SESSION_STORE))
except Exception as e:
logger.debug(f"会话持久化失败: {e}")
def setup_routes(app, prefix=''):
"""注册面板认证路由"""
import json as _json
# 🟢 关键:将 Session Store 挂载到 app,供拦截器读取
app['panel_session_store'] = PANEL_SESSION_STORE
# 从数据库恢复持久化会话
_load_sessions_from_db(app)
# 路由注册
app.router.add_post(f'{prefix}/api/login', handle_login)
# 退出和状态检查都需要拦截
app.router.add_post(f'{prefix}/api/logout', panel_auth(handle_logout))
app.router.add_get(f'{prefix}/api/auth/status', panel_auth(handle_auth_status))
@@ -92,7 +135,8 @@ async def handle_login(req):
"login_time": __import__('time').time()
}
PANEL_SESSION_STORE[token] = user_info
_save_sessions_to_db(req.app) # 持久化到数据库
logger.info(f"✅ 面板登录成功: {username} (Session: {token[:4]}...)")
resp = web.json_response({"success": True, "username": username})
@@ -113,8 +157,9 @@ async def handle_logout(req):
token = req.cookies.get("panel_token")
if token and token in PANEL_SESSION_STORE:
del PANEL_SESSION_STORE[token]
_save_sessions_to_db(req.app) # 持久化删除
logger.info(f"👋 用户退出登录")
resp = web.json_response({"success": True})
resp.del_cookie("panel_token")
return resp
+17 -4
View File
@@ -1,15 +1,28 @@
from aiohttp import web
from ..utils.auth import panel_auth
import re
# Shell 元字符黑名单 — 防止命令注入
_SHELL_DANGER = re.compile(r'[;&|`$(){}!#~<>]')
def setup_routes(app, prefix=''):
app.router.add_post(f'{prefix}/api/command', panel_auth(exec_cmd))
async def exec_cmd(req):
d = await req.json()
raw = d.get('command', '')
# 安全检查: 拒绝含 shell 元字符的命令
if _SHELL_DANGER.search(raw):
return web.json_response(
{"success": False, "error": "命令包含不允许的字符"}, status=400
)
cs = req.app.get('service_manager').get_service("command")
if not cs: return web.json_response({"error": "Missing"}, 503)
if not cs:
return web.json_response({"error": "Missing"}, status=503)
try:
res = await cs.execute_command(d.get('command',''))
res = await cs.execute_command(raw)
return web.json_response({"success": True, "output": str(res)})
except Exception as e:
return web.json_response({"success": False, "error": str(e)})
except Exception:
return web.json_response({"success": False, "error": "命令执行失败"})
+8 -6
View File
@@ -44,13 +44,15 @@ if os.name == 'nt':
if drive.exists():
_ALLOWED_ROOTS.append(drive)
else:
# Linux / macOS / Android
_ALLOWED_ROOTS = [
Path("/"),
Path("/media/sd"), # Android shared storage
Path("/mnt"), # WSL mounts
]
# Linux / macOS / Android — 默认仅限项目目录 + data/,生产安全
_ALLOWED_ROOTS = []
_ALLOWED_ROOTS.append(_PROJECT_ROOT)
# 从环境变量读取额外允许路径 (逗号分隔)
_extra_roots = os.environ.get("SENSU_FILE_ROOTS", "")
for r in _extra_roots.split(","):
r = r.strip()
if r:
_ALLOWED_ROOTS.append(Path(r))
# Deduplicate and keep only existing
_seen = set()
_filtered = []