security: 公网生产环境加固 — P0/P1 全部修复

API 认证 (16 个未保护端点 → 全部加 panel_auth):
- 文件管理器: 11 端点 (list/mkdir/delete/upload/download/read/write/...)
- 项目管理: 6 端点 (list/run/stop/logs/stdin/page), 修复硬编码路径前缀
- 代理管理: 3 端点 (list/add/remove)
- 系统状态: 2 HTTP + 1 WS (token 校验)
- 插件页面: 3 端点 (page/sse/event)
- 内部路由: 3 端点 (plugins/commands/data)

认证系统加固:
- 密码哈希: 固定盐 → 每用户独立 secrets.token_hex(16) 随机盐
- 默认密码警告: 启动时检测并打印 critical 级别日志
- 登录频率限制: 5 次失败 / IP → 锁定 60 秒, 返回 429

基础设施:
- 安全响应头: X-Content-Type-Options/X-Frame-Options/X-XSS-Protection/Referrer-Policy
- WebSocket 鉴权: query string token 校验

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
qinglong
2026-06-13 13:37:14 +08:00
parent 2dc3c16787
commit f7b4908322
8 changed files with 162 additions and 63 deletions
+47 -4
View File
@@ -2,6 +2,7 @@
# -*- coding: utf-8 -*-
import secrets
import time
import logging
from aiohttp import web
from ..utils.auth import panel_auth
@@ -9,9 +10,41 @@ from ..utils.auth import panel_auth
logger = logging.getLogger(__name__)
# 全局 Session 存储 (内存型)
# 格式: { "token_string": { "username": "...", "perms": [...] } }
PANEL_SESSION_STORE = {}
# 登录频率限制 — {ip: [fail_count, lock_until_timestamp]}
_LOGIN_FAILS: dict[str, list] = {}
_MAX_FAILS = 5
_LOCK_SECONDS = 60
def _check_rate_limit(ip: str) -> bool:
"""检查 IP 是否被限流。返回 True = 允许尝试"""
now = time.time()
entry = _LOGIN_FAILS.get(ip)
if entry:
fail_count, lock_until = entry
if now < lock_until:
return False # still locked
if now >= lock_until + _LOCK_SECONDS:
_LOGIN_FAILS.pop(ip, None) # expired, reset
return True
def _record_fail(ip: str):
now = time.time()
entry = _LOGIN_FAILS.get(ip, [0, 0])
entry[0] += 1
if entry[0] >= _MAX_FAILS:
entry[1] = now + _LOCK_SECONDS
logger.warning(f"🔒 IP {ip} 登录锁定 {_LOCK_SECONDS}s ({_MAX_FAILS} 次失败)")
_LOGIN_FAILS[ip] = entry
def _clear_fails(ip: str):
_LOGIN_FAILS.pop(ip, None)
def setup_routes(app, prefix=''):
"""注册面板认证路由"""
# 🟢 关键:将 Session Store 挂载到 app,供拦截器读取
@@ -26,16 +59,25 @@ def setup_routes(app, prefix=''):
async def handle_login(req):
"""处理面板登录"""
try:
# 频率限制
ip = req.remote
if not _check_rate_limit(ip):
return web.json_response(
{"success": False, "msg": "尝试次数过多,请 60 秒后重试"},
status=429,
)
data = await req.json()
username = data.get('username')
password = data.get('password')
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:
_clear_fails(ip)
# 登录成功:生成 Token
token = secrets.token_hex(16)
@@ -54,7 +96,8 @@ async def handle_login(req):
resp.set_cookie("panel_token", token, max_age=259200, httponly=True, samesite="Lax")
return resp
else:
logger.warning(f"❌ 面板登录失败: 用户 {username} 密码错误")
_record_fail(ip)
logger.warning(f"❌ 面板登录失败: 用户 {username} 密码错误 (IP: {ip})")
return web.json_response({"success": False, "msg": "用户名或密码错误"}, status=401)
except Exception as e:
+12 -11
View File
@@ -28,6 +28,7 @@ import logging
import mimetypes
from pathlib import Path
from aiohttp import web
from ..utils.auth import panel_auth
logger = logging.getLogger(__name__)
@@ -412,14 +413,14 @@ def setup_file_routes(app, service_manager, prefix=''):
return web.json_response({"error": str(e)}, status=500)
# ── Register routes ──
app.router.add_get(f'{prefix}/api/files/list', list_dir)
app.router.add_post(f'{prefix}/api/files/mkdir', mkdir)
app.router.add_post(f'{prefix}/api/files/touch', touch)
app.router.add_post(f'{prefix}/api/files/delete', delete)
app.router.add_post(f'{prefix}/api/files/rename', rename)
app.router.add_post(f'{prefix}/api/files/upload', upload)
app.router.add_get(f'{prefix}/api/files/download', download)
app.router.add_get(f'{prefix}/api/files/read', read_file)
app.router.add_post(f'{prefix}/api/files/write', write_file)
app.router.add_get(f'{prefix}/api/files/info', file_info)
app.router.add_get(f'{prefix}/api/files/picker', picker_api)
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_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_get(f'{prefix}/api/files/info', panel_auth(file_info))
app.router.add_get(f'{prefix}/api/files/picker', panel_auth(picker_api))
+6 -4
View File
@@ -1,5 +1,7 @@
from aiohttp import web
import json, logging
from ..utils.auth import panel_auth
logger = logging.getLogger(__name__)
def setup_plugin_web_routes(app, service_manager):
@@ -32,7 +34,7 @@ def setup_plugin_web_routes(app, service_manager):
return web.json_response({"ok": True})
return web.json_response({"ok": False}, status=404)
app.router.add_get("/plugin/{name}", plugin_page)
app.router.add_get("/plugin/{name}/sse", plugin_sse)
app.router.add_post("/plugin/{name}/event", plugin_event)
logger.info("Plugin web routes registered")
app.router.add_get("/plugin/{name}", panel_auth(plugin_page))
app.router.add_get("/plugin/{name}/sse", panel_auth(plugin_sse))
app.router.add_post("/plugin/{name}/event", panel_auth(plugin_event))
logger.info("Plugin web routes registered (已加认证)")
+9 -7
View File
@@ -1,5 +1,7 @@
from aiohttp import web
import json, logging
from ..utils.auth import panel_auth
logger = logging.getLogger(__name__)
def _get_engine(request):
@@ -51,10 +53,10 @@ def setup_project_routes(app, service_manager, prefix=''):
async def project_page(request):
return web.FileResponse("static/web_panel/pages/projects.html")
app.router.add_get(f'{prefix}/api/projects', list_projects)
app.router.add_post("/api/projects/run", run_project)
app.router.add_get("/api/projects/{name}/logs", get_logs)
app.router.add_post("/api/projects/{name}/stop", stop_project)
app.router.add_post("/api/projects/{name}/stdin", send_stdin)
app.router.add_get("/pages/projects", project_page)
logger.info("📦 项目管理路由已注册")
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_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_get(f'{prefix}/pages/projects', panel_auth(project_page))
logger.info("📦 项目管理路由已注册 (已加认证)")
+6 -4
View File
@@ -1,5 +1,7 @@
from aiohttp import web, ClientSession
import json, logging, asyncio
from ..utils.auth import panel_auth
logger = logging.getLogger(__name__)
def setup_proxy_routes(app, service_manager, prefix=''):
@@ -24,7 +26,7 @@ def setup_proxy_routes(app, service_manager, prefix=''):
ps.unregister_proxy(path)
return web.json_response({"ok": True})
app.router.add_get(f'{prefix}/api/proxy', list_proxies)
app.router.add_post(f'{prefix}/api/proxy', add_proxy)
app.router.add_delete(f'{prefix}/api/proxy/{{path}}', remove_proxy)
logger.info(f'🔀 代理路由已注册 ({prefix}/api/proxy)')
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))
logger.info(f'🔀 代理路由已注册 ({prefix}/api/proxy) (已加认证)')
+19 -4
View File
@@ -4,6 +4,7 @@ import asyncio
import logging
from aiohttp import web
from ..utils.system_info import SystemInfoCollector
from ..utils.auth import panel_auth
collector = SystemInfoCollector()
logger = logging.getLogger(__name__)
@@ -11,11 +12,25 @@ logger = logging.getLogger(__name__)
# Track active system-status WS clients
_sys_ws_clients: set = set()
def _ws_auth_wrapper(handler):
"""WebSocket 鉴权包装 — 从 query string 取 token 验证"""
async def wrapper(request):
token = request.query.get("token", "")
session_store = request.app.get("panel_session_store", {})
if not token or token not in session_store:
ws = web.WebSocketResponse()
await ws.prepare(request)
await ws.send_str(json.dumps({"error": "Unauthorized"}))
await ws.close(code=4001, message="Unauthorized")
return ws
return await handler(request)
return wrapper
def setup_routes(app, prefix=''):
app.router.add_get(f'{prefix}/api/framework', get_framework)
app.router.add_get(f'{prefix}/api/system', get_system)
app.router.add_get(f'{prefix}/api/system/ws', sys_ws_handler)
logger.info(f"📡 系统状态WS端点已注册: {prefix}/api/system/ws")
app.router.add_get(f'{prefix}/api/framework', panel_auth(get_framework))
app.router.add_get(f'{prefix}/api/system', panel_auth(get_system))
app.router.add_get(f'{prefix}/api/system/ws', _ws_auth_wrapper(sys_ws_handler))
logger.info(f"📡 系统状态WS端点已注册: {prefix}/api/system/ws (已加认证)")
def _read_version():
"""Read version from config file (shared helper)"""