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:
@@ -97,7 +97,7 @@ commands:
|
|||||||
permissions:
|
permissions:
|
||||||
- framework.command.test
|
- framework.command.test
|
||||||
source: internal
|
source: internal
|
||||||
last_updated: 11552.003166269
|
last_updated: 11814.024966534
|
||||||
plugin_commands:
|
plugin_commands:
|
||||||
example_plugin:
|
example_plugin:
|
||||||
echo: *id001
|
echo: *id001
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
http_port: 4200
|
http_port: 4200
|
||||||
last_updated: 11552.015952154
|
last_updated: 11814.04652419
|
||||||
plugin_routes:
|
plugin_routes:
|
||||||
example_plugin:
|
example_plugin:
|
||||||
- methods:
|
- methods:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class AuthService:
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.users: Dict[str, User] = {}
|
self.users: Dict[str, User] = {}
|
||||||
self.tokens: Dict[str, Token] = {}
|
self.tokens: Dict[str, Token] = {}
|
||||||
self.token_expiry_hours = 24
|
self.token_expiry_hours = 2 # 生产环境 2 小时过期
|
||||||
self.secret_key = secrets.token_hex(32)
|
self.secret_key = secrets.token_hex(32)
|
||||||
logger.debug("AuthService初始化开始")
|
logger.debug("AuthService初始化开始")
|
||||||
|
|
||||||
|
|||||||
@@ -151,17 +151,24 @@ class InternetService:
|
|||||||
logger.error(f"保存网络配置时出错: {str(e)}")
|
logger.error(f"保存网络配置时出错: {str(e)}")
|
||||||
|
|
||||||
def _setup_security_middleware(self):
|
def _setup_security_middleware(self):
|
||||||
"""注入安全响应头中间件"""
|
"""注入安全响应头 + 错误脱敏中间件"""
|
||||||
|
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def security_headers(request, handler):
|
async def security_headers(request, handler):
|
||||||
|
try:
|
||||||
resp = await handler(request)
|
resp = await handler(request)
|
||||||
|
except web.HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# 生产模式: 脱敏错误,仅返回通用消息,详细信息写日志
|
||||||
|
logger.error(f"未捕获异常 {request.method} {request.path}: {e}", exc_info=True)
|
||||||
|
resp = web.json_response(
|
||||||
|
{"error": "Internal server error"}, status=500
|
||||||
|
)
|
||||||
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
|
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
resp.headers.setdefault("X-Frame-Options", "DENY")
|
resp.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
resp.headers.setdefault("X-XSS-Protection", "1; mode=block")
|
resp.headers.setdefault("X-XSS-Protection", "1; mode=block")
|
||||||
resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
# 生产环境有反向代理 TLS 时可开启:
|
|
||||||
# resp.headers.setdefault("Strict-Transport-Security", "max-age=31536000")
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
self.http_app.middlewares.append(security_headers)
|
self.http_app.middlewares.append(security_headers)
|
||||||
@@ -316,25 +323,28 @@ class InternetService:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
async def _check_plugin_auth(self, plugin_name: str, request) -> Dict[str, Any]:
|
async def _check_plugin_auth(self, plugin_name: str, request) -> Dict[str, Any]:
|
||||||
"""检查插件权限"""
|
"""检查插件路由权限 — 先验证用户身份,再检查插件权限"""
|
||||||
try:
|
try:
|
||||||
# 获取权限服务
|
# 1. 验证用户身份 (panel token)
|
||||||
|
token = request.cookies.get("panel_token")
|
||||||
|
if not token:
|
||||||
|
auth_hdr = request.headers.get("Authorization", "")
|
||||||
|
if auth_hdr.startswith("Bearer "):
|
||||||
|
token = auth_hdr.split(" ", 1)[1]
|
||||||
|
if not token:
|
||||||
|
return {"allowed": False, "reason": "未认证"}
|
||||||
|
|
||||||
|
session_store = request.app.get("panel_session_store", {})
|
||||||
|
if token not in session_store:
|
||||||
|
return {"allowed": False, "reason": "会话无效或已过期"}
|
||||||
|
|
||||||
|
# 2. 检查插件是否有网络访问权限
|
||||||
permission_service = self.service_manager.get_service("permission")
|
permission_service = self.service_manager.get_service("permission")
|
||||||
if not permission_service:
|
if permission_service and not permission_service.has_permission(
|
||||||
return {"allowed": False, "reason": "权限服务不可用"}
|
plugin_name, "plugin.network.access"
|
||||||
|
):
|
||||||
# 检查插件是否有网络访问权限
|
|
||||||
if not permission_service.has_permission(plugin_name, "plugin.network.access"):
|
|
||||||
return {"allowed": False, "reason": "插件没有网络访问权限"}
|
return {"allowed": False, "reason": "插件没有网络访问权限"}
|
||||||
|
|
||||||
# 检查API密钥(如果配置了)
|
|
||||||
api_key = request.headers.get('X-API-Key')
|
|
||||||
if api_key:
|
|
||||||
# 验证API密钥逻辑
|
|
||||||
valid_keys = self.config.get('api_keys', [])
|
|
||||||
if api_key not in valid_keys:
|
|
||||||
return {"allowed": False, "reason": "无效的API密钥"}
|
|
||||||
|
|
||||||
return {"allowed": True, "reason": "权限验证通过"}
|
return {"allowed": True, "reason": "权限验证通过"}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -49,14 +49,57 @@ def _clear_fails(ip: str):
|
|||||||
_LOGIN_FAILS.pop(ip, None)
|
_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=''):
|
def setup_routes(app, prefix=''):
|
||||||
"""注册面板认证路由"""
|
"""注册面板认证路由"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
# 🟢 关键:将 Session Store 挂载到 app,供拦截器读取
|
# 🟢 关键:将 Session Store 挂载到 app,供拦截器读取
|
||||||
app['panel_session_store'] = PANEL_SESSION_STORE
|
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/login', handle_login)
|
||||||
# 退出和状态检查都需要拦截
|
|
||||||
app.router.add_post(f'{prefix}/api/logout', panel_auth(handle_logout))
|
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))
|
app.router.add_get(f'{prefix}/api/auth/status', panel_auth(handle_auth_status))
|
||||||
|
|
||||||
@@ -92,6 +135,7 @@ async def handle_login(req):
|
|||||||
"login_time": __import__('time').time()
|
"login_time": __import__('time').time()
|
||||||
}
|
}
|
||||||
PANEL_SESSION_STORE[token] = user_info
|
PANEL_SESSION_STORE[token] = user_info
|
||||||
|
_save_sessions_to_db(req.app) # 持久化到数据库
|
||||||
|
|
||||||
logger.info(f"✅ 面板登录成功: {username} (Session: {token[:4]}...)")
|
logger.info(f"✅ 面板登录成功: {username} (Session: {token[:4]}...)")
|
||||||
|
|
||||||
@@ -113,6 +157,7 @@ async def handle_logout(req):
|
|||||||
token = req.cookies.get("panel_token")
|
token = req.cookies.get("panel_token")
|
||||||
if token and token in PANEL_SESSION_STORE:
|
if token and token in PANEL_SESSION_STORE:
|
||||||
del PANEL_SESSION_STORE[token]
|
del PANEL_SESSION_STORE[token]
|
||||||
|
_save_sessions_to_db(req.app) # 持久化删除
|
||||||
logger.info(f"👋 用户退出登录")
|
logger.info(f"👋 用户退出登录")
|
||||||
|
|
||||||
resp = web.json_response({"success": True})
|
resp = web.json_response({"success": True})
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from ..utils.auth import panel_auth
|
from ..utils.auth import panel_auth
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Shell 元字符黑名单 — 防止命令注入
|
||||||
|
_SHELL_DANGER = re.compile(r'[;&|`$(){}!#~<>]')
|
||||||
|
|
||||||
|
|
||||||
def setup_routes(app, prefix=''):
|
def setup_routes(app, prefix=''):
|
||||||
app.router.add_post(f'{prefix}/api/command', panel_auth(exec_cmd))
|
app.router.add_post(f'{prefix}/api/command', panel_auth(exec_cmd))
|
||||||
|
|
||||||
|
|
||||||
async def exec_cmd(req):
|
async def exec_cmd(req):
|
||||||
d = await req.json()
|
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")
|
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:
|
try:
|
||||||
res = await cs.execute_command(d.get('command',''))
|
res = await cs.execute_command(raw)
|
||||||
return web.json_response({"success": True, "output": str(res)})
|
return web.json_response({"success": True, "output": str(res)})
|
||||||
except Exception as e:
|
except Exception:
|
||||||
return web.json_response({"success": False, "error": str(e)})
|
return web.json_response({"success": False, "error": "命令执行失败"})
|
||||||
|
|||||||
@@ -44,13 +44,15 @@ if os.name == 'nt':
|
|||||||
if drive.exists():
|
if drive.exists():
|
||||||
_ALLOWED_ROOTS.append(drive)
|
_ALLOWED_ROOTS.append(drive)
|
||||||
else:
|
else:
|
||||||
# Linux / macOS / Android
|
# Linux / macOS / Android — 默认仅限项目目录 + data/,生产安全
|
||||||
_ALLOWED_ROOTS = [
|
_ALLOWED_ROOTS = []
|
||||||
Path("/"),
|
|
||||||
Path("/media/sd"), # Android shared storage
|
|
||||||
Path("/mnt"), # WSL mounts
|
|
||||||
]
|
|
||||||
_ALLOWED_ROOTS.append(_PROJECT_ROOT)
|
_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
|
# Deduplicate and keep only existing
|
||||||
_seen = set()
|
_seen = set()
|
||||||
_filtered = []
|
_filtered = []
|
||||||
|
|||||||
Reference in New Issue
Block a user