exflower-license-server 1.4.3 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -135,16 +135,20 @@ docker run -d -p 4001:4001 \
135
135
  ```bash
136
136
  cd license-server
137
137
  npm install
138
- npm start
138
+ ADMIN_TOKEN=your-secure-token npm start
139
139
  ```
140
140
 
141
141
  ## 管理员 Token 管理
142
142
 
143
- ### 默认 Token
143
+ ### Token 必须显式配置(S5,2026-09-23 安全加固)
144
144
 
145
- 首次启动时,默认管理员 Token 为 **`exflower-admin-2026`**。
145
+ **未设置 `ADMIN_TOKEN`(或使用默认值/占位符)时服务直接拒绝启动。**
146
146
 
147
- **⚠️ 生产环境必须修改默认 Token。**
147
+ ```bash
148
+ [FATAL] 未设置有效的 ADMIN_TOKEN(默认值/占位符不允许启动)。
149
+ ```
150
+
151
+ 推荐用 `ADMIN_TOKEN_HASH`(sha256 hex)代替明文 `ADMIN_TOKEN`:`echo -n "你的token" | shasum -a 256`。
148
152
 
149
153
  ### 临时修改(当前会话)
150
154
 
@@ -218,7 +222,7 @@ sudo systemctl enable --now exflower-license-server
218
222
  | 变量 | 默认值 | 说明 |
219
223
  |------|--------|------|
220
224
  | `PORT` | `4001` | 服务端口 |
221
- | `ADMIN_TOKEN` | `exflower-admin-2026` | 管理后台鉴权 Token |
225
+ | `ADMIN_TOKEN` | **必填** | 管理后台鉴权 Token(默认值/占位符拒绝启动) |
222
226
  | `ADMIN_TOKEN_HASH` | — | 管理 Token 的 sha256(hex)。设置后忽略 `ADMIN_TOKEN`,明文不落环境变量(`echo -n "你的token" \| shasum -a 256` 生成) |
223
227
  | `LICENSE_DATA_DIR` | 见下方 | 数据库持久化目录 |
224
228
  | `NODE_ENV` | `production` | 运行环境 |
@@ -285,7 +289,7 @@ sudo systemctl enable --now exflower-license-server
285
289
  - 登录时真实校验 Token,错误 Token 无法进入后台
286
290
  - 管理接口限速 60 次/分/IP,license 验证接口 120 次/分/IP(超限 429)
287
291
  - Token 比较使用 timing-safe 方式;支持 `ADMIN_TOKEN_HASH` 哈希配置(明文不落环境变量)
288
- - 使用默认 Token 启动时打印醒目警告;非 localhost 的 HTTP 访问显示明文传输警示 banner
292
+ - 未设置有效 `ADMIN_TOKEN` 时拒绝启动(默认值/占位符一律视为未设置);非 localhost 的 HTTP 访问显示明文传输警示 banner
289
293
 
290
294
  ## 白标品牌名
291
295
 
package/admin/index.html CHANGED
@@ -475,6 +475,14 @@
475
475
  <script>
476
476
  let ADMIN_TOKEN = '';
477
477
 
478
+ // S1(2026-09-23 审查):动态值进 innerHTML 前一律 esc;onclick 只传服务端生成的
479
+ // id、展示名从缓存查——JS 字符串上下文 esc 救不了(双引号断属性、单引号断串)
480
+ function esc(s) {
481
+ return String(s ?? '')
482
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
483
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
484
+ }
485
+
478
486
  // 登录真校验:token 必须能通过一次真实 admin 请求,否则拒绝进入
479
487
  async function ensureLogin() {
480
488
  while (true) {
@@ -622,16 +630,16 @@
622
630
  const fmt = fmtTime;
623
631
  const rows = [
624
632
  ['状态', `<span class="badge ${cls}">${label}</span>`],
625
- ['客户', a.customer || '—'],
626
- ['Key 前缀', a.key_prefix ? `<code>${a.key_prefix}</code>` : '—'],
627
- ['服务器指纹', a.fingerprint ? `<code>${a.fingerprint.slice(0, 13)}…</code>` : '—'],
633
+ ['客户', esc(a.customer) || '—'],
634
+ ['Key 前缀', a.key_prefix ? `<code>${esc(a.key_prefix)}</code>` : '—'],
635
+ ['服务器指纹', a.fingerprint ? `<code>${esc(String(a.fingerprint).slice(0, 13))}…</code>` : '—'],
628
636
  ['合同到期', a.expires_at ? `${fmtDate(a.expires_at)}(剩 ${a.days_remaining} 天)` : '—'],
629
637
  ['凭证有效期至', fmt(a.token_expires_at)],
630
638
  ['实例配额(中央)', a.instance_quota ? `总额度 ${a.instance_quota.limit},已分配 ${a.instance_quota.used},剩余 ${a.instance_quota.remaining}` : ((a.limits && a.limits.max_instances) || '—')],
631
- ['主端点', `<code>${a.endpoints ? a.endpoints.primary : ''}</code>`],
632
- ['备端点', a.endpoints && a.endpoints.backup ? `<code>${a.endpoints.backup}</code>` : '—'],
639
+ ['主端点', `<code>${esc(a.endpoints ? a.endpoints.primary : '')}</code>`],
640
+ ['备端点', a.endpoints && a.endpoints.backup ? `<code>${esc(a.endpoints.backup)}</code>` : '—'],
633
641
  ['最近复核', fmt(a.last_revalidate)],
634
- ['最近错误', a.last_error ? `<span style="color:#c62828">${a.last_error}</span>` : '—'],
642
+ ['最近错误', a.last_error ? `<span style="color:#c62828">${esc(a.last_error)}</span>` : '—'],
635
643
  ];
636
644
  document.getElementById('act-status').innerHTML =
637
645
  '<table><tbody>' + rows.map(r => `<tr><th style="width:140px;text-align:left">${r[0]}</th><td>${r[1]}</td></tr>`).join('') + '</tbody></table>';
@@ -668,9 +676,11 @@
668
676
  }
669
677
 
670
678
  async function loadStats() {
671
- const res = await api('GET', '/health');
672
- document.getElementById('stat-companies').textContent = res.total_companies || 0;
673
- document.getElementById('stat-instances').textContent = res.active_instances || 0;
679
+ // S12(2026-09-23 审查):/health 不再对外回运营计数,统计改走 admin 实例接口
680
+ const res = await api('GET', '/api/admin/instances');
681
+ const st = res.stats || {};
682
+ document.getElementById('stat-companies').textContent = st.total_companies ?? 0;
683
+ document.getElementById('stat-instances').textContent = st.active_instances ?? 0;
674
684
  }
675
685
 
676
686
  // ── License 管理 ──
@@ -701,7 +711,7 @@
701
711
 
702
712
  let expiresHtml = '永久';
703
713
  if (c.expires_at) {
704
- const dateStr = String(c.expires_at).slice(0, 10); // 纯日期串(管理员录入),不做时区换算
714
+ const dateStr = esc(String(c.expires_at).slice(0, 10)); // 纯日期串(管理员录入),不做时区换算
705
715
  // 到期日按本地当日 23:59:59 算剩余天数,避免 UTC 午夜解析差一天
706
716
  const [ey, em, ed] = dateStr.split('-').map(Number);
707
717
  const daysLeft = Math.ceil((new Date(ey, em - 1, ed, 23, 59, 59) - Date.now()) / 86400000);
@@ -715,8 +725,8 @@
715
725
  }
716
726
 
717
727
  tr.innerHTML = `
718
- <td><strong>${c.name}</strong></td>
719
- <td><span class="license-key">${c.license_key}</span></td>
728
+ <td><strong>${esc(c.name)}</strong></td>
729
+ <td><span class="license-key">${esc(c.license_key)}</span></td>
720
730
  <td>${c.max_instances}实例 / ${c.max_agents}Agent / ${c.max_crews}项目组</td>
721
731
  <td><span class="badge ${finalClass}">${finalStatus}</span></td>
722
732
  <td>${expiresHtml}</td>
@@ -726,7 +736,7 @@
726
736
  <button class="btn-sm ${c.status === 'active' ? 'btn-plain' : ''}" onclick="toggleStatus('${c.id}', '${c.status}')">
727
737
  ${c.status === 'active' ? '暂停' : '恢复'}
728
738
  </button>
729
- <button class="btn-sm btn-plain" onclick="resetKey('${c.id}', '${c.name}')">重置 Key</button>
739
+ <button class="btn-sm btn-plain" onclick="resetKey('${c.id}')">重置 Key</button>
730
740
  <button class="btn-sm btn-danger" onclick="deleteCompany('${c.id}')">删除</button>
731
741
  </td>
732
742
  `;
@@ -746,10 +756,10 @@
746
756
  const hbColor = daysSince > 3 ? '#d32f2f' : daysSince > 1 ? '#ef6c00' : '#2e7d32';
747
757
 
748
758
  tr.innerHTML = `
749
- <td>${i.company_name}</td>
750
- <td><code>${i.instance_id}</code></td>
751
- <td>${i.hostname || '-'}</td>
752
- <td>${i.version || '-'}</td>
759
+ <td>${esc(i.company_name)}</td>
760
+ <td><code>${esc(i.instance_id)}</code></td>
761
+ <td>${esc(i.hostname) || '-'}</td>
762
+ <td>${esc(i.version) || '-'}</td>
753
763
  <td>${i.first_seen ? fmtDate(i.first_seen) : '-'}</td>
754
764
  <td style="color:${hbColor}">${i.last_heartbeat ? fmtTime(i.last_heartbeat) : '-'}</td>
755
765
  <td><button class="btn-sm btn-danger" onclick="deleteInstance('${i.id}')">删除</button></td>
@@ -801,7 +811,8 @@
801
811
  document.getElementById('key-modal').classList.add('show');
802
812
  }
803
813
 
804
- async function resetKey(id, name) {
814
+ async function resetKey(id) {
815
+ const name = companiesCache.find(x => x.id === id)?.name || id;
805
816
  if (!confirm(`确定重置「${name}」的激活码?\n\n旧激活码将立即失效,使用该激活码的客户端会失去授权,需要重新激活。`)) return;
806
817
  const res = await api('POST', `/api/admin/companies/${id}/reset-key`);
807
818
  if (res.success && res.license_key) {
@@ -975,13 +986,13 @@
975
986
  const c = categoriesCache[i];
976
987
  const tr = document.createElement('tr');
977
988
  tr.innerHTML = `
978
- <td><strong>${c.name}</strong></td>
989
+ <td><strong>${esc(c.name)}</strong></td>
979
990
  <td>${c.template_count}</td>
980
991
  <td>
981
992
  <button class="btn-sm btn-plain" ${i === 0 ? 'disabled' : ''} onclick="moveCategory('${c.id}', -1)" title="上移">↑</button>
982
993
  <button class="btn-sm btn-plain" ${i === categoriesCache.length - 1 ? 'disabled' : ''} onclick="moveCategory('${c.id}', 1)" title="下移">↓</button>
983
994
  <button class="btn-sm btn-plain" onclick="renameCategory('${c.id}')">改名</button>
984
- <button class="btn-sm btn-danger" onclick="deleteCategory('${c.id}', '${c.name.replace(/'/g, "\\'")}')">删除</button>
995
+ <button class="btn-sm btn-danger" onclick="deleteCategory('${c.id}')">删除</button>
985
996
  </td>
986
997
  `;
987
998
  tbody.appendChild(tr);
@@ -1025,7 +1036,8 @@
1025
1036
  renderCategoryList();
1026
1037
  }
1027
1038
 
1028
- async function deleteCategory(id, name) {
1039
+ async function deleteCategory(id) {
1040
+ const name = categoriesCache.find(c => c.id === id)?.name || id;
1029
1041
  if (!confirm(`确定删除分类「${name}」?挂在该分类下的模板将回落为未分类。`)) return;
1030
1042
  await api('DELETE', `/api/admin/template-categories/${id}`);
1031
1043
  toast('已删除');
@@ -1067,19 +1079,19 @@
1067
1079
  card.title = `创建于 ${t.created_at || '-'}`;
1068
1080
  card.innerHTML = `
1069
1081
  <div class="card-head">
1070
- <h3>${t.name}</h3>
1071
- <span class="ver">v${t.version || '1.0'}</span>
1082
+ <h3>${esc(t.name)}</h3>
1083
+ <span class="ver">v${esc(t.version || '1.0')}</span>
1072
1084
  </div>
1073
- <p class="desc">${t.description || ''}</p>
1085
+ <p class="desc">${esc(t.description)}</p>
1074
1086
  <div class="chips">
1075
- ${t.category ? `<span class="chip">${t.category}</span>` : ''}
1076
- ${companiesChip(t) ? `<span class="chip chip-neutral">${companiesChip(t)}</span>` : ''}
1087
+ ${t.category ? `<span class="chip">${esc(t.category)}</span>` : ''}
1088
+ ${companiesChip(t) ? `<span class="chip chip-neutral">${esc(companiesChip(t))}</span>` : ''}
1077
1089
  </div>
1078
1090
  <div class="card-foot">
1079
1091
  <span class="foot-meta">导入 ${importCount} 次</span>
1080
1092
  <div class="actions">
1081
1093
  <button class="act" onclick="editTemplate('${t.id}')">编辑</button>
1082
- <button class="act" onclick="showImportDetails('${t.id}', '${t.name.replace(/'/g, "\\'")}')">导入记录</button>
1094
+ <button class="act" onclick="showImportDetails('${t.id}')">导入记录</button>
1083
1095
  <button class="act act-danger" onclick="deleteTemplate('${t.id}')">删除</button>
1084
1096
  </div>
1085
1097
  </div>
@@ -1099,7 +1111,7 @@
1099
1111
  box.appendChild(all);
1100
1112
  for (const c of res.companies || []) {
1101
1113
  const label = document.createElement('label');
1102
- label.innerHTML = `<input type="checkbox" value="${c.id}" checked><span>${c.name}</span>`;
1114
+ label.innerHTML = `<input type="checkbox" value="${c.id}" checked><span>${esc(c.name)}</span>`;
1103
1115
  box.appendChild(label);
1104
1116
  }
1105
1117
  // 联动:全选 ↔ 单项
@@ -1440,7 +1452,7 @@
1440
1452
  loadTemplates();
1441
1453
  }
1442
1454
 
1443
- async function showImportDetails(templateId, templateName) {
1455
+ async function showImportDetails(templateId) {
1444
1456
  const res = await api('GET', `/api/admin/imports?template_id=${encodeURIComponent(templateId)}`);
1445
1457
  const tbody = document.getElementById('import-detail-body');
1446
1458
  tbody.innerHTML = '';
@@ -1452,16 +1464,16 @@
1452
1464
  for (const imp of imports) {
1453
1465
  const tr = document.createElement('tr');
1454
1466
  tr.innerHTML = `
1455
- <td>${imp.company_name || '-'}</td>
1456
- <td><code>${imp.instance_id || '-'}</code></td>
1457
- <td>${imp.template_name || '-'}</td>
1467
+ <td>${esc(imp.company_name) || '-'}</td>
1468
+ <td><code>${esc(imp.instance_id) || '-'}</code></td>
1469
+ <td>${esc(imp.template_name) || '-'}</td>
1458
1470
  <td>${imp.imported_at ? fmtTime(imp.imported_at) : '-'}</td>
1459
1471
  `;
1460
1472
  tbody.appendChild(tr);
1461
1473
  }
1462
1474
  }
1463
1475
 
1464
- document.getElementById('import-detail-title').textContent = `「${templateName}」导入记录 (${imports.length}次)`;
1476
+ document.getElementById('import-detail-title').textContent = `「${imports[0]?.template_name || templateId}」导入记录 (${imports.length}次)`;
1465
1477
  document.getElementById('import-detail-modal').classList.add('show');
1466
1478
  }
1467
1479
 
@@ -1493,15 +1505,16 @@
1493
1505
  return;
1494
1506
  }
1495
1507
  const platformLabel = { 'mac-arm64': 'macOS arm64', 'mac-x64': 'macOS Intel', 'win': 'Windows' };
1508
+ updatesCache = rows;
1496
1509
  body.innerHTML = rows.map(u => `
1497
1510
  <tr>
1498
- <td>${platformLabel[u.platform] || u.platform}</td>
1499
- <td>${u.version}</td>
1500
- <td style="font-family:monospace;font-size:12px;">${u.file_name}</td>
1511
+ <td>${esc(platformLabel[u.platform] || u.platform)}</td>
1512
+ <td>${esc(u.version)}</td>
1513
+ <td style="font-family:monospace;font-size:12px;">${esc(u.file_name)}</td>
1501
1514
  <td>${fmtPkgSize(u.file_size)}</td>
1502
1515
  <td>${u.is_latest ? '✅' : ''}</td>
1503
1516
  <td>${u.created_at ? fmtDate(u.created_at) : '-'}</td>
1504
- <td><button class="btn-sm btn-danger" onclick="deleteUpdate('${u.id}', '${u.version} (${u.platform})')">删除</button></td>
1517
+ <td><button class="btn-sm btn-danger" onclick="deleteUpdate('${u.id}')">删除</button></td>
1505
1518
  </tr>
1506
1519
  `).join('');
1507
1520
  }
@@ -1539,7 +1552,11 @@
1539
1552
  }
1540
1553
  }
1541
1554
 
1542
- async function deleteUpdate(id, label) {
1555
+ let updatesCache = [];
1556
+
1557
+ async function deleteUpdate(id) {
1558
+ const u = updatesCache.find(x => x.id === id);
1559
+ const label = u ? `${u.version} (${u.platform})` : id;
1543
1560
  if (!confirm(`确定删除更新包「${label}」?`)) return;
1544
1561
  await api('DELETE', `/api/admin/updates/${id}`);
1545
1562
  toast('已删除');
@@ -1574,22 +1591,22 @@
1574
1591
  return;
1575
1592
  }
1576
1593
  grid.innerHTML = pkgs.map(p => `
1577
- <div class="tpl-card" title="上传于 ${p.created_at || '-'}">
1594
+ <div class="tpl-card" title="上传于 ${esc(p.created_at) || '-'}">
1578
1595
  <div class="card-head">
1579
- <h3>${p.name}</h3>
1580
- <span class="ver">v${p.version || '1.0'}</span>
1596
+ <h3>${esc(p.name)}</h3>
1597
+ <span class="ver">v${esc(p.version || '1.0')}</span>
1581
1598
  </div>
1582
- <p class="desc">${p.description || ''}</p>
1599
+ <p class="desc">${esc(p.description)}</p>
1583
1600
  <div class="chips">
1584
- ${p.category ? `<span class="chip">${p.category}</span>` : ''}
1585
- ${companiesChip(p) ? `<span class="chip chip-neutral">${companiesChip(p)}</span>` : ''}
1601
+ ${p.category ? `<span class="chip">${esc(p.category)}</span>` : ''}
1602
+ ${companiesChip(p) ? `<span class="chip chip-neutral">${esc(companiesChip(p))}</span>` : ''}
1586
1603
  </div>
1587
1604
  <div class="card-foot">
1588
1605
  <span class="foot-meta">${fmtPkgSize(p.file_size)} · 导入 ${importCounts[p.id] || 0} 次</span>
1589
1606
  <div class="actions">
1590
1607
  <button class="act" onclick="editPackage('${p.id}')">编辑</button>
1591
- <button class="act" onclick="showImportDetails('${p.id}', '${p.name.replace(/'/g, "\\'")}')">导入记录</button>
1592
- <button class="act act-danger" onclick="deletePackage('${p.id}', '${p.name.replace(/'/g, "\\'")}')">删除</button>
1608
+ <button class="act" onclick="showImportDetails('${p.id}')">导入记录</button>
1609
+ <button class="act act-danger" onclick="deletePackage('${p.id}')">删除</button>
1593
1610
  </div>
1594
1611
  </div>
1595
1612
  </div>
@@ -1680,7 +1697,8 @@
1680
1697
  }
1681
1698
  }
1682
1699
 
1683
- async function deletePackage(id, name) {
1700
+ async function deletePackage(id) {
1701
+ const name = packagesCache.find(x => x.id === id)?.name || id;
1684
1702
  if (!confirm(`确定删除轻应用「${name}」?客户端将无法再下载。`)) return;
1685
1703
  await api('DELETE', `/api/admin/packages/${id}`);
1686
1704
  toast('已删除');
package/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * 环境变量:
7
7
  * PORT - 服务端口 (默认 4001)
8
- * ADMIN_TOKEN - 管理后台鉴权 Token (默认 exflower-admin-2026)
8
+ * ADMIN_TOKEN - 管理后台鉴权 Token(必设;默认值/占位符拒绝启动)
9
9
  * ADMIN_TOKEN_HASH - 管理 Token 的 sha256(hex),设置后忽略 ADMIN_TOKEN(不落明文)
10
10
  *
11
11
  * 启动:
@@ -31,9 +31,31 @@ const PORT = process.env.PORT || 4001;
31
31
  const DEFAULT_ADMIN_TOKEN = 'exflower-admin-2026';
32
32
  const ADMIN_TOKEN_HASH = (process.env.ADMIN_TOKEN_HASH || '').trim();
33
33
  const ADMIN_TOKEN = process.env.ADMIN_TOKEN || DEFAULT_ADMIN_TOKEN;
34
- const usingDefaultToken = !ADMIN_TOKEN_HASH && ADMIN_TOKEN === DEFAULT_ADMIN_TOKEN;
35
34
 
36
- app.use(cors());
35
+ // S2(2026-09-23 审查):限流键此前在反代后取到 caddy 的 IP,全体流量共享一个桶。
36
+ // trust proxy 1 = 只信第一跳反代(宿主 caddy)的 X-Forwarded-For;
37
+ // 无反代直连形态(AO 类)没有 XFF,req.ip 仍是 socket 地址,行为不变
38
+ app.set('trust proxy', 1);
39
+
40
+ // S5(2026-09-23 审查):默认/占位 token 都是仓库内公开字符串,等于无鉴权。
41
+ // 未配置强凭证直接拒绝启动(部署检查单:升级镜像前逐实例核实 env)
42
+ const PLACEHOLDER_TOKENS = [DEFAULT_ADMIN_TOKEN, 'change-me-to-a-secure-token'];
43
+ if (!ADMIN_TOKEN_HASH && (!ADMIN_TOKEN || PLACEHOLDER_TOKENS.includes(ADMIN_TOKEN))) {
44
+ console.error('[FATAL] 未设置有效的 ADMIN_TOKEN(默认值/占位符不允许启动)。');
45
+ console.error(' 请设置 ADMIN_TOKEN 或 ADMIN_TOKEN_HASH(sha256 hex)环境变量后重启。');
46
+ process.exit(1);
47
+ }
48
+
49
+ // S11(2026-09-23 审查):cors() = 反射任意 origin。消费方是 Electron 客户端
50
+ // (Node fetch,无 Origin 头)+ 同源 admin 页;只放行本机开发 origin,其余不给
51
+ // ACAO 头(浏览器侧读不到响应,非浏览器请求不受影响)
52
+ app.use(cors({
53
+ origin: (origin, cb) => {
54
+ if (!origin) return cb(null, true);
55
+ if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return cb(null, true);
56
+ cb(null, false);
57
+ }
58
+ }));
37
59
  app.use(express.json());
38
60
 
39
61
  // Admin dashboard (static HTML)
@@ -58,22 +80,14 @@ require('./activation').startScheduler();
58
80
  require('./updateSync').startUpdateSync();
59
81
 
60
82
  app.listen(PORT, () => {
83
+ // S6(2026-09-23 审查):横幅不再打明文 token(容器日志多方可见),只回掩码
61
84
  const tokenLine = ADMIN_TOKEN_HASH
62
85
  ? 'Admin Token: (hashed via ADMIN_TOKEN_HASH)'
63
- : `Admin Token: ${ADMIN_TOKEN}`;
86
+ : `Admin Token: ${ADMIN_TOKEN.slice(0, 3)}****`;
64
87
  console.log(`╔══════════════════════════════════════════════════╗`);
65
88
  console.log(`║ ExFlower License Server ║`);
66
89
  console.log(`║ Port: ${PORT.toString().padEnd(42)} ║`);
67
90
  console.log(`║ Admin Dashboard: http://localhost:${PORT}/admin ║`);
68
91
  console.log(`║ ${tokenLine.padEnd(48)} ║`);
69
92
  console.log(`╚══════════════════════════════════════════════════╝`);
70
- if (usingDefaultToken) {
71
- console.warn('');
72
- console.warn('╔══════════════════════════════════════════════════╗');
73
- console.warn('║ ⚠️ 警告:正在使用默认 ADMIN_TOKEN! ║');
74
- console.warn('║ 生产环境必须设置 ADMIN_TOKEN 或 ADMIN_TOKEN_HASH ║');
75
- console.warn('║ WARNING: default admin token in use! ║');
76
- console.warn('╚══════════════════════════════════════════════════╝');
77
- console.warn('');
78
- }
79
93
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exflower-license-server",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "description": "ExFlower License Server - 在线授权验证服务",
5
5
  "main": "index.js",
6
6
  "bin": {
package/routes.js CHANGED
@@ -13,6 +13,15 @@ const activation = require('./activation');
13
13
  const { ensureFresh } = require('./updateSync');
14
14
 
15
15
  const DEFAULT_ADMIN_TOKEN = 'exflower-admin-2026';
16
+
17
+ // S1(2026-09-23 审查):客户端上报的 hostname/version/instance_id 直接落库且
18
+ // admin 页 innerHTML 直出,恶意值即存储型 XSS(毒 hostname → admin 浏览器执行 →
19
+ // 窃 ADMIN_TOKEN → 传带毒更新包给全部客户)。入库白名单净化,非法整体置 '-'
20
+ const _SAFE_REPORT = /^[A-Za-z0-9._-]+$/;
21
+ function sanitizeReport(v, max = 64) {
22
+ const s = String(v == null ? '' : v).trim();
23
+ return (s && s.length <= max && _SAFE_REPORT.test(s)) ? s : '-';
24
+ }
16
25
  // ADMIN_TOKEN_HASH(sha256 hex)优先;否则用明文 ADMIN_TOKEN。二选一。
17
26
  const ADMIN_TOKEN_HASH = (process.env.ADMIN_TOKEN_HASH || '').trim().toLowerCase();
18
27
  const ADMIN_TOKEN = process.env.ADMIN_TOKEN || DEFAULT_ADMIN_TOKEN;
@@ -48,17 +57,27 @@ function rateLimit({ windowMs, max }) {
48
57
  }
49
58
 
50
59
  // x-license-key → company(客户端端点共用)
60
+ // S3(2026-09-23 审查):被暂停/已过期的激活码此前仍可拉模板、轻应用包和客户端
61
+ // 更新——补齐与 verify/heartbeat 相同的拒绝语义
51
62
  function companyByLicenseKey(req, res) {
52
63
  const licenseKey = req.headers['x-license-key'];
53
64
  if (!licenseKey) {
54
65
  res.status(401).json({ success: false, message: 'Missing x-license-key header' });
55
66
  return null;
56
67
  }
57
- const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
68
+ const company = queryGet('SELECT id, status, expires_at FROM companies WHERE license_key = ?', [licenseKey]);
58
69
  if (!company) {
59
70
  res.status(404).json({ success: false, message: 'Invalid license key' });
60
71
  return null;
61
72
  }
73
+ if (company.status === 'suspended') {
74
+ res.status(403).json({ success: false, message: 'License suspended' });
75
+ return null;
76
+ }
77
+ if (company.expires_at && new Date(company.expires_at) < new Date()) {
78
+ res.status(403).json({ success: false, message: 'License expired' });
79
+ return null;
80
+ }
62
81
  return company;
63
82
  }
64
83
 
@@ -113,6 +132,18 @@ async function readExfappManifest(filePath) {
113
132
  return JSON.parse(buf.toString('utf8'));
114
133
  }
115
134
 
135
+ // S9(2026-09-23 审查):上传包哈希此前 readFileSync 整读进内存——GB 级包在弱机
136
+ // OOM、>4GB 超 Buffer 上限直接 500。改流式分块哈希,算法/编码由调用方指定
137
+ function hashFile(filePath, algo, encoding) {
138
+ return new Promise((resolve, reject) => {
139
+ const hash = crypto.createHash(algo);
140
+ fs.createReadStream(filePath)
141
+ .on('data', (chunk) => hash.update(chunk))
142
+ .on('error', reject)
143
+ .on('end', () => resolve(hash.digest(encoding)));
144
+ });
145
+ }
146
+
116
147
  // 资料图谱模板上传(.exflib zip:manifest.json{format:'exflower-library'} + libraries/ + libraries-dbrows.json)
117
148
  const LIBTPL_DIR = path.join(DATA_DIR, 'library-templates');
118
149
  const libraryUpload = multer({
@@ -160,10 +191,12 @@ function buildLatestYml(mac) {
160
191
  // 多实例的关键防线);本地文件仅作无 remote_url 时的兜底(离线/气隙场景)。
161
192
  // 服务器仍后台拉包,本地副本是 CDN 不可达时的最终兜底(files/ 路由 302 已覆盖)。
162
193
  const fileUrl = (r) => {
163
- if (r.remote_url) return r.remote_url;
194
+ if (r.remote_url) return esc(r.remote_url);
164
195
  return `files/${r.platform}/${esc(r.file_name)}`;
165
196
  };
166
- const lines = [`version: ${rows[0].version}`, 'files:'];
197
+ // S14(2026-09-23 审查):version 原样裸插值——值里带换行/引号可注入 YAML 改写
198
+ // 客户端升级目标。统一引号包裹+单引号转义(入口另有 semver 校验,这里兜底存量行)
199
+ const lines = [`version: '${esc(rows[0].version)}'`, 'files:'];
167
200
  for (const r of rows) {
168
201
  lines.push(` - url: '${fileUrl(r)}'`);
169
202
  lines.push(` sha512: '${r.sha512}'`);
@@ -187,6 +220,10 @@ function setupRoutes(app) {
187
220
  // 防爆破限速:admin 接口与 license 验证接口按 IP 限速
188
221
  app.use('/api/admin', rateLimit({ windowMs: 60_000, max: 60 }));
189
222
  app.use('/api/license', rateLimit({ windowMs: 60_000, max: 120 }));
223
+ // S4(2026-09-23 审查):内容三棵树此前完全无限流——持 key 者可无限探测 id、
224
+ // 打满带宽。常规树 120/min(正常客户端低频),下载路由更严 30/min
225
+ app.use(['/api/templates', '/api/packages', '/api/updates'], rateLimit({ windowMs: 60_000, max: 120 }));
226
+ app.use(['/api/templates/:id/download', '/api/packages/:id/download', '/api/updates/files'], rateLimit({ windowMs: 60_000, max: 30 }));
190
227
 
191
228
  // ── 中央激活门禁:未激活/被顶替/过期 = 拒绝全部客户端服务(admin 端点不受影响) ──
192
229
  app.use(['/api/license', '/api/templates', '/api/packages', '/api/updates'], (req, res, next) => {
@@ -216,7 +253,9 @@ function setupRoutes(app) {
216
253
  }
217
254
  res.json({ success: true, activation: st });
218
255
  } catch (e) {
219
- res.status(500).json({ success: false, message: e.message });
256
+ // S13(2026-09-23 审查):500 不直出内部错误细节,统一文案,详情进日志
257
+ console.error('[LicenseServer] Activation configure failed:', e.message);
258
+ res.status(500).json({ success: false, message: '服务器内部错误' });
220
259
  }
221
260
  });
222
261
 
@@ -225,7 +264,8 @@ function setupRoutes(app) {
225
264
  const r = await activation.revalidateNow();
226
265
  res.json({ success: !!r.ok, message: r.ok ? '复核成功' : (r.message || r.error), activation: activation.getStatus() });
227
266
  } catch (e) {
228
- res.status(500).json({ success: false, message: e.message });
267
+ console.error('[LicenseServer] Activation retry failed:', e.message);
268
+ res.status(500).json({ success: false, message: '服务器内部错误' });
229
269
  }
230
270
  });
231
271
 
@@ -236,7 +276,11 @@ function setupRoutes(app) {
236
276
 
237
277
  // ── License Verification ──
238
278
  app.post('/api/license/verify', (req, res) => {
239
- const { license_key, instance_id, hostname, ip, version } = req.body;
279
+ const { license_key, ip } = req.body;
280
+ // S1:上报字段净化要放在查表/落库之前(instance_id 也是查键,先净化保证 upsert 两端一致)
281
+ const instance_id = sanitizeReport(req.body?.instance_id);
282
+ const hostname = sanitizeReport(req.body?.hostname, 128);
283
+ const version = sanitizeReport(req.body?.version, 32);
240
284
 
241
285
  if (!license_key || !instance_id) {
242
286
  return res.status(400).json({
@@ -523,7 +567,13 @@ function setupRoutes(app) {
523
567
  JOIN companies c ON i.company_id = c.id
524
568
  ORDER BY i.last_heartbeat DESC
525
569
  `);
526
- res.json({ success: true, instances });
570
+ // S12(2026-09-23 审查):原 /health 无认证公开的运营计数挪到这里(admin 鉴权后)
571
+ const stats = queryGet(`
572
+ SELECT
573
+ (SELECT COUNT(*) FROM companies) as total_companies,
574
+ (SELECT COUNT(*) FROM instances WHERE status = 'active' AND last_heartbeat > datetime('now', '-7 days')) as active_instances
575
+ `);
576
+ res.json({ success: true, instances, stats });
527
577
  });
528
578
 
529
579
  app.delete('/api/admin/instances/:id', requireAdmin, (req, res) => {
@@ -559,15 +609,8 @@ function setupRoutes(app) {
559
609
  const setPackageCompanies = (id, ids) => setShareCompanies('package_companies', 'package_id', id, ids);
560
610
 
561
611
  app.get('/api/templates', (req, res) => {
562
- const licenseKey = req.headers['x-license-key'];
563
- if (!licenseKey) {
564
- return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
565
- }
566
-
567
- const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
568
- if (!company) {
569
- return res.status(404).json({ success: false, message: 'Invalid license key' });
570
- }
612
+ const company = companyByLicenseKey(req, res);
613
+ if (!company) return;
571
614
 
572
615
  const { type } = req.query;
573
616
  let sql = `
@@ -593,14 +636,8 @@ function setupRoutes(app) {
593
636
 
594
637
  // Import summary (must come before /:id to avoid matching 'import-summary' as an id)
595
638
  app.get('/api/templates/import-summary', (req, res) => {
596
- const licenseKey = req.headers['x-license-key'];
597
- if (!licenseKey) {
598
- return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
599
- }
600
- const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
601
- if (!company) {
602
- return res.status(404).json({ success: false, message: 'Invalid license key' });
603
- }
639
+ const company = companyByLicenseKey(req, res);
640
+ if (!company) return;
604
641
 
605
642
  // 按设备过滤:导入状态跟着设备(instance_id)走,而非激活码;
606
643
  // 未传 instance_id 的旧版客户端保持 company 级聚合(向后兼容)
@@ -623,15 +660,8 @@ function setupRoutes(app) {
623
660
 
624
661
  // Get a single template (full content)
625
662
  app.get('/api/templates/:id', (req, res) => {
626
- const licenseKey = req.headers['x-license-key'];
627
- if (!licenseKey) {
628
- return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
629
- }
630
-
631
- const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
632
- if (!company) {
633
- return res.status(404).json({ success: false, message: 'Invalid license key' });
634
- }
663
+ const company = companyByLicenseKey(req, res);
664
+ if (!company) return;
635
665
 
636
666
  const template = queryGet(
637
667
  `SELECT * FROM templates WHERE id = ? AND ${TEMPLATE_VISIBLE}`,
@@ -981,7 +1011,7 @@ function setupRoutes(app) {
981
1011
  fs.mkdirSync(LIBTPL_DIR, { recursive: true });
982
1012
  fs.renameSync(req.file.path, dest);
983
1013
 
984
- const sha256 = crypto.createHash('sha256').update(fs.readFileSync(dest)).digest('hex');
1014
+ const sha256 = await hashFile(dest, 'sha256', 'hex');
985
1015
  const name = (req.body?.name || '').trim() || manifest.library?.name || '未命名图谱';
986
1016
  queryRun(
987
1017
  `INSERT INTO templates (id, company_id, type, name, description, content_json, category, file_path, version, created_at, updated_at)
@@ -1008,7 +1038,8 @@ function setupRoutes(app) {
1008
1038
  res.json({ success: true, template: { id, name } });
1009
1039
  } catch (err) {
1010
1040
  cleanup();
1011
- res.status(500).json({ success: false, message: err.message });
1041
+ console.error('[LicenseServer] Library upload failed:', err.message);
1042
+ res.status(500).json({ success: false, message: '服务器内部错误' });
1012
1043
  }
1013
1044
  });
1014
1045
 
@@ -1087,22 +1118,30 @@ function setupRoutes(app) {
1087
1118
  }
1088
1119
  res.json({ success: true, template: { id, name, assets: assets.length } });
1089
1120
  } catch (err) {
1090
- res.status(500).json({ success: false, message: err.message });
1121
+ console.error('[LicenseServer] Doc upload failed:', err.message);
1122
+ res.status(500).json({ success: false, message: '服务器内部错误' });
1091
1123
  }
1092
1124
  });
1093
1125
 
1094
1126
  // ── Template Import Log (client-facing) ──
1095
1127
  app.post('/api/templates/:id/import-log', (req, res) => {
1096
- const licenseKey = req.headers['x-license-key'];
1097
- if (!licenseKey) {
1098
- return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
1099
- }
1100
- const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
1101
- if (!company) {
1102
- return res.status(404).json({ success: false, message: 'Invalid license key' });
1103
- }
1128
+ const company = companyByLicenseKey(req, res);
1129
+ if (!company) return;
1104
1130
 
1105
1131
  const { instance_id, template_name, template_type } = req.body;
1132
+ // S8(2026-09-23 审查):任意 id 可无限刷导入记录(伪造统计 + 表膨胀),
1133
+ // 只允许记录该公司可见的模板或轻应用包 id
1134
+ const tpl = queryGet(
1135
+ `SELECT id FROM templates WHERE id = ? AND ${TEMPLATE_VISIBLE}`,
1136
+ [req.params.id, company.id, company.id]
1137
+ );
1138
+ const pkg = tpl ? null : queryGet(
1139
+ `SELECT id FROM app_packages WHERE id = ? AND ${PACKAGE_VISIBLE}`,
1140
+ [req.params.id, company.id, company.id]
1141
+ );
1142
+ if (!tpl && !pkg) {
1143
+ return res.status(404).json({ success: false, message: 'Template not found' });
1144
+ }
1106
1145
  const id = generateId();
1107
1146
  queryRun(
1108
1147
  `INSERT INTO template_imports (id, template_id, company_id, instance_id, template_name, template_type)
@@ -1251,7 +1290,7 @@ function setupRoutes(app) {
1251
1290
  req.file = null; // 已落位,禁止 cleanup
1252
1291
 
1253
1292
  const stat = fs.statSync(finalPath);
1254
- const sha256 = crypto.createHash('sha256').update(fs.readFileSync(finalPath)).digest('hex');
1293
+ const sha256 = await hashFile(finalPath, 'sha256', 'hex');
1255
1294
  const finalName = (name || '').trim() || manifest.app?.title || id;
1256
1295
 
1257
1296
  queryRun(
@@ -1271,7 +1310,7 @@ function setupRoutes(app) {
1271
1310
  } catch (err) {
1272
1311
  cleanup();
1273
1312
  console.error('[LicenseServer] Package upload failed:', err.message);
1274
- res.status(500).json({ success: false, message: err.message });
1313
+ res.status(500).json({ success: false, message: '服务器内部错误' });
1275
1314
  }
1276
1315
  });
1277
1316
 
@@ -1360,7 +1399,7 @@ function setupRoutes(app) {
1360
1399
  res.json({ success: true, updates });
1361
1400
  });
1362
1401
 
1363
- app.post('/api/admin/updates', requireAdmin, updateUpload.single('file'), (req, res) => {
1402
+ app.post('/api/admin/updates', requireAdmin, updateUpload.single('file'), async (req, res) => {
1364
1403
  const cleanup = () => { if (req.file) fs.unlink(req.file.path, () => {}); };
1365
1404
  try {
1366
1405
  if (!req.file) return res.status(400).json({ success: false, message: 'file is required' });
@@ -1373,6 +1412,12 @@ function setupRoutes(app) {
1373
1412
  cleanup();
1374
1413
  return res.status(400).json({ success: false, message: 'version is required' });
1375
1414
  }
1415
+ // S14(2026-09-23 审查):version 会插值进 latest.yml,非法值 = YAML 注入面;
1416
+ // 入口限 semver(buildLatestYml 侧还有引号+转义双保险)
1417
+ if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) {
1418
+ cleanup();
1419
+ return res.status(400).json({ success: false, message: 'version must be semver, e.g. 2.1.8' });
1420
+ }
1376
1421
  // 文件名净化:空格等 URL 不友好字符替换(yml 里按此名寻址)
1377
1422
  const safeName = (req.file.originalname || `${platform}-${version}`).replace(/[^\w.()-]+/g, '-');
1378
1423
 
@@ -1384,7 +1429,7 @@ function setupRoutes(app) {
1384
1429
  req.file = null;
1385
1430
 
1386
1431
  const stat = fs.statSync(finalPath);
1387
- const sha512 = crypto.createHash('sha512').update(fs.readFileSync(finalPath)).digest('base64');
1432
+ const sha512 = await hashFile(finalPath, 'sha512', 'base64');
1388
1433
 
1389
1434
  const isLatest = set_latest !== '0' && set_latest !== 'false';
1390
1435
  if (isLatest) {
@@ -1399,7 +1444,7 @@ function setupRoutes(app) {
1399
1444
  } catch (err) {
1400
1445
  cleanup();
1401
1446
  console.error('[LicenseServer] Update upload failed:', err.message);
1402
- res.status(500).json({ success: false, message: err.message });
1447
+ res.status(500).json({ success: false, message: '服务器内部错误' });
1403
1448
  }
1404
1449
  });
1405
1450
 
@@ -1412,13 +1457,10 @@ function setupRoutes(app) {
1412
1457
  });
1413
1458
 
1414
1459
  // ── Health ──
1460
+ // S12(2026-09-23 审查):/health 无认证,total_companies/active_instances 属运营
1461
+ // 数据不再对外(计数在 /api/admin/instances);compose healthcheck 只看状态码
1415
1462
  app.get('/health', (req, res) => {
1416
- const stats = queryGet(`
1417
- SELECT
1418
- (SELECT COUNT(*) FROM companies) as total_companies,
1419
- (SELECT COUNT(*) FROM instances WHERE status = 'active' AND last_heartbeat > datetime('now', '-7 days')) as active_instances
1420
- `);
1421
- res.json({ status: 'ok', ...stats });
1463
+ res.json({ status: 'ok' });
1422
1464
  });
1423
1465
  }
1424
1466