仰望星辰工作室

refactor: 优化多模块代码,修复安全与体验问题

Y yang1145 提交于 2026-09-13 19:05 · 870d7d9 ·父提交 424e35f
refactor: 优化多模块代码,修复安全与体验问题

1. 修复docker续费SQL注入风险,改用预处理语句
2. 优化API鉴权日志与错误提示,避免泄露密钥
3. 重构到期时间格式化逻辑,统一处理不同格式输入
4. 修复升降级配额逻辑,改为全量传参避免配额丢失
5. 新增密码显示隐藏功能,优化复制按钮安全实现
6. 增加SSL校验开关配置,优化调试日志输出控制
7. 修复并发插入主机ID重复问题,增加重试机制
8. 优化文档与模板,增加XSS防护与安全跳转属性
8 个文件变更 +192 -98 13172193298@163.com
•api/api.php +31 -12
•api/docker.php +4 -2
•mf_modules/servers/mnbtdocker/README.md +7 -10
•mf_modules/servers/mnbtdocker/mnbtdocker.php +50 -21
•mf_modules/servers/mnbtdocker/templates/console.html +15 -9
•mf_modules/servers/mnbthost/README.md +1 -1
•mf_modules/servers/mnbthost/mnbthost.php +57 -38
•mf_modules/servers/mnbthost/templates/console.html +27 -5
变更内容
diff --git a/api/api.php b/api/api.php
index 0e07c4c..82092ad 100644
--- a/api/api.php
+++ b/api/api.php
@@ -37,8 +37,9 @@ $et_zj=$DB->get_row_prepare("SELECT * FROM MN_zj WHERE user=? limit 1", [$user])
 if($cert=='' || $cert['qk']=='false')api_json_exit(100, '错误!该宝塔不存在或该宝塔已经被关闭');
 $adyjm=$cert['ktmy'].$cert['qmk'];$mdjm=md5($adyjm);
 if($keye!=$mdjm){
-    mnbt_log('外部API','API鉴权','API-'.$bh.' '.$user.' 宝塔调用密钥错误(发送:'.substr($keye,0,8).',期望:'.substr($mdjm,0,8).',btdh='.$bh.')','鉴权失败',$DB);
-    api_json_exit(100, '调用密钥不匹配!正确的调用密钥为:'.$mdjm);
+    // 不回显正确密钥,避免密钥泄露
+    mnbt_log('外部API','API鉴权','API-'.$bh.' '.$user.' 宝塔调用密钥错误(发送:'.substr($keye,0,8).',btdh='.$bh.')','鉴权失败',$DB);
+    api_json_exit(100, '调用密钥不匹配!');
 }
 $btipe=($cert['ptl']=='true'?'https':'http').'://'.$cert['btip'].':'.$cert['btdk'];
 $btkeye=$cert['btmy'];
@@ -52,7 +53,8 @@ if($gn=='cfif'){
     $webdx=json_encode(array('max'=>daddslashes($_POST['webdx'] ?? 0),'dq'=>0));
     $sqldx=json_encode(array('max'=>daddslashes($_POST['sqldx'] ?? 0),'dq'=>0));
     $ymbds=daddslashes($_POST['ymbds'] ?? 0);
-    if($et_zj!='' || $et_zj!=false){
+    // 直接真值判断:已存在则报错
+    if($et_zj){
         api_lifecycle_log('API开通主机','开通'.$user.'失败:主机已存在','开通失败');
         api_json_exit(100, '错误!该主机已经存在,请重新开通!');
     }
@@ -87,12 +89,11 @@ if($gn=='cfif'){
         api_lifecycle_log('API开通主机','开通'.$user.'失败:账号重复','开通失败');
         api_json_exit(100, '错误!该账号已存在!请更换账号!');
     }
-    $rowe=$DB->get_row_prepare("SELECT * FROM MN_zj WHERE 1 order by id desc limit 1");
-    $id=$rowe['id']+1;
+    // max(id)+1 并发下可能重复导致插入失败,失败时重新取号重试几次
     $hskr=mt_rand(4,10);
     $rqsj=md5($date.$user);
     $wjler=substr($rqsj, $hskr , 3);
-    $btserw='mnbt.'.$id.mt_rand(1,999).$wjler;
+    $btserw='mnbt.'.mt_rand(1,999).$wjler;
     $mrwww=$cert['btos']=='1' ? $conf['hxi'].'/'.$btserw : $conf['hxo'].'/'.$btserw;
     $r_data = $api->webkt($user,$pass,$btserw,'主机','true','true',$phpVersion,$mrwww);
     $cjqk=$r_data['siteStatus'] ?? false;
@@ -111,7 +112,17 @@ if($gn=='cfif'){
         $aedfs = '0'; $sqlfs = '0';
         foreach(($r_datn['data'] ?? []) as $val){ if($val['name']===$user){ $aedfs=$val['id']; break; } }
         foreach(($r_datp['data'] ?? []) as $val){ if($val['name']===$user){ $sqlfs=$val['id']; break; } }
-        if($DB->query_prepare("INSERT INTO `MN_zj` (`id`, `ssbt`, `user`, `pass`, `sqluser`, `sqlpass`, `data`, `datae`, `qk`, `btid`, `sqldz`, `ftpid`, `ymbds`, `hxa`, `hxb`, `hxc`, `hxd`, `llmax`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", [$id, $bh, $user, $pass, $user, $pass, $date, $datae, 'true', $zdide, $btserw, $aedfs, $ymbds, $webdx, $sqldx, '2', $sqlfs, $flowratemax])){
+        // 写入主机记录:max(id)+1 在并发下可能重复导致插入失败,失败时重新取号重试几次
+        $insert_ok = false;
+        for($tryi = 0; $tryi < 3; $tryi++){
+            $rowe=$DB->get_row_prepare("SELECT id FROM MN_zj WHERE 1 order by id desc limit 1");
+            $id=($rowe['id'] ?? 0)+1;
+            if($DB->query_prepare("INSERT INTO `MN_zj` (`id`, `ssbt`, `user`, `pass`, `sqluser`, `sqlpass`, `data`, `datae`, `qk`, `btid`, `sqldz`, `ftpid`, `ymbds`, `hxa`, `hxb`, `hxc`, `hxd`, `llmax`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", [$id, $bh, $user, $pass, $user, $pass, $date, $datae, 'true', $zdide, $btserw, $aedfs, $ymbds, $webdx, $sqldx, '2', $sqlfs, $flowratemax])){
+                $insert_ok = true;
+                break;
+            }
+        }
+        if($insert_ok){
             api_lifecycle_log('API开通主机','开通'.$user.'成功,站点'.$btserw,'开通成功');
             $host_row = $DB->get_row_prepare("SELECT * FROM MN_zj WHERE id=? limit 1", [$id]);
             if (function_exists('mnbt_do_action')) {
@@ -128,6 +139,7 @@ if($gn=='cfif'){
         api_json_exit(100, '错误!网站创建失败!宝塔返回信息:'.($r_data['msg'] ?? '未知错误'));
     }
 }elseif($gn=='zt'){
+    if(empty($et_zj)) api_json_exit(100, '不存在主机用户名');
     $api = new bt_api($btipe,$btkeye);
     $api->siteqt($et_zj['btid'],$et_zj['sqldz'],false);
     $api->setftpzt($et_zj['ftpid'],$et_zj['user'],'0');
@@ -137,11 +149,13 @@ if($gn=='cfif'){
     }
     api_json_exit(200, '主机暂停成功!');
 }elseif($gn=='xf'){
+    if(empty($et_zj)) api_json_exit(100, '不存在主机用户名');
     $x_dq_date=($_POST['setdate'] ?? '0')=='0' ? '0000-00-00' : $_POST['setdate'];
     $old_date=$et_zj['datae'] ?? '';
     $api = new bt_api($btipe,$btkeye);
     $r_data = $api->setdqsj($et_zj['btid'],$x_dq_date);
-    if(strtotime($date)-strtotime($x_dq_date)<0 && $x_dq_date!='0000-00-00' && $et_zj['qk']){
+    // qk 为字符串 'true'/'false',需显式比较;未到期且已暂停时续费解停
+    if(strtotime($date)-strtotime($x_dq_date)<0 && $x_dq_date!='0000-00-00' && $et_zj['qk']=='true'){
         $api->siteqt($et_zj['btid'],$et_zj['sqldz'],true);
         $api->setftpzt($et_zj['ftpid'],$et_zj['user'],'1');
     }
@@ -156,6 +170,7 @@ if($gn=='cfif'){
     api_lifecycle_log('API续费主机','续费'.$user.'数据库写入失败','续费失败');
     api_json_exit(100, '主机续费失败,数据库写入失败!');
 }elseif($gn=='jc'){
+    if(empty($et_zj)) api_json_exit(100, '不存在主机用户名');
     $api = new bt_api($btipe,$btkeye);
     $api->siteqt($et_zj['btid'],$et_zj['sqldz'],true);
     $api->setftpzt($et_zj['ftpid'],$et_zj['user'],'1');
@@ -167,6 +182,7 @@ if($gn=='cfif'){
     }
     else api_json_exit(100, '主机暂停解除成功!但是写入数据库时出现错误!请站长排查!');
 }elseif($gn=='tz'){
+    if(empty($et_zj)) api_json_exit(100, '不存在主机用户名');
     $api = new bt_api($btipe,$btkeye);
     $r_data = $api->delsite($et_zj['btid'],$et_zj['sqldz']);
     if($r_data['status']){
@@ -184,7 +200,9 @@ if($gn=='cfif'){
         api_json_exit(100, '主机删除失败!因为'.($r_data['msg'] ?? '未知错误'));
     }
 }elseif($gn=='czmm'){
-    $x_up_pass=$_POST['password'];
+    if(empty($et_zj)) api_json_exit(100, '不存在主机用户名');
+    if(!isset($_POST['password']) || $_POST['password']==='')api_json_exit(100, '错误!缺少新密码参数 password!');
+    $x_up_pass=daddslashes($_POST['password']);
     $api = new bt_api($btipe,$btkeye);
     $api->setftppass($et_zj['ftpid'],$user,$x_up_pass);
     if($DB->query_prepare("update `MN_zj` set `pass` =? where `user`=?", [$x_up_pass, $user]))api_json_exit(200, '主机FTP及控制面板登陆密码重置成功!');
@@ -195,9 +213,10 @@ if($gn=='cfif'){
     $hxa_array = json_decode($zjdata['hxa'],true);
     $hxb_array = json_decode($zjdata['hxb'],true);
     $llmax_array = json_decode($zjdata['llmax'],true);
-    $hxa_array['max'] = $_POST['websize'];
-    $hxb_array['max'] = $_POST['sqlsize'];
-    $llmax_array['max'] = $_POST['ll'];
+    // 有传参才覆盖对应配额,三项相互独立,避免缺参导致配额被清零
+    if(isset($_POST['websize']) && $_POST['websize']!=='') $hxa_array['max'] = $_POST['websize'];
+    if(isset($_POST['sqlsize']) && $_POST['sqlsize']!=='') $hxb_array['max'] = $_POST['sqlsize'];
+    if(isset($_POST['ll']) && $_POST['ll']!=='') $llmax_array['max'] = $_POST['ll'];
     if($DB->query_prepare("UPDATE `MN_zj` SET `hxa` = ?, `hxb` = ?, `llmax` = ? WHERE `user` = ?", [json_encode($hxa_array), json_encode($hxb_array), json_encode($llmax_array), $user])) api_json_exit(200, '主机修改成功');
     api_json_exit(100, '主机修改失败我也不知道什么问题,请联系开发者');
 }elseif($gn == 'start'){
diff --git a/api/docker.php b/api/docker.php
index 4ef1963..006dd00 100644
--- a/api/docker.php
+++ b/api/docker.php
@@ -185,7 +185,8 @@ if ($gn === 'xf') {
 		api_json_exit(100, '错误!该 Docker 账号不存在');
 	}
 	$old_datae = $urow['datae'];
-	$updates = "datae='" . $new_datae . "'";
+	$updates = "datae=?";
+	$bind = [$new_datae];
 	// 若原 expired 且新到期时间未过 → 恢复 active
 	if ($urow['qk'] === 'expired') {
 		if ($new_datae === '0000-00-00' || strtotime($date) - strtotime($new_datae) < 0) {
@@ -200,7 +201,8 @@ if ($gn === 'xf') {
 			}
 		}
 	}
-	$DB->query("UPDATE MN_docker_user SET {$updates} WHERE id=" . intval($urow['id']));
+	$bind[] = $urow['id'];
+	$DB->query_prepare("UPDATE MN_docker_user SET {$updates} WHERE id=?", $bind);
 	api_lifecycle_log('API续费Docker', '续费 ' . $user . ' ' . $old_datae . '=>' . $new_datae, '续费成功');
 	if (function_exists('mnbt_do_action')) {
 		mnbt_do_action('docker.user.renewed', array_merge($urow, ['datae' => $new_datae]), ['source' => 'api', 'old_date' => $old_datae, 'new_date' => $new_datae]);
diff --git a/mf_modules/servers/mnbtdocker/README.md b/mf_modules/servers/mnbtdocker/README.md
index bd35e49..ca1cba1 100644
--- a/mf_modules/servers/mnbtdocker/README.md
+++ b/mf_modules/servers/mnbtdocker/README.md
@@ -31,23 +31,19 @@
 
 产品关联模块后只需填写可选配置(如 `plan_id`),其余从服务器字段自动解析。
 
-### 方式二:模块配置选项(精确控制)
+**注意**:`api_url` / `api_key` / `node_id` / `call_key` 不支持通过配置选项填写,必须使用上述服务器字段。
 
-产品配置选项中填写:
+产品可配置选项(configoptions)仅支持以下两个:
 
-| key | 说明 |
+| 配置选项 | 说明 |
 |-----|------|
-| `api_url` | `https://mnbt.example.com/api/docker.php` |
-| `api_key` | 系统 API 密钥 |
-| `node_id` | 节点编号 |
-| `call_key` | `md5(节点ktmy . 节点qmk)` |
-| `plan_id` | 默认套餐 ID(可选) |
-| `console_url` | `https://mnbt.example.com/docker/login.php` |
+| `configoption1`(默认套餐 ID) | 开通时绑定的默认套餐(`MN_docker_plan.id`,可选) |
+| `configoption2`(控制台地址) | Docker 控制台入口 URL(可选,留空则按服务器地址自动拼接 `/docker/login.php`) |
 
 ## 产品关联
 
 1. 后台「产品 → 添加产品」→ 类型:**服务器产品** → 模块:**梦奈宝塔Docker对接插件**
-2. 可配置选项添加「套餐 ID」字段,映射 key 为 `plan_id`(可选,用于升降级)
+2. 可配置选项添加「套餐 ID」字段(即 `configoption1`,可选,用于升降级;变更套餐时以此处的值为新套餐)
 3. 上架后即可前台购买
 
 ## 模块方法说明
@@ -74,4 +70,5 @@
 
 - **单容器模型**:每个账号仅一个容器;开通后用户需在 MNBT 控制台的应用商店自行创建容器
 - **双登录体系**:魔方登录态 ≠ 梦奈宝塔Docker对接插件_token,用户需用 Docker 账号二次登录控制台
+- **用量查询(gn=sy)**:本插件未实现用量查询功能,前台不展示磁盘用量
 - **P1 自动登录**:下期通过 `gn=dl` 一次性票据实现免登录跳转
diff --git a/mf_modules/servers/mnbtdocker/mnbtdocker.php b/mf_modules/servers/mnbtdocker/mnbtdocker.php
index 56afe90..2d5ca90 100644
--- a/mf_modules/servers/mnbtdocker/mnbtdocker.php
+++ b/mf_modules/servers/mnbtdocker/mnbtdocker.php
@@ -125,8 +125,11 @@ function _mnbtdocker_api_call($params, $gn, $extra = [], $timeout = 30)
         'username' => $username,
     ], $extra);
 
-    // DEBUG:打印请求参数(部署确认问题后可删除此段)
-    $debug_info = "gn={$gn}, mn_bh=[{$post['mn_bh']}], mn_key=[len=" . strlen($post['mn_key']) . "], mn_keye=[len=" . strlen($post['mn_keye']) . "], mn_vs=[{$post['mn_vs']}], username=[{$post['username']}]";
+    // DEBUG:仅在 MNBT_DEBUG 为 true 时打印请求参数
+    $debug_info = '';
+    if (defined('MNBT_DEBUG') && MNBT_DEBUG) {
+        $debug_info = "gn={$gn}, mn_bh=[{$post['mn_bh']}], mn_key=[len=" . strlen($post['mn_key']) . "], mn_keye=[len=" . strlen($post['mn_keye']) . "], mn_vs=[{$post['mn_vs']}], username=[{$post['username']}]";
+    }
 
     $url = $api_url . '?gn=' . urlencode($gn);
 
@@ -155,8 +158,10 @@ function _mnbtdocker_api_call($params, $gn, $extra = [], $timeout = 30)
         return ['success' => false, 'code' => 0, 'msg' => '[mnbtdocker] 响应解析失败:' . substr($resp, 0, 200)];
     }
 
-    // 附加调试信息到响应中
-    $decoded['_debug'] = $debug_info;
+    // 附加调试信息到响应中(仅 MNBT_DEBUG 开启时)
+    if ($debug_info !== '') {
+        $decoded['_debug'] = $debug_info;
+    }
     return $decoded;
 }
 
@@ -207,6 +212,25 @@ function mnbtdocker_TestLink($params)
 //  生命周期方法
 // ========================================================================
 
+/**
+ * 统一把 nextduedate 转为 'Y-m-d'(后端 dqtime/setdate 期望格式)
+ * 纯数字时间戳先转日期;空值/'0000-00-00' 返回 '0'(永久)
+ */
+function _mnbtdocker_format_dqtime($nextduedate)
+{
+    $dqtime = trim((string)($nextduedate ?? ''));
+    if ($dqtime === '' || $dqtime === '0000-00-00' || $dqtime === '0000-00-00 00:00:00') {
+        return '0';
+    }
+    if (ctype_digit($dqtime)) {
+        $dqtime = date('Y-m-d', (int)$dqtime);
+    } else {
+        $ts = strtotime($dqtime);
+        $dqtime = $ts ? date('Y-m-d', $ts) : '0';
+    }
+    return $dqtime ?: '0';
+}
+
 /**
  * 开通账户
  * 只开通账号,不创建容器(容器由用户登录控制台后在应用商店创建)
@@ -223,10 +247,7 @@ function mnbtdocker_CreateAccount($params)
         $password = substr(md5(uniqid(mt_rand(), true)), 0, 12);
     }
 
-    $dqtime = $params['nextduedate'] ?? '';
-    if (empty($dqtime) || $dqtime == '0000-00-00') {
-        $dqtime = '0';
-    }
+    $dqtime = _mnbtdocker_format_dqtime($params['nextduedate'] ?? '');
 
     $extra = [
         'username' => $username,
@@ -241,9 +262,18 @@ function mnbtdocker_CreateAccount($params)
     $r = _mnbtdocker_api_call($params, 'kt', $extra);
     $result = _mnbtdocker_return($r);
 
-    if ($result === 'success' && !empty($password)) {
-        // 尝试回写密码到产品表
-        // idcsmart 可能通过返回值中的 password 字段自动更新
+    // 开通成功后把真实密码回写到魔方产品表,确保用户可在产品详情中看到密码
+    if ($result === 'success' && !empty($password) && !empty($params['hostid'])) {
+        try {
+            if (class_exists('\think\facade\Db')) {
+                // 魔方财务(ThinkPHP 6)环境:更新 host 表 password 字段
+                \think\facade\Db::name('host')
+                    ->where('id', (int)$params['hostid'])
+                    ->update(['password' => $password]);
+            }
+        } catch (\Throwable $e) {
+            // 回写失败不影响开通结果(如非 TP 环境或字段不存在)
+        }
     }
 
     return $result;
@@ -273,10 +303,7 @@ function mnbtdocker_TerminateAccount($params)
 /** 续费 */
 function mnbtdocker_Renew($params)
 {
-    $dqtime = $params['nextduedate'] ?? '';
-    if (empty($dqtime) || $dqtime == '0000-00-00') {
-        $dqtime = '0';
-    }
+    $dqtime = _mnbtdocker_format_dqtime($params['nextduedate'] ?? '');
     $r = _mnbtdocker_api_call($params, 'xf', ['setdate' => $dqtime]);
     return _mnbtdocker_return($r);
 }
@@ -287,11 +314,13 @@ function mnbtdocker_ChangePackage($params)
     // 从可配置选项中获取新的 plan_id
     // 魔方升降级时 old_configoptions / configoptions 会有新旧值
     $new_plan_id = $params['configoptions']['plan_id'] ?? $params['config_options']['plan_id'] ?? 0;
-    if (empty($new_plan_id)) {
-        // 兼容:某些魔方版本 key 在 configoptionX 直接覆盖
-        list(, , , , $new_plan_id) = _mnbtdocker_resolve_params($params);
+    $new_plan_id = is_array($new_plan_id) ? '' : trim((string)$new_plan_id);
+    if ($new_plan_id === '') {
+        // 兼容:某些魔方版本 key 在 configoptionX 直接覆盖(此时 configoption1 即新套餐)
+        $new_plan_id = trim((string)($params['configoption1'] ?? ''));
     }
-    if (empty($new_plan_id)) {
+    if ($new_plan_id === '' || (int)$new_plan_id <= 0) {
+        // 解析不到新套餐 ID 时明确报错,避免静默重复提交原套餐
         return '未找到新套餐 ID,请确认产品可配置选项中已设置 plan_id 字段';
     }
     $r = _mnbtdocker_api_call($params, 'bg', ['plan_id' => $new_plan_id]);
@@ -315,7 +344,7 @@ function mnbtdocker_CrackPassword($params, $new_pass = '')
  */
 function mnbtdocker_Status($params)
 {
-    $r = _mnbtdocker_api_call($params, 'ztcx', [], 60);
+    $r = _mnbtdocker_api_call($params, 'ztcx', [], 25);
     if (!($r['success'] ?? false) || ($r['code'] ?? 0) != 200) {
         return [
             'status' => 'error',
@@ -349,7 +378,7 @@ function mnbtdocker_Status($params)
     }
 
     // 附加容器信息
-    if ($container && !empty($container['port'])) {
+    if ($container && !empty($container['port']) && is_array($container['port'])) {
         $des .= ' | 端口:' . implode(', ', $container['port']);
     }
 
diff --git a/mf_modules/servers/mnbtdocker/templates/console.html b/mf_modules/servers/mnbtdocker/templates/console.html
index 914205b..d3df65b 100644
--- a/mf_modules/servers/mnbtdocker/templates/console.html
+++ b/mf_modules/servers/mnbtdocker/templates/console.html
@@ -31,14 +31,14 @@
                 <div class="info-label">控制台地址</div>
                 <div class="info-value">
                     {if condition="!empty($console_url)"}
-                    <a href="{$console_url}" target="_blank">{$console_url}</a>
+                    <a href="{$console_url|htmlspecialchars}" target="_blank" rel="noopener noreferrer">{$console_url|htmlspecialchars}</a>
                     {else /}
                     <span style="color:#999">未配置</span>
                     {/if}
                 </div>
             </div>
             {if condition="!empty($console_url)"}
-            <button class="copy-btn" onclick="dockerCopy(this,'{$console_url}')" title="复制">📋</button>
+            <button class="copy-btn" data-text="{$console_url|htmlspecialchars}" title="复制">📋</button>
             {/if}
         </div>
 
@@ -46,10 +46,10 @@
             <div class="info-icon user">👤</div>
             <div class="info-body">
                 <div class="info-label">登录账号</div>
-                <div class="info-value"><code>{$username|default='—'}</code></div>
+                <div class="info-value"><code>{$username|default='—'|htmlspecialchars}</code></div>
             </div>
             {if condition="!empty($username)"}
-            <button class="copy-btn" onclick="dockerCopy(this,'{$username}')" title="复制">📋</button>
+            <button class="copy-btn" data-text="{$username|htmlspecialchars}" title="复制">📋</button>
             {/if}
         </div>
 
@@ -57,24 +57,24 @@
             <div class="info-icon pwd">🔑</div>
             <div class="info-body">
                 <div class="info-label">登录密码</div>
-                <div class="info-value"><code>{$password|default='—'}</code></div>
+                <div class="info-value"><code>{$password|default='—'|htmlspecialchars}</code></div>
             </div>
             {if condition="!empty($password)"}
-            <button class="copy-btn" onclick="dockerCopy(this,'{$password}')" title="复制">📋</button>
+            <button class="copy-btn" data-text="{$password|htmlspecialchars}" title="复制">📋</button>
             {/if}
         </div>
 
         <div class="info-row">
-            <div class="info-icon stat {$status_class}">📊</div>
+            <div class="info-icon stat {$status_class|htmlspecialchars}">📊</div>
             <div class="info-body">
                 <div class="info-label">容器状态</div>
-                <div class="status-text">{$status_text}</div>
+                <div class="status-text">{$status_text|htmlspecialchars}</div>
             </div>
         </div>
 
         {if condition="!empty($console_url)"}
         <div class="btn-area">
-            <a href="{$console_url}" target="_blank" class="btn btn-primary btn-sm">前往控制台</a>
+            <a href="{$console_url|htmlspecialchars}" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">前往控制台</a>
         </div>
         {/if}
 
@@ -82,6 +82,7 @@
 </div>
 
 <script>
+// 通过 data-* 属性安全传参,避免将变量直接拼入 JS 字符串
 function dockerCopy(btn, text) {
     var ta = document.createElement('textarea');
     ta.value = text;
@@ -93,4 +94,9 @@ function dockerCopy(btn, text) {
     btn.innerHTML = '✓';
     setTimeout(function(){ btn.classList.remove('copied'); btn.innerHTML = '📋'; }, 1500);
 }
+document.addEventListener('click', function(e) {
+    var btn = e.target.closest ? e.target.closest('.copy-btn') : null;
+    if (!btn) return;
+    dockerCopy(btn, btn.getAttribute('data-text') || '');
+});
 </script>
diff --git a/mf_modules/servers/mnbthost/README.md b/mf_modules/servers/mnbthost/README.md
index 18f9f5c..f5f7fe0 100644
--- a/mf_modules/servers/mnbthost/README.md
+++ b/mf_modules/servers/mnbthost/README.md
@@ -52,7 +52,7 @@ MNBT 后台「宝塔列表」→ 找到对应节点 → **ktmy 列**点击👁
 | 恢复 | `jc` | qk=true |
 | 删除 | `tz` | 删站点+删行 |
 | 续费 | `xf` | 更新到期时间 |
-| 升降级 | `zjmode` | 更新空间/数据库/流量配额(仅传变更项) |
+| 升降级 | `zjmode` | 全量传三项配额(空间/数据库/流量),后端有传参才覆盖对应字段 |
 | 改密 | `czmm` | 重置 FTP+控制面板密码 |
 | 开机/关机 | `start`/`stop` | 站点启停(P0 已实现) |
 | 状态/同步 | `ztcx` | 状态+配额用量 |
diff --git a/mf_modules/servers/mnbthost/mnbthost.php b/mf_modules/servers/mnbthost/mnbthost.php
index db63af3..e2d1311 100644
--- a/mf_modules/servers/mnbthost/mnbthost.php
+++ b/mf_modules/servers/mnbthost/mnbthost.php
@@ -79,7 +79,7 @@ function mnbthost_ConfigOptions()
  *   server_password          → 调用密钥 md5(ktmy.qmk),空则默认 md5('')
  *   accesshash               → 系统 API 密钥($conf['api'])
  *
- * @return array [api_url, api_key, node_id, call_key, plan_id, console_url]
+ * @return array [api_url, api_key, node_id, call_key, console_url]
  */
 function _mnbthost_resolve_params($params)
 {
@@ -125,6 +125,24 @@ function _mnbthost_resolve_params($params)
     return [$api_url, $api_key, $node_id, $call_key, $console_url];
 }
 
+/**
+ * 将魔方的到期时间统一转换为 'Y-m-d' 格式
+ * 魔方 nextduedate 可能是纯时间戳或 'Y-m-d H:i:s' 字符串;
+ * 空 / '0000-00-00' 返回 '0'(后端约定 0 = 永不到期)
+ */
+function _mnbthost_format_duedate($nextduedate)
+{
+    $nextduedate = trim((string)$nextduedate);
+    if ($nextduedate === '' || $nextduedate == '0000-00-00' || $nextduedate == '0000-00-00 00:00:00') {
+        return '0';
+    }
+    if (ctype_digit($nextduedate)) {
+        return date('Y-m-d', (int)$nextduedate);
+    }
+    $ts = strtotime($nextduedate);
+    return $ts ? date('Y-m-d', $ts) : '0';
+}
+
 /**
  * 调用 MNBT 虚拟主机 API
  *
@@ -155,10 +173,17 @@ function _mnbthost_api_call($params, $gn, $extra = [], $timeout = 30)
         'username' => $username,
     ], $extra);
 
-    // DEBUG:打印请求参数摘要(定位鉴权问题用,确认后可删除)
-    $dbg = "gn={$gn} | mn_bh=[{$post['mn_bh']}] | mn_key_len=" . strlen($post['mn_key'])
-        . " | mn_keye=[" . substr($post['mn_keye'], 0, 6) . '***' . substr($post['mn_keye'], -4) . '](len=' . strlen($post['mn_keye']) . ')'
-        . " | username=[{$post['username']}]";
+    // DEBUG:打印请求参数摘要(仅当定义了 MNBT_DEBUG 且为真时附加,避免生产环境泄露敏感信息)
+    $dbg = '';
+    if (defined('MNBT_DEBUG') && MNBT_DEBUG) {
+        $dbg = "gn={$gn} | mn_bh=[{$post['mn_bh']}] | mn_key_len=" . strlen($post['mn_key'])
+            . " | mn_keye=[" . substr($post['mn_keye'], 0, 6) . '***' . substr($post['mn_keye'], -4) . '](len=' . strlen($post['mn_keye']) . ')'
+            . " | username=[{$post['username']}]";
+    }
+
+    // SSL 证书校验开关:默认关闭以兼容自签名证书环境;
+    // 需要开启时在魔方入口文件定义 define('MNBT_SSL_VERIFY', true) 即可
+    $ssl_verify = defined('MNBT_SSL_VERIFY') ? MNBT_SSL_VERIFY : false;
 
     $url = $api_url . '?gn=' . urlencode($gn);
 
@@ -169,8 +194,8 @@ function _mnbthost_api_call($params, $gn, $extra = [], $timeout = 30)
         CURLOPT_POSTFIELDS     => http_build_query($post),
         CURLOPT_TIMEOUT        => $timeout,
         CURLOPT_RETURNTRANSFER => true,
-        CURLOPT_SSL_VERIFYHOST => false,
-        CURLOPT_SSL_VERIFYPEER => false,
+        CURLOPT_SSL_VERIFYPEER => (bool)$ssl_verify,
+        CURLOPT_SSL_VERIFYHOST => $ssl_verify ? 2 : 0,
         CURLOPT_HTTPHEADER     => ['Content-Type: application/x-www-form-urlencoded; charset=UTF-8'],
     ]);
     $resp = curl_exec($ch);
@@ -187,8 +212,10 @@ function _mnbthost_api_call($params, $gn, $extra = [], $timeout = 30)
         return ['success' => false, 'code' => 0, 'msg' => '[mnbthost] 响应解析失败:' . substr($resp, 0, 200)];
     }
 
-    // 附加调试摘要到响应
-    $decoded['_debug'] = $dbg;
+    // 附加调试摘要到响应(仅 MNBT_DEBUG 开启时)
+    if ($dbg !== '') {
+        $decoded['_debug'] = $dbg;
+    }
     return $decoded;
 }
 
@@ -246,10 +273,7 @@ function mnbthost_CreateAccount($params)
         $password = substr(md5(uniqid(mt_rand(), true)), 0, 12);
     }
 
-    $dqtime = $params['nextduedate'] ?? '';
-    if (empty($dqtime) || $dqtime == '0000-00-00') {
-        $dqtime = '0';
-    }
+    $dqtime = _mnbthost_format_duedate($params['nextduedate'] ?? '');
 
     $co = $params['configoptions'] ?? [];
     $extra = [
@@ -290,40 +314,33 @@ function mnbthost_TerminateAccount($params)
 /** 续费 */
 function mnbthost_Renew($params)
 {
-    $dqtime = $params['nextduedate'] ?? '';
-    if (empty($dqtime) || $dqtime == '0000-00-00') {
-        $dqtime = '0';
-    }
+    $dqtime = _mnbthost_format_duedate($params['nextduedate'] ?? '');
     $r = _mnbthost_api_call($params, 'xf', ['setdate' => $dqtime]);
     return _mnbthost_return($r);
 }
 
-/** 升降级(更新空间/数据库/流量配额,仅传变更项) */
+/** 升降级(更新空间/数据库/流量配额,全量传三项配额,用现有配置值兜底) */
 function mnbthost_ChangePackage($params)
 {
-    $upgrade = $params['configoptions_upgrade'] ?? [];
     $co = $params['configoptions'] ?? [];
 
-    $extra = [];
-    if (isset($upgrade['webdx']))   $extra['websize'] = $co['webdx'];
-    if (isset($upgrade['sqldx']))   $extra['sqlsize'] = $co['sqldx'];
-    if (isset($upgrade['sizemax'])) $extra['ll'] = $co['sizemax'];
-
-    if (empty($extra)) {
-        // 无配额变更时仍调用一次确保同步(zjmode 需传全量,用现有值兜底)
-        $extra = [
-            'websize' => $co['webdx'] ?? 0,
-            'sqlsize' => $co['sqldx'] ?? 0,
-            'll'      => $co['sizemax'] ?? 0,
-        ];
-    }
+    // 后端 zjmode 按传参覆盖对应配额,为保持三项配额一致,这里全量传三项
+    $extra = [
+        'websize' => $co['webdx'] ?? 0,
+        'sqlsize' => $co['sqldx'] ?? 0,
+        'll'      => $co['sizemax'] ?? 0,
+    ];
     $r = _mnbthost_api_call($params, 'zjmode', $extra);
     return _mnbthost_return($r);
 }
 
-/** 重置密码(idcsmart 将新密码作为第二参数传入) */
+/** 重置密码(idcsmart 将新密码作为第二参数传入,部分版本仅放在 $params['password']) */
 function mnbthost_CrackPassword($params, $new_pass = '')
 {
+    // 优先取第二参数,为空则回退到 $params['password'](兼容魔方部分版本)
+    if (empty($new_pass)) {
+        $new_pass = $params['password'] ?? '';
+    }
     if (empty($new_pass)) return '缺少新密码';
     $r = _mnbthost_api_call($params, 'czmm', ['password' => $new_pass]);
     return _mnbthost_return($r);
@@ -439,11 +456,13 @@ function mnbthost_ClientAreaOutput($params, $key)
     return [
         'template' => 'templates/console.html',
         'vars'     => [
-            'status_text'  => $status_info['des'],
-            'status_class' => $status_class,
-            'console_url'  => $console_url,
-            'username'     => $params['domain'] ?? ($params['username'] ?? ''),
-            'password'     => $params['password'] ?? '',
+            'status_text'  => htmlspecialchars($status_info['des'] ?? '', ENT_QUOTES),
+            'status_class' => htmlspecialchars($status_class, ENT_QUOTES),
+            'console_url'  => htmlspecialchars($console_url ?? '', ENT_QUOTES),
+            'username'     => htmlspecialchars($params['domain'] ?? ($params['username'] ?? ''), ENT_QUOTES),
+            'password'     => htmlspecialchars($params['password'] ?? '', ENT_QUOTES),
+            // 密码默认展示掩码,点击"显示"后再展示明文
+            'password_mask' => !empty($params['password']) ? str_repeat('•', 8) : '',
             'quota'        => $quota,
             'quota_pct'    => $quota_pct,
         ],
diff --git a/mf_modules/servers/mnbthost/templates/console.html b/mf_modules/servers/mnbthost/templates/console.html
index ee1b7b0..4fe7791 100644
--- a/mf_modules/servers/mnbthost/templates/console.html
+++ b/mf_modules/servers/mnbthost/templates/console.html
@@ -46,7 +46,7 @@
                 </div>
             </div>
             {if condition="!empty($console_url)"}
-            <button class="copy-btn" onclick="hostCopy(this,'{$console_url}')" title="复制">📋</button>
+            <button class="copy-btn" data-copy="{$console_url}" onclick="hostCopy(this)" title="复制">📋</button>
             {/if}
         </div>
 
@@ -57,7 +57,7 @@
                 <div class="info-value"><code>{$username|default='—'}</code></div>
             </div>
             {if condition="!empty($username)"}
-            <button class="copy-btn" onclick="hostCopy(this,'{$username}')" title="复制">📋</button>
+            <button class="copy-btn" data-copy="{$username}" onclick="hostCopy(this)" title="复制">📋</button>
             {/if}
         </div>
 
@@ -65,10 +65,15 @@
             <div class="info-icon pwd">🔑</div>
             <div class="info-body">
                 <div class="info-label">登录密码</div>
-                <div class="info-value"><code>{$password|default='—'}</code></div>
+                <div class="info-value"><input type="hidden" id="hostPasswordPlain" value="{$password}">
+<code id="hostPassword" style="user-select:none">{$password_mask|default='—'}</code>
+                    {if condition="!empty($password)"}
+                    <a href="javascript:void(0)" onclick="hostTogglePwd(this)" style="margin-left:6px;font-size:12px;">显示</a>
+                    {/if}
+                </div>
             </div>
             {if condition="!empty($password)"}
-            <button class="copy-btn" onclick="hostCopy(this,'{$password}')" title="复制">📋</button>
+            <button class="copy-btn" data-copy="{$password}" onclick="hostCopy(this)" title="复制">📋</button>
             {/if}
         </div>
 
@@ -138,7 +143,9 @@
 </div>
 
 <script>
-function hostCopy(btn, text) {
+// 复制:从 data-copy 属性取值,避免将值拼进 JS 字符串导致注入
+function hostCopy(btn) {
+    var text = btn.getAttribute('data-copy') || '';
     var ta = document.createElement('textarea');
     ta.value = text;
     ta.style.position = 'fixed'; ta.style.left = '-9999px';
@@ -149,4 +156,19 @@ function hostCopy(btn, text) {
     btn.innerHTML = '✓';
     setTimeout(function(){ btn.classList.remove('copied'); btn.innerHTML = '📋'; }, 1500);
 }
+
+// 密码显示/隐藏切换
+function hostTogglePwd(link) {
+    var el = document.getElementById('hostPassword');
+    if (!el) return;
+    if (el.dataset.show !== '1') {
+        el.dataset.plain = el.textContent = document.getElementById('hostPasswordPlain').value;
+        el.dataset.show = '1';
+        link.textContent = '隐藏';
+    } else {
+        el.textContent = '••••••••';
+        el.dataset.show = '0';
+        link.textContent = '显示';
+    }
+}
 </script>