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:
qinglong
2026-06-13 14:07:38 +08:00
parent d835b66ca9
commit 2da52e80d0
8 changed files with 84 additions and 29 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -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
+21 -3
View File
@@ -3,19 +3,35 @@
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
self.project_root = Path(__file__).resolve().parent.parent.parent self.project_root = Path(__file__).resolve().parent.parent.parent
@@ -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",
} }
# 注册静态文件 # 注册静态文件
+14 -3
View File
@@ -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)
+23 -6
View File
@@ -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))
+3 -3
View File
@@ -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("📦 项目管理路由已注册 (已加认证)")
+2 -2
View File
@@ -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) (已加认证)')
+19 -10
View File
@@ -4,31 +4,40 @@
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
token = request.cookies.get("panel_token") token = request.cookies.get("panel_token")
if not token and request.headers.get("Authorization", "").startswith("Bearer "): if not token and request.headers.get("Authorization", "").startswith("Bearer "):
token = request.headers["Authorization"].split(" ", 1)[1] token = request.headers["Authorization"].split(" ", 1)[1]
is_valid = False is_valid = False
# 2. 从面板 Session Store 验证 # 2. 从面板 Session Store 验证
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