# 安全加固实测脚本(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 计数键 | 服务器 | | `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`) | | `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 定稿,全仓统一) | 退出码 | 含义 | |--------|------| | `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 的列注释核对,别照字面猜**; > 三处读同一个字段就更要三处对齐(本次就是两处对一个错)。 ## 前置说明 - 脚本内有 `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)。