# LiteMall 安全审计报告 — 第二组路由

审计范围：config.js / exchange.js / group-bulk.js / locale.js / membership.js / migration.js / migration-portal.js / notification.js / pagebuilder.js / system.js / theme.js / upload.js

环境约定：数据库访问经 `utils/db`（参数化 better-sqlite3）；金额应整数分禁 float；统一 `{code,message,data}`。
挂载结论（来自 app.js）：全局 `router.use('/api/admin', adminAuth)` 已保护所有 `/api/admin/*`（除少数白名单），但 **migration-portal.js 挂载在 `/migration`（非 admin 前缀），/api/system、/api/config 等挂载在非 admin 前缀**。

---

## 文件:migration-portal.js
⚠️ 【高】**挂在公开前缀 `/migration`（非 admin 前缀），多个解析/转换接口无鉴权，可被滥用做 DoS/资源耗尽**
文件挂载于 `app.use('/migration', ...)`，**不在** `/api/admin` 之下。经核查：
- 写库接口 **`POST /api/import`（401 行）与 `POST /api/import-data`（365 行）已带 `authAdmin`** ✓（正确，未授权无法写入数据）
- 但以下接口**无 authAdmin、无 token 校验**：`POST /api/convert`（299 行）、`POST /api/adapters/convert`（325 行）、`POST /api/preview`（353 行）、`POST /api/detect`（283 行）、`POST /api/adapters/detect`（341 行）、`POST /api/formats`（380 行）、`POST /api/history`（391 行）。
攻击者可匿名向 `/api/convert`、`/api/preview`、`/api/adapters/convert` 提交超大 JSON/xlsx(base64)，触发服务端 JSON.parse / xlsx 解析 / 适配器转换等**高 CPU、高内存**操作（`/api/convert` 对 content 无大小限制，299-321 行）→ 未授权资源耗尽/DoS。`/api/token`（23 行）虽 authAdmin 生成迁移令牌，但上述公开接口均未校验 token。

修复：对 convert/preview/detect 等接口增加**登录/令牌校验**，并对 body 大小设限（如 express.json({ limit:'2mb' })）、对 content 长度做上限；门户 HTML（63 行）不应脱离鉴权随意触发服务端解析。（注：`authAdmin` 已在顶部 import，但只挂在 token/import/import-data 上。）

⚠️ 【中】**SSRF：用户可控 `imageBaseUrl` 触发后端任意外网/内网图片下载（admin-gated）**
`/api/import` 中 `req.body.imageBaseUrl`（422-426 行）送入 `imageMigrator.downloadProductImages()`（lib/migration/image-migrator.js:53-58 用 `http/https .get(url)`）。该模块**无 host 白名单/内网地址拦截，且 `rejectUnauthorized: false`（58 行，关闭 TLS 证书校验，可被 MITM）**。因该接口已 authAdmin，属 **admin 触发**（管理员若解析来路不明的迁移包/域名，可被引导让服务器请求内网地址或遭 MITM 注入图片）。修复：在 image-migrator 加 host 黑名单（复用 app.js `/proxy-img` 的 isPrivate 判断逻辑）并移除 `rejectUnauthorized:false`。

⚠️ 【中】**迁移令牌未在公开接口强制校验**
`verifyToken`（49-55 行）通过 `stats LIKE '%"token":"<token>"%'` 匹配（JSON 字符串 LIKE 模糊匹配，非精确查询），且 `POST /api/token`（23 行，authAdmin）生成的令牌在公开解析接口（convert/preview/detect）上**均未被校验**。建议将令牌独立存表（带过期时间），并对门户公开接口强制校验令牌。

⚠️ 【低】`GET /portal`（63 行）对外返回带自动导入逻辑的 HTML，其前端 JS 直接调用公开的 `/migration/api/convert`（无需登录即可触发服务端解析转换）与需 admin 的 `/migration/api/import`。建议门户页仅在令牌有效时可用，且避免将公开解析接口暴露为"一键搬家"入口。

---

## 文件:migration.js
✅ 所有路由均带 `authAdmin`，鉴权正确。
⚠️ 【中】**SSRF：`imageBaseUrl` / 商品图片 URL 用户可控，无内网拦截**
`POST /import`（147-170 行）与 `POST /images/download`（239-259 行）把 `req.body.imageBaseUrl` 和商品图片链接交给 `imageMigrator.downloadProductImages()`。image-migrator 无 SSRF 防护且 `rejectUnauthorized:false`。虽为 admin-only，但管理员若点击来路不明的迁移包/域名，仍可被引导访问内网或 MITM。修复同 migration-portal：加 host 黑名单、移除 `rejectUnauthorized:false`。

⚠️ 【中】**导入数据可信校验缺失**
`/import`（127-196 行）、`/upload`（286 行）、`/upload-xlsx`（407 行）接受任意 JSON/xlsx，`conflictStrategy=overwrite` 可覆盖线上商品/用户。作为 admin 功能本身可接受，但建议：overwrite 需二次确认 + 强制备份；并对导入的价格/库存/积分字段做整数分与 >=0 校验（lib/migration.js:296/362 直接 INSERT，未做类型/范围校验）。

✅ `GET /download/:filename`（72-85 行）有 `..`/`/` 过滤防路径穿越，正确。

---

## 文件:membership.js
⚠️ 【高】**付费会员购买无支付校验，可免费开通会员**
`POST /buy`（20-65 行）：仅校验 `isFeatureEnabled('paid_membership')`，然后**直接把订单置为 `pending_ship`（33 行）并立即**：写入 `user_memberships`（41 行，active）、加 experience、升 level（45 行）、送优惠券（49-59 行）。**没有任何支付平台回调/支付状态验证**，`payment_method` 只是用户传入的字符串。任何登录用户可反复调用 `/buy` 领取会员+经验+优惠券（无幂等、无消耗校验）。参考 order.js 直付守卫的注释（order.js:291），此类"直付即发"接口必须注意生产禁用并走真实支付回调。
修复建议（伪代码）：
```js
// 不再直接发权益，改为创建待支付订单，支付回调后再激活
const r = run(`INSERT INTO orders(order_no,user_id,total_amount,pay_amount,payment_method,status,remark)
  VALUES(?,?,?,?,?,?,?)`, [orderNo, req.userId, plan.price, plan.price, pm, 'pending_pay', `购买会员:${plan.name}`]);
return res.json({ code:0, message:'下单成功，请支付', data:{ order_no: orderNo, amount: plan.price }});
// 在支付回调(如 /api/pay/notify)校验该 order_no 的 pay 状态后，再执行下方插入 user_memberships/加经验/发券 逻辑
```

⚠️ 【低】`plan.price` 直接取库值存入 `orders.total_amount`，未用 money 工具转整数分；DB 若为浮点则有精度隐患。建议金额统一 `toNum`/分存储。

---

## 文件:group-bulk.js
⚠️ 【中/高】**预售/阶梯团购下单无支付校验，且不扣真实商品库存，存在超卖**
- `presaleRouter.post('/order')`（45-79 行）：创建订单直接置 `deposit_paid`（73 行），**未收定金即标记已付定金**。
- `presaleRouter.post('/order/:orderNo/pay-final')`（82-97 行）：`status==='deposit_paid'` 即可标记 `final_paid`，**无尾款支付校验**。
- `bulkGroupRouter.post('/order')`（146-192 行）：直接 `status:'paid'`（186 行）创建，**无支付流程**。
三个下单接口都只做 `total_stock`/`limit_count` 的 SUM 预检（53-58、158-165 行），**没有扣减 `products` 表真实库存、没有 CAS 乐观锁**，且下单本身不消费任何资金 → 可无限下单占库存/超卖；预售与团购是两个独立活动，库存互不感知。
建议：参照 order.js 的支付回调校验 + 库存 CAS（`UPDATE products SET stock=stock-? WHERE id=? AND stock>=?` 在事务内），未支付订单不得占用库存/不得置 paid 状态。

⚠️ 【中】**数量参数未校验，可传 0/负数/浮点**
三个下单接口的 `quantity = req.body.quantity || 1` 直接使用（49、150 行），未做 `Math.max(1,...)`/整数/上限钳制。`mul(pp.deposit, quantity)`（63 行）在负 quantity 下得到负数定金；0 数量也成立订单。参考 exchange.js:129 已有 `Math.max(1, Math.min(999, parseInt(...)))`。修复：
```js
const qty = Math.max(1, Math.min(999, Math.floor(Number(req.body.quantity) || 1)));
```

✅ 后台管理路由（adminPresale 各增删改查）均带 `authAdmin`，且经 `/api/admin` 全局鉴权，正确。
✅ 前端 "我的订单"（`/orders`，100/195 行）均以 `user_id=req.userId` 过滤，无越权。

---

## 文件:system.js
✅ `router.use(authAdmin)`（10 行）全局保护，正确。
⚠️ 【中】**备份恢复存在路径穿越风险（admin-only 任意文件覆盖 DB）**
`POST /backup/restore`（169-187 行）：`const src = path.join(backupDir, name)` 中 `name` 仅判 `existsSync`，**未过滤 `..` / `/` / 绝对路径**。传 `name = "../../../../etc/passwd"`（或任意可读文件）会被 copy 到 `config.dbPath`，再用 `fs.copyFileSync` 覆盖正在使用的数据库文件（180 行 close+copy），导致：任意文件被当作 DB / DB 被覆盖 / 服务损坏。修复：
```js
const plain = path.basename(name);                    // 去掉目录部分
if (name !== plain || !/^litemall_.+\.db$/.test(plain)) return res.json(fail('非法的备份文件名'));
const src = path.join(backupDir, plain);
```

⚠️ 【低】`PUT /config`（28-43 行）可改任意 `system_config` 值（含 payment/jwt/module/operate_mode 等安全开关）；`POST /admins`（64 行）允许任意 admin 创建新 admin（同级提权）。属"管理员即超管"的既有模型，managed admin 若被攻破即全权限。建议：敏感配置项（支付密钥、运行模式、安全开关）写入前限制 key 白名单，并对此类高危操作记录审计日志。

✅ 金额/分：backup 无金额操作；登录日志分页参数化（243-245 行），正确。
⚠️ 低：`GET /logs` 可读 server.log（192-207 行），admin-only，可接受；但日志可能含敏感信息，注意脱敏。

---

## 文件:notification.js
✅ 写操作均鉴权：`POST /send` authAdmin（24 行）、模板/日志查询 authAdmin（51/73 行）、用户订阅读写 authUser 且按 `req.userId` 过滤（101/109 行），无越权。
✅ 分页过滤均参数化（80-92 行），无 SQL 注入。
✅ 有功能门控（14-19 行）。
✅ 无金额、无文件、无命令执行。
结论：**本文件基本安全**。
（低）`POST /send` 允许 admin 向任意 `user_id` 发送任意模板并 `force:true` —— 属管理员能力，可接受；建议对批量/高频发送做限流防骚扰。

---

## 文件:locale.js
✅ `POST /switch` 带 `authAdmin`（58 行），正确；参数校验 locale 白名单（i18n.setLocale 返回 false）。
✅ 其余为公开只读，无写入、无 SQL 注入、无文件操作。
结论：**本文件无问题**。

---

## 文件:config.js
✅ 全部为公开只读 GET，无写操作、无注入、无状态。
⚠️ 【低】**信息泄露**：挂载于 `/api/config`（无鉴权），`GET /payment`（15-20 行）把 `system_config group_name='payment'` 的**全部支付配置（可能含商户号/密钥/回调地址）返回给匿名访客**。建议：payment 组中敏感字段（key/secret）脱敏或仅对 admin 返回；分享/基础组可保留公开。
⚠️ 低：`GET /config/public` 未做分组输入校验以外的过滤，同样注意不要纳入敏感 key。

---

## 文件:pagebuilder.js
✅ 写操作 `POST /layout`（20 行）带 authAdmin；`/layouts` 也 authAdmin；公开读正常。
⚠️ 【低】`GET /render/:terminal`（40-47 行）public 且 `terminal` 直接传 `renderTemplate(terminal)`，若该 lib 按 terminal 拼路径需确认无目录穿越（此处为参数化模板读取，风险低）；建议对 terminal 做白名单 `['mobile','pc','mini']`。

---

## 文件:theme.js
✅ 所有写接口均带 `authAdmin`：`PUT /save`、`PUT /custom`、`POST /custom`、`DELETE /custom/:id`、`PUT /apply`、`PUT /page-map`（160/177/288/304/327/393 行）。
✅ `theme_customs` 删除/应用均参数化。
⚠️ 【低】**存储型 CSS 注入**：`GET /css-vars`（194-228 行）把管理员保存的 `colors[*]` 值**未转义直接拼接进 CSS 字符串**返回 `text/css`。虽然 admin-only，且 content-type 为 css 降低了 XSS 概率，但含换行/`}`/`<` 的恶意值可破坏 CSS / 注入额外样式，且 `/css-vars` 是公开端读取，中毒主题会影响所有访客。建议对 val 做 `val.replace(/[{};\n<]/g,'')` 或白名单校验颜色格式。
⚠️ 低：`/current`、`/json`、`/page-theme/:pageKey` 公开读，均只反射数据库主题；`pageKey` 参数化查询，无注入。正常。

---

## 文件:upload.js
⚠️ 【中】**多图上传绕过 magic-bytes 校验**
`POST /image`（47-56 行）单图上传用 `isValidImageFile()`（真正的文件头 magic-bytes 校验，36-44 行）✓。
但 `POST /images`（59-63 行）多图上传**仅依赖 multer 的 `fileFilter`**（扩展名 + 客户端 `mimetype`），**没有逐文件校验文件内容 magic-bytes**。攻击者可把任意内容（如 HTML/脚本/可执行文件）改名 `.jpg` 并伪造 `Content-Type: image/jpeg` 上传成功，服务端存放于可被静态服务器访问的 `uploads/` 目录 → 存储型 XSS / 文件落地风险。修复：复用在 `/image` 已实现的校验：
```js
router.post('/images', authAdmin, upload.array('files', 10), (req, res) => {
  if (!req.files || !req.files.length) return res.json(fail('请选择图片'));
  for (const f of req.files) {
    if (!isValidImageFile(f)) { fs.unlinkSync(f.path); return res.json(fail('文件内容不是有效图片: ' + f.filename)); }
  }
  ...
});
```
⚠️ 低：`fileFilter` 的 `imgMime` 对空 mimetype 直接放行（30 行 `if (file.mimetype && ...)`），即客户端不传 Content-Type 也能过；但单图有 magic-bytes 兜底、多图没有，故受影响的是多图接口。建议统一要求 mimetype 且在服务端用 magic-bytes 校验后存储。
✅ 文件名由服务端随机生成（16 行），无用户可控路径/遍历；扩展名白名单。

---

## 文件:exchange.js
✅ 前后端通过 baseUrl 区分并由 `/api/admin/exchange` 全局 admin 鉴权兜底，写操作均带 authAdmin；前端下单 `POST /order`（126 行）authUser。
✅ **积分扣减与库存扣减用 CAS 乐观锁**（157/161 行 `WHERE ... AND experience>=?` / `AND stock>=?` 且 `changes===0` throw），事务包裹（155 行），可防超兑/超卖。参考 order.js 的加固写法，此处实现正确。
✅ 前端查询均按 `req.userId` 过滤（202/220/232 行），无越权/IDOR。
✅ 数量已钳制 `Math.max(1, Math.min(999,...))`（129 行）；限购计数与库存均参数化。
→ 结论：**本文件基本安全**。
（低）`PUT /products/:id`（84-96 行）更新时 `points||0` 允许把积分改写成 0（创建时强制 >0，更新存在绕过：`points=0` → 免费兑换）。建议更新也强制 `points>0` 校验。
（低）`price` 未校验类型/整数分（74/89 行存 `parseInt` 前的字符串到货币字段）。金额建议走 money 工具且整数分校验。

---

## 汇总（按严重度排序）
| 严重度 | 文件 | 问题 |
|---|---|---|
| 高 | migration-portal.js | 挂在公开前缀 `/migration`，/api/convert/preview/detect 无鉴权无限制，可未授权 DoS/资源耗尽（写库接口已 authAdmin） |
| 高 | membership.js | 付费会员买即发：无支付校验，免费开通会员/升等级/送券 |
| 中 | migration-portal.js | admin-gated SSRF：imageBaseUrl 无内网拦截 + `rejectUnauthorized:false`；迁移令牌未在公开接口强制校验 |
| 中/高 | group-bulk.js | 预售/团购下单无支付校验且不扣真实库存，可超卖 |
| 中 | group-bulk.js | quantity 未钳制，可 0/负数订单 + 负金额 |
| 中 | system.js | `/backup/restore` 路径穿越可覆盖 DB / 任意文件 |
| 中 | migration.js | admin 触发 SSRF + 导入数据无类型/范围校验（overwrite 覆盖） |
| 中 | upload.js | 多图 `/images` 绕过 magic-bytes 校验，可落地非图片文件 |
| 低 | config.js | `/payment` 匿名泄露支付配置 |
| 低 | theme.js | `/css-vars` 存储型 CSS 注入 |
| 低 | exchange.js | 商品更新 `points||0` 可改为 0（免费兑换） |
| 低 | pagebuilder.js | `/render/:terminal` terminal 未白名单 |

✅ 未发现 SQL 注入（全部参数化）、命令执行（无 child_process/exec/spawn/eval）、用户可控路径进 readFile（除 system.js backup 已标出）。
