diff --git a/config/plugins/commands.yaml b/config/plugins/commands.yaml index f30ae27..2d29e1d 100644 --- a/config/plugins/commands.yaml +++ b/config/plugins/commands.yaml @@ -89,6 +89,6 @@ commands: permissions: - framework.command.test source: internal -last_updated: 12161.390698641 +last_updated: 12807.090239176 plugin_commands: {} total_commands: 18 diff --git a/config/services/network_routes.yaml b/config/services/network_routes.yaml index 714cc24..c4fb57c 100644 --- a/config/services/network_routes.yaml +++ b/config/services/network_routes.yaml @@ -1,4 +1,4 @@ http_port: 4200 -last_updated: 12161.450926401 +last_updated: 12807.105822092 plugin_routes: {} websocket_port: 4240 diff --git a/services/web_panel/manager.py b/services/web_panel/manager.py index 155845c..ae0617e 100644 --- a/services/web_panel/manager.py +++ b/services/web_panel/manager.py @@ -3,19 +3,35 @@ import os import logging +import hashlib +import secrets from pathlib import Path from aiohttp import web from .routes import auth, status, plugins, commands, logs, projects, proxy, plugin_web, files logger = logging.getLogger(__name__) + +def _hash_pw(password: str, salt: str) -> str: + return hashlib.sha256((password + salt).encode()).hexdigest() + + class WebPanelManager: def __init__(self, config: dict, service_manager): panel_cfg = config.get('panel', {}).get('entrance', {}) self.base_path = panel_cfg.get('path', '/panel') 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.sm = service_manager self.project_root = Path(__file__).resolve().parent.parent.parent @@ -37,8 +53,10 @@ class WebPanelManager: app['panel_config'] = { 'username': self.panel_user, 'password': self.panel_pass, + 'password_hash': self._pw_hash, + 'password_salt': self._pw_salt, '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", } # 注册静态文件 diff --git a/services/web_panel/routes/auth.py b/services/web_panel/routes/auth.py index 126d3fd..9477faf 100644 --- a/services/web_panel/routes/auth.py +++ b/services/web_panel/routes/auth.py @@ -120,10 +120,21 @@ async def handle_login(req): cfg = req.app.get('panel_config', {}) 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) # 登录成功:生成 Token token = secrets.token_hex(16) diff --git a/services/web_panel/routes/files.py b/services/web_panel/routes/files.py index 58e951f..fb4bf0c 100644 --- a/services/web_panel/routes/files.py +++ b/services/web_panel/routes/files.py @@ -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_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 = { '.txt','.py','.js','.ts','.html','.css','.json','.yaml','.yml', '.md','.ini','.cfg','.conf','.log','.sh','.bat','.env','.xml', @@ -316,6 +326,13 @@ def setup_file_routes(app, service_manager, prefix=''): continue # Sanitize filename 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 size = 0 with open(dest, 'wb') as f: @@ -423,13 +440,13 @@ def setup_file_routes(app, service_manager, prefix=''): # ── Register routes ── 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/touch', panel_auth(touch)) - app.router.add_post(f'{prefix}/api/files/delete', panel_auth(delete)) - app.router.add_post(f'{prefix}/api/files/rename', panel_auth(rename)) - app.router.add_post(f'{prefix}/api/files/upload', panel_auth(upload)) + 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, csrf_protect=True)) + 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, csrf_protect=True)) + 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/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/picker', panel_auth(picker_api)) diff --git a/services/web_panel/routes/projects.py b/services/web_panel/routes/projects.py index 9b64dba..47c3c30 100644 --- a/services/web_panel/routes/projects.py +++ b/services/web_panel/routes/projects.py @@ -54,9 +54,9 @@ def setup_project_routes(app, service_manager, prefix=''): return web.FileResponse("static/web_panel/pages/projects.html") 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_post(f'{prefix}/api/projects/{{name}}/stop', panel_auth(stop_project)) - app.router.add_post(f'{prefix}/api/projects/{{name}}/stdin', panel_auth(send_stdin)) + 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, csrf_protect=True)) app.router.add_get(f'{prefix}/pages/projects', panel_auth(project_page)) logger.info("📦 项目管理路由已注册 (已加认证)") diff --git a/services/web_panel/routes/proxy.py b/services/web_panel/routes/proxy.py index 69fc4fd..a154c4a 100644 --- a/services/web_panel/routes/proxy.py +++ b/services/web_panel/routes/proxy.py @@ -27,6 +27,6 @@ def setup_proxy_routes(app, service_manager, prefix=''): return web.json_response({"ok": True}) 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_delete(f'{prefix}/api/proxy/{{path}}', panel_auth(remove_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, csrf_protect=True)) logger.info(f'🔀 代理路由已注册 ({prefix}/api/proxy) (已加认证)') diff --git a/services/web_panel/utils/auth.py b/services/web_panel/utils/auth.py index 9ed5a25..2b26fb7 100644 --- a/services/web_panel/utils/auth.py +++ b/services/web_panel/utils/auth.py @@ -4,31 +4,40 @@ import functools from aiohttp import web -def panel_auth(handler): - """面板专用鉴权装饰器:基于面板自有的 Session Store 验证""" +def panel_auth(handler, csrf_protect: bool = False): + """面板专用鉴权装饰器 — 基于面板自有的 Session Store 验证 + csrf_protect=True 时额外检查 X-CSRF-Token 头 (用于写操作) + """ @functools.wraps(handler) async def wrapper(request, *args, **kwargs): # 1. 获取 Token token = request.cookies.get("panel_token") if not token and request.headers.get("Authorization", "").startswith("Bearer "): token = request.headers["Authorization"].split(" ", 1)[1] - + is_valid = False - + # 2. 从面板 Session Store 验证 session_store = request.app.get('panel_session_store', {}) if token and token in session_store: is_valid = True - # 验证通过,将用户信息注入 request 供后续使用 request['user'] = session_store[token] - - # 3. 拦截逻辑 (不再依赖外部 AuthService,确保安全隔离) + + # 3. 拦截逻辑 if not is_valid: - # 返回 401 并附带提示,前端可据此判断状态 return web.json_response({ - "error": "未认证或会话已过期", + "error": "未认证或会话已过期", "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 wrapper