2026-09-20 02:44:51 +08:00
|
|
|
|
# 安全加固实测脚本(2026-09)
|
|
|
|
|
|
|
|
|
|
|
|
本目录是「存储型 XSS / 登录爆破 / 服务降权 / nginx 与 CORS 收敛」四项加固的**可复现实测脚本**与部署脚本留存。
|
|
|
|
|
|
所有脚本都在**服务器上**(或在本地直连测试库时)运行,改动前均已备份并验证。
|
|
|
|
|
|
|
|
|
|
|
|
| 文件 | 用途 | 运行位置 |
|
|
|
|
|
|
|------|------|----------|
|
|
|
|
|
|
| `probe_upload.py` | A 项:登录 → 上传 `xss.svg`(应 400)/ 改名 `.png` 的 SVG(应 400,魔数兜底)/ 真 PNG(应 200);头像与反馈图两个入口都测 | 服务器(打 `127.0.0.1:3000`) |
|
|
|
|
|
|
| `probe_bruteforce.py` | B 项:成功登录清零计数、5 次失败锁定(第 6 次与"正确密码"都返回统一文案)、challenge 限流(第 11 次起 429)、并发闸门观察 | 服务器(打 `127.0.0.1:3000`) |
|
|
|
|
|
|
| `probe_prod.py` | A/D 项(生产):用服务端 `JWT_SECRET` 临时签一个 token,只打"应被拒绝"的路径 + 反馈图测试文件(末尾自删),并打印 CORS 白名单内外行为 | 服务器(打 `127.0.0.1:9000` + 走域名) |
|
|
|
|
|
|
| `probe_export.py` | C 项:Excel 导出(写 `./exports`)在降权后是否可用(测试环境) | 服务器(打 `127.0.0.1:3000`) |
|
|
|
|
|
|
| `smoke_prod.py` | 生产冒烟:SPA 资源、challenge、登录参数校验,并清理测试产生的 Redis 计数键 | 服务器 |
|
2026-09-20 04:31:42 +08:00
|
|
|
|
| `make_test_user.py` | 在 `pms_dev` 建/重置实测账号 `13900000009 / SecTest#2026`(role=0,无组织) | 本地(经 SSH 隧道 `127.0.0.1:13306`,先跑 `pwsh tools/dev-tunnel.ps1`;⛔ 不再直连 `10.8.0.252`) |
|
2026-09-20 02:44:51 +08:00
|
|
|
|
| `nginx-hardening.sh` | D 项:把两份 conf 落到 `/etc/nginx/conf.d/`,在 `nginx.conf` 的 `http{}` 内插入 `limit_req_zone`,`nginx -t` 通过才 reload,失败回滚 | 服务器(先 `pscp` conf 到 `/tmp`) |
|
|
|
|
|
|
| `switch-user-test.sh` | C 项:测试环境两单元 `User=root → User=pms`,失败立即回滚 | 服务器 |
|
|
|
|
|
|
| `deploy-prod-sec.sh` | 生产:二进制原子替换 + 两单元降权,失败回滚(单元 + 二进制) | 服务器 |
|
|
|
|
|
|
|
2026-09-20 04:31:42 +08:00
|
|
|
|
## 退出码约定(2026-09-20 定稿,全仓统一)
|
|
|
|
|
|
|
|
|
|
|
|
| 退出码 | 含义 |
|
|
|
|
|
|
|--------|------|
|
|
|
|
|
|
| `0` | 全部判据通过 |
|
|
|
|
|
|
| `1` | **有判据失败**(失败项额外打到 stderr) |
|
|
|
|
|
|
| `2` | 环境/连接问题(读不到 `.env`、连不上 Redis/后端 —— 各脚本用 `raise SystemExit(...)` 报出) |
|
|
|
|
|
|
|
|
|
|
|
|
- 探针的判据与退出码统一由**同目录的 `verdict.py`** 提供(`Verdict.check(label, passed, detail)` + `exit_code()`)。
|
|
|
|
|
|
- ⛔ **"打印了 ❌ 却 exit 0"是假绿**:`probe_upload` / `probe_prod` / `probe_bruteforce` / `smoke_prod`
|
|
|
|
|
|
早期结尾写死 `return 0`,**无论安全控制是否生效都 exit 0**,与 `tsc --noEmit` 空跑同类。
|
|
|
|
|
|
已改为"失败 → exit 1",并按仓库铁律**实测过失败路径**:
|
|
|
|
|
|
把 `probe_upload.py` 的 A1 判据翻成 `st == 200` 后在服务器上跑 → **exit 1**;
|
|
|
|
|
|
恢复后 → **6/6 判据通过、exit 0**(`verdict.py` 本身另有 0/1/空判据 三种自测)。
|
|
|
|
|
|
- ⚠️ **拿不准阈值的现象不写成判据**(如 challenge 限流"第几次触发 429"、并发耗时)——只作观察输出,
|
|
|
|
|
|
否则会误报;误报与假绿同样有害。
|
|
|
|
|
|
- ✅ **C 项(降权后 Excel 导出落盘)结论:可用 —— 由落盘物证直接判定(2026-09-20)**
|
|
|
|
|
|
- 测试环境:`/data/pms_dev/api-server/exports/充电记录__-.xlsx`,**5559 B,属主 `pms:pms`**,
|
|
|
|
|
|
时间 04:29(与 `test/schema_alignment_e2e.py` 的成功导出同一时刻);
|
|
|
|
|
|
- 生产环境:同类产物 5457 B、属主同为 `pms:pms`;
|
|
|
|
|
|
- 两环境 `exports/` 与 `uploads/` 目录均为 `drwxr-xr-x pms pms`,两个 api-server 单元均为
|
|
|
|
|
|
`User=pms` / `Group=pms`(`systemctl show -p User -p Group` 实测)。
|
|
|
|
|
|
- ⇒ 降权成 `pms` 的进程**确实能往 `exports/` 落盘**(这正是 C 项要验证的事)。
|
|
|
|
|
|
- ⚠️ 上述结论**不依赖 `probe_export.py`**:该脚本因测试库唯一账号没有任何 `user_roles`(无
|
|
|
|
|
|
`charge_record:export`)仍返回 `exit 2`「未验证」(见下条)。想让它也给出判决,二选一补齐夹具。
|
|
|
|
|
|
- ⚠️ **`probe_export.py` 当前是"未验证"(exit 2),不是"失败"** —— 实测测试库 `pms_dev`
|
|
|
|
|
|
**只有 1 个账号(`13900000009`)且 `user_roles` 表为空**,即它**一个权限码都没有**:
|
|
|
|
|
|
创建导出任务恒 `403 缺少权限: charge_record:export`。该探针要回答的是
|
|
|
|
|
|
"**降权成 `pms` 后还能不能往 `exports/` 写文件**",而没权限根本走不到那一步 ——
|
|
|
|
|
|
早期把这种情形报成 `exit 1` 属**误报**(把夹具缺陷说成产品缺陷)。
|
|
|
|
|
|
现已改为 `exit 2`(环境/夹具问题,"未验证")。**补齐夹具后必须重跑**,二选一:
|
|
|
|
|
|
① 给该账号配含 `charge_record:export` 的角色;
|
|
|
|
|
|
② 用 `PMS_PROBE_PHONE` / `PMS_PROBE_PASSWORD` 环境变量给一个超管账号。
|
|
|
|
|
|
- 🐞 **`probe_export.py` 曾把任务状态语义搞反(已修,值得记住)**:
|
|
|
|
|
|
`download_tasks.status` 的**权威语义是 `0 排队 / 1 处理中 / 2 完成 / 3 失败`**
|
|
|
|
|
|
(`db/migrate.rs:225` 的列注释;写入侧 `routes/charge_records.rs::mark_task_completed` 写的就是 `2`)。
|
|
|
|
|
|
该脚本此前**等 `status == 1`(处理中)当成功、把 `status == 2`(完成)当"导出任务失败"** ——
|
|
|
|
|
|
即**导出成功也会被判失败**(假红)。已按权威语义订正,并与另外两处正确口径对齐:
|
|
|
|
|
|
`web/src/pages/charge-records/DownloadCenter.tsx::statusText`、
|
|
|
|
|
|
`test/schema_alignment_e2e.py`(等 `status in (2,3)` 再断言 `== 2`)。
|
|
|
|
|
|
> 教训:**状态码这种"看着像布尔"的字段,一定要回权威 DDL 的列注释核对,别照字面猜**;
|
|
|
|
|
|
> 三处读同一个字段就更要三处对齐(本次就是两处对一个错)。
|
|
|
|
|
|
|
2026-09-20 02:44:51 +08:00
|
|
|
|
## 前置说明
|
|
|
|
|
|
|
|
|
|
|
|
- 脚本内有 `ENV_FILE` 常量指向线上 `.env`(读 `REDIS_URL` / `JWT_SECRET`),**不要把 .env 内容复制到仓库**。
|
|
|
|
|
|
- `probe_upload.py` / `probe_bruteforce.py` 需要从 Redis 读 `challenge:{cid}` 的正确偏移量来模拟"人正确拖动滑块"(服务器没装 `redis-cli`,
|
|
|
|
|
|
脚本里自带一个 30 行的最小 RESP 客户端)。
|
|
|
|
|
|
- 实测账号 `13900000009 / SecTest#2026` 目前**仍留在测试库 `pms_dev`**(role=0,无组织,看不到任何数据)。
|
|
|
|
|
|
如不再需要:`DELETE FROM users WHERE phone='13900000009';`(同时可删除 `uploads/avatars`、`uploads/feedback` 下的测试 PNG)。
|