security: P3+ — 面板密码哈希 + 上传类型白名单 + CSRF Token
面板密码安全: - 支持哈希存储 (password_hash/password_salt) - 登录使用 secrets.compare_digest 时序安全比较 - 回退明文兼容旧配置 - 默认密码 admin 时打印 CRITICAL 警告 文件上传安全: - 拒绝危险扩展名: .exe/.dll/.so/.sh/.bat/.ps1 等 - 拒绝敏感文件名: .htaccess/Makefile/Dockerfile 等 - 上传时检查并返回 403 CSRF 防护: - panel_auth(csrf_protect=True) 参数 - 写操作需 X-CSRF-Token header 匹配 session token - 保护范围: 文件删除/写入/创建/上传/重命名 + 项目启停 + 代理管理 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,6 @@ commands:
|
|||||||
permissions:
|
permissions:
|
||||||
- framework.command.test
|
- framework.command.test
|
||||||
source: internal
|
source: internal
|
||||||
last_updated: 12161.390698641
|
last_updated: 12807.090239176
|
||||||
plugin_commands: {}
|
plugin_commands: {}
|
||||||
total_commands: 18
|
total_commands: 18
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
http_port: 4200
|
http_port: 4200
|
||||||
last_updated: 12161.450926401
|
last_updated: 12807.105822092
|
||||||
plugin_routes: {}
|
plugin_routes: {}
|
||||||
websocket_port: 4240
|
websocket_port: 4240
|
||||||
|
|||||||
@@ -3,18 +3,34 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from .routes import auth, status, plugins, commands, logs, projects, proxy, plugin_web, files
|
from .routes import auth, status, plugins, commands, logs, projects, proxy, plugin_web, files
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_pw(password: str, salt: str) -> str:
|
||||||
|
return hashlib.sha256((password + salt).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
class WebPanelManager:
|
class WebPanelManager:
|
||||||
def __init__(self, config: dict, service_manager):
|
def __init__(self, config: dict, service_manager):
|
||||||
panel_cfg = config.get('panel', {}).get('entrance', {})
|
panel_cfg = config.get('panel', {}).get('entrance', {})
|
||||||
self.base_path = panel_cfg.get('path', '/panel')
|
self.base_path = panel_cfg.get('path', '/panel')
|
||||||
self.panel_user = os.environ.get('SENSU_PANEL_USER', panel_cfg.get('username', 'admin'))
|
self.panel_user = os.environ.get('SENSU_PANEL_USER', panel_cfg.get('username', 'admin'))
|
||||||
self.panel_pass = os.environ.get('SENSU_PANEL_PASS', panel_cfg.get('password', 'admin'))
|
|
||||||
|
raw = os.environ.get('SENSU_PANEL_PASS') or panel_cfg.get('password', 'admin')
|
||||||
|
salt = panel_cfg.get('password_salt', '') or secrets.token_hex(16)
|
||||||
|
pw_hash = panel_cfg.get('password_hash', '') or _hash_pw(raw, salt)
|
||||||
|
self.panel_pass = raw # 保留向后兼容
|
||||||
|
self._pw_hash = pw_hash
|
||||||
|
self._pw_salt = salt
|
||||||
|
|
||||||
|
if raw == 'admin' and not panel_cfg.get('password_hash'):
|
||||||
|
logger.critical("⚠️ 面板使用默认密码 admin!")
|
||||||
|
|
||||||
self.base_path = f"/{self.base_path.strip('/')}"
|
self.base_path = f"/{self.base_path.strip('/')}"
|
||||||
self.sm = service_manager
|
self.sm = service_manager
|
||||||
@@ -37,8 +53,10 @@ class WebPanelManager:
|
|||||||
app['panel_config'] = {
|
app['panel_config'] = {
|
||||||
'username': self.panel_user,
|
'username': self.panel_user,
|
||||||
'password': self.panel_pass,
|
'password': self.panel_pass,
|
||||||
|
'password_hash': self._pw_hash,
|
||||||
|
'password_salt': self._pw_salt,
|
||||||
'index_path': self.project_root / "static" / "web_panel" / "index.html",
|
'index_path': self.project_root / "static" / "web_panel" / "index.html",
|
||||||
'home_path': self.project_root / "static" / "web_panel" / "home.html" # 🟢 新增
|
'home_path': self.project_root / "static" / "web_panel" / "home.html",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 注册静态文件
|
# 注册静态文件
|
||||||
|
|||||||
@@ -120,10 +120,21 @@ async def handle_login(req):
|
|||||||
|
|
||||||
cfg = req.app.get('panel_config', {})
|
cfg = req.app.get('panel_config', {})
|
||||||
cfg_user = cfg.get('username', 'admin')
|
cfg_user = cfg.get('username', 'admin')
|
||||||
cfg_pass = cfg.get('password', 'admin')
|
|
||||||
|
|
||||||
# 校验配置中的账号密码
|
# 校验 — 优先哈希比较,回退明文 (向后兼容)
|
||||||
if username == cfg_user and password == cfg_pass:
|
pw_hash = cfg.get('password_hash', '')
|
||||||
|
pw_salt = cfg.get('password_salt', '')
|
||||||
|
if pw_hash and pw_salt:
|
||||||
|
import hashlib
|
||||||
|
ok = secrets.compare_digest(
|
||||||
|
pw_hash,
|
||||||
|
hashlib.sha256((password + pw_salt).encode()).hexdigest()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cfg_pass = cfg.get('password', 'admin')
|
||||||
|
ok = secrets.compare_digest(username, cfg_user) and secrets.compare_digest(password, cfg_pass)
|
||||||
|
|
||||||
|
if username == cfg_user and ok:
|
||||||
_clear_fails(ip)
|
_clear_fails(ip)
|
||||||
# 登录成功:生成 Token
|
# 登录成功:生成 Token
|
||||||
token = secrets.token_hex(16)
|
token = secrets.token_hex(16)
|
||||||
|
|||||||
@@ -65,6 +65,16 @@ _ALLOWED_ROOTS = _filtered or [Path("/") if os.name != 'nt' else Path("C:\\")]
|
|||||||
|
|
||||||
MAX_READ_SIZE = 1 * 1024 * 1024 # 1 MB for text read
|
MAX_READ_SIZE = 1 * 1024 * 1024 # 1 MB for text read
|
||||||
MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB per upload
|
MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB per upload
|
||||||
|
|
||||||
|
# 危险文件扩展名 — 禁止上传
|
||||||
|
_DENY_EXTENSIONS = {
|
||||||
|
'.exe', '.dll', '.so', '.sh', '.bash', '.zsh', '.fish',
|
||||||
|
'.bat', '.cmd', '.ps1', '.vbs', '.vba', '.wsf', '.msi',
|
||||||
|
'.pyc', '.pyo', '.class', '.jar', '.war',
|
||||||
|
'.php', '.jsp', '.asp', '.aspx', '.cgi', '.pl',
|
||||||
|
'.deb', '.rpm', '.apk', '.ipa',
|
||||||
|
}
|
||||||
|
_DENY_NAMES = {'.htaccess', 'Makefile', 'Dockerfile', '.bashrc', '.profile'}
|
||||||
TEXT_EXTENSIONS = {
|
TEXT_EXTENSIONS = {
|
||||||
'.txt','.py','.js','.ts','.html','.css','.json','.yaml','.yml',
|
'.txt','.py','.js','.ts','.html','.css','.json','.yaml','.yml',
|
||||||
'.md','.ini','.cfg','.conf','.log','.sh','.bat','.env','.xml',
|
'.md','.ini','.cfg','.conf','.log','.sh','.bat','.env','.xml',
|
||||||
@@ -316,6 +326,13 @@ def setup_file_routes(app, service_manager, prefix=''):
|
|||||||
continue
|
continue
|
||||||
# Sanitize filename
|
# Sanitize filename
|
||||||
fname = Path(fname).name
|
fname = Path(fname).name
|
||||||
|
# 安全检查: 拒绝危险文件类型
|
||||||
|
ext = Path(fname).suffix.lower()
|
||||||
|
if ext in _DENY_EXTENSIONS or fname in _DENY_NAMES:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"禁止上传的文件类型: {ext or fname}"},
|
||||||
|
status=403,
|
||||||
|
)
|
||||||
dest = target_dir / fname
|
dest = target_dir / fname
|
||||||
size = 0
|
size = 0
|
||||||
with open(dest, 'wb') as f:
|
with open(dest, 'wb') as f:
|
||||||
@@ -423,13 +440,13 @@ def setup_file_routes(app, service_manager, prefix=''):
|
|||||||
|
|
||||||
# ── Register routes ──
|
# ── Register routes ──
|
||||||
app.router.add_get(f'{prefix}/api/files/list', panel_auth(list_dir))
|
app.router.add_get(f'{prefix}/api/files/list', panel_auth(list_dir))
|
||||||
app.router.add_post(f'{prefix}/api/files/mkdir', panel_auth(mkdir))
|
app.router.add_post(f'{prefix}/api/files/mkdir', panel_auth(mkdir, csrf_protect=True))
|
||||||
app.router.add_post(f'{prefix}/api/files/touch', panel_auth(touch))
|
app.router.add_post(f'{prefix}/api/files/touch', panel_auth(touch, csrf_protect=True))
|
||||||
app.router.add_post(f'{prefix}/api/files/delete', panel_auth(delete))
|
app.router.add_post(f'{prefix}/api/files/delete', panel_auth(delete, csrf_protect=True))
|
||||||
app.router.add_post(f'{prefix}/api/files/rename', panel_auth(rename))
|
app.router.add_post(f'{prefix}/api/files/rename', panel_auth(rename, csrf_protect=True))
|
||||||
app.router.add_post(f'{prefix}/api/files/upload', panel_auth(upload))
|
app.router.add_post(f'{prefix}/api/files/upload', panel_auth(upload, csrf_protect=True))
|
||||||
app.router.add_get(f'{prefix}/api/files/download', panel_auth(download))
|
app.router.add_get(f'{prefix}/api/files/download', panel_auth(download))
|
||||||
app.router.add_get(f'{prefix}/api/files/read', panel_auth(read_file))
|
app.router.add_get(f'{prefix}/api/files/read', panel_auth(read_file))
|
||||||
app.router.add_post(f'{prefix}/api/files/write', panel_auth(write_file))
|
app.router.add_post(f'{prefix}/api/files/write', panel_auth(write_file, csrf_protect=True))
|
||||||
app.router.add_get(f'{prefix}/api/files/info', panel_auth(file_info))
|
app.router.add_get(f'{prefix}/api/files/info', panel_auth(file_info))
|
||||||
app.router.add_get(f'{prefix}/api/files/picker', panel_auth(picker_api))
|
app.router.add_get(f'{prefix}/api/files/picker', panel_auth(picker_api))
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ def setup_project_routes(app, service_manager, prefix=''):
|
|||||||
return web.FileResponse("static/web_panel/pages/projects.html")
|
return web.FileResponse("static/web_panel/pages/projects.html")
|
||||||
|
|
||||||
app.router.add_get(f'{prefix}/api/projects', panel_auth(list_projects))
|
app.router.add_get(f'{prefix}/api/projects', panel_auth(list_projects))
|
||||||
app.router.add_post(f'{prefix}/api/projects/run', panel_auth(run_project))
|
app.router.add_post(f'{prefix}/api/projects/run', panel_auth(run_project, csrf_protect=True))
|
||||||
app.router.add_get(f'{prefix}/api/projects/{{name}}/logs', panel_auth(get_logs))
|
app.router.add_get(f'{prefix}/api/projects/{{name}}/logs', panel_auth(get_logs))
|
||||||
app.router.add_post(f'{prefix}/api/projects/{{name}}/stop', panel_auth(stop_project))
|
app.router.add_post(f'{prefix}/api/projects/{{name}}/stop', panel_auth(stop_project, csrf_protect=True))
|
||||||
app.router.add_post(f'{prefix}/api/projects/{{name}}/stdin', panel_auth(send_stdin))
|
app.router.add_post(f'{prefix}/api/projects/{{name}}/stdin', panel_auth(send_stdin, csrf_protect=True))
|
||||||
app.router.add_get(f'{prefix}/pages/projects', panel_auth(project_page))
|
app.router.add_get(f'{prefix}/pages/projects', panel_auth(project_page))
|
||||||
logger.info("📦 项目管理路由已注册 (已加认证)")
|
logger.info("📦 项目管理路由已注册 (已加认证)")
|
||||||
|
|||||||
@@ -27,6 +27,6 @@ def setup_proxy_routes(app, service_manager, prefix=''):
|
|||||||
return web.json_response({"ok": True})
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
app.router.add_get(f'{prefix}/api/proxy', panel_auth(list_proxies))
|
app.router.add_get(f'{prefix}/api/proxy', panel_auth(list_proxies))
|
||||||
app.router.add_post(f'{prefix}/api/proxy', panel_auth(add_proxy))
|
app.router.add_post(f'{prefix}/api/proxy', panel_auth(add_proxy, csrf_protect=True))
|
||||||
app.router.add_delete(f'{prefix}/api/proxy/{{path}}', panel_auth(remove_proxy))
|
app.router.add_delete(f'{prefix}/api/proxy/{{path}}', panel_auth(remove_proxy, csrf_protect=True))
|
||||||
logger.info(f'🔀 代理路由已注册 ({prefix}/api/proxy) (已加认证)')
|
logger.info(f'🔀 代理路由已注册 ({prefix}/api/proxy) (已加认证)')
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
import functools
|
import functools
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
def panel_auth(handler):
|
def panel_auth(handler, csrf_protect: bool = False):
|
||||||
"""面板专用鉴权装饰器:基于面板自有的 Session Store 验证"""
|
"""面板专用鉴权装饰器 — 基于面板自有的 Session Store 验证
|
||||||
|
csrf_protect=True 时额外检查 X-CSRF-Token 头 (用于写操作)
|
||||||
|
"""
|
||||||
@functools.wraps(handler)
|
@functools.wraps(handler)
|
||||||
async def wrapper(request, *args, **kwargs):
|
async def wrapper(request, *args, **kwargs):
|
||||||
# 1. 获取 Token
|
# 1. 获取 Token
|
||||||
@@ -19,16 +21,23 @@ def panel_auth(handler):
|
|||||||
session_store = request.app.get('panel_session_store', {})
|
session_store = request.app.get('panel_session_store', {})
|
||||||
if token and token in session_store:
|
if token and token in session_store:
|
||||||
is_valid = True
|
is_valid = True
|
||||||
# 验证通过,将用户信息注入 request 供后续使用
|
|
||||||
request['user'] = session_store[token]
|
request['user'] = session_store[token]
|
||||||
|
|
||||||
# 3. 拦截逻辑 (不再依赖外部 AuthService,确保安全隔离)
|
# 3. 拦截逻辑
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
# 返回 401 并附带提示,前端可据此判断状态
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"error": "未认证或会话已过期",
|
"error": "未认证或会话已过期",
|
||||||
"status": 401
|
"status": 401
|
||||||
}, status=401)
|
}, status=401)
|
||||||
|
|
||||||
|
# 4. CSRF 检查 — 写操作需要 X-CSRF-Token 头 (同 token)
|
||||||
|
if csrf_protect:
|
||||||
|
csrf = request.headers.get("X-CSRF-Token", "")
|
||||||
|
if csrf != token:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "CSRF 验证失败",
|
||||||
|
"status": 403,
|
||||||
|
}, status=403)
|
||||||
|
|
||||||
return await handler(request, *args, **kwargs)
|
return await handler(request, *args, **kwargs)
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
Reference in New Issue
Block a user