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
+14 -3
View File
@@ -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)
+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_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))
+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")
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("📦 项目管理路由已注册 (已加认证)")
+2 -2
View File
@@ -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) (已加认证)')