Files
dsh_shenxian/dsh-server-docs/04-调整方案/41-技能上传安全加固与用户技能启停.md
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

118 lines
7.3 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 41-技能上传安全加固与用户技能启停(2026-09-11 落地)
## 背景与动机
用户质疑:「**用户上传的在自己进程中运行不行吗,有什么风险呢**」。
复核结论:**用户对了一半,我上一轮把理由说错了。**
- ✅ **「运行」确实是自伤**:技能 `scripts/` 由 agent 经 bash 执行,跑在自己实例里 —— uid 隔离 +
bwrap + cgroup(512M/150%/128)+ 档案 39 的 `/etc` 白名单 + H5(实例不能主动连宿主/其他租户)。
跑崩、偷写、乱连,**最坏只弄坏自己那份** → **不该以「用户能跑任意代码」为禁止理由**。
- 🔴 **真风险在「上传/解压」** —— 那一步是**平台以 root 身份在跑**:不可信输入 × root 执行。
### P0(实测复现):zip symlink 成员穿透 → root 改写任意宿主文件属主/权限
- zip 成员**可以是符号链接**,`unzip` 默认**原样恢复**;symlink 目标写在 **zip 元数据**里,
**不在任何文件内容中** → `scanDir`(只读文件内容,且 `!st.isFile()` 直接 `continue`)**永远看不到**。
- 成员名校验(拒绝对路径 / `..`)**放行** symlink:实测 `skill-x/evil-link` 全部校验通过。
- 随后 `applyStaged` 以 **root** 执行 `chownTree`/`chmodTree`,二者**跟随**符号链接
→ 改写**链接目标**(宿主任意文件)的属主与权限。实测:`-rw------- root:root`
→ **`-rw-r--r-- <uid>`**。指向 `/etc/shadow` 即 = 密码哈希 chmod 644 全机可读 + chown 给该租户。
- **攻击面精确定位**:`mine` 路由 `ensureMineRoot(user.id)` → `applyStaged(..., root, owner)`
→ `owner !== undefined` → **一定执行 chown/chmod**;`shared`(admin)**不传 owner** → 不受影响。
即**每一名已登录用户**都能触发。
### P1:zip bomb → 跨租户 DoS
`UPLOAD_BODY_LIMIT = 180MB` 只限**压缩体**;无解压后大小/文件数上限;宿主 `quotaon /` 未启用
(档案 38a)= **无磁盘配额** → 220MB 解压规模实测仅 224KB 压缩体。
## 用户决策
| 决策点 | 用户选择 |
|---|---|
| 安全加固 | ✅ 执行(symlink 拒绝 + 解压上限 + `lchown`/跳过 symlink) |
| **是否保留用户自助上传** | ✅ **保留**(撤回我上一轮"去掉上传"的建议) |
| 用户自传技能的权限 | **可启用 / 可禁用 / 可删除** |
| admin 统一投放的技能 | **只在界面显示,不可禁用、不可删除** |
## 实现
### A. 安全加固(`src/web/routes/skills.ts`)
1. **拒绝任何非普通文件条目**:新增 `findSpecialEntry()`(symlink / 设备 / FIFO / socket),
在 `unzip` 解压后、`scanDir` 与任何 `chown/chmod` **之前**调用 → 400。
2. **解压后规模上限**:新增 `sumUncompressedSize()`(解析 `unzip -l` 表格区)
+ `MAX_SKILL_FILES = 2000`、`MAX_SKILL_UNCOMPRESSED_BYTES = 200MB` → 400。
3. **纵深防御**:`chownTree` 改 `lchownSync` 并**跳过 symlink**;`chmodTree` **跳过 symlink**
(Linux 无 `lchmod`,ENOSYS)。
### B. 用户技能「启用 / 禁用 / 删除」(dsh 无 disable 机制的绕法)
dsh 的 `discoverRoot` 只扫 `$DSH_HOME/skills` → 新增**库目录** `<userRoot>/home/skills-library/`
(在 `$DSH_HOME` 下、但**不在** `skills/` 内,故不被发现):
| 动作 | 实现 | 生效 |
|---|---|---|
| 启用 | `skills-library/<name>` → `$DSH_HOME/skills/<name>`(`rename`,同文件系统) | **watch 即时,无需重启** |
| 禁用 | `$DSH_HOME/skills/<name>` → `skills-library/<name>`(**保留文件**,可再启用) | 即时 |
| 删除 | 两处一起 `rm` | 即时 |
**API**(`src/web/routes/skills.ts`):
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | `/api/skills/mine` | `requireAuth` | 合并三类:共享(`source:'shared'`,`locked:true`)/ 用户已启用 / 用户已禁用 |
| POST | `/api/skills/mine` | `requireAuth` | 上传(新增共享同名守卫 → 409) |
| POST | `/api/skills/mine/:name/enable` | `requireAuth` | 库 → 启用位 |
| POST | `/api/skills/mine/:name/disable` | `requireAuth` | 启用位 → 库 |
| DELETE | `/api/skills/mine/:name` | `requireAuth` | 两处一起删 |
**共享技能守卫**:`sharedNames()`/`assertNotShared()` —— 与平台共享技能同名时,
上传 / 启用 / 禁用 / 删除**一律 409**(否则用户会白上传一份永远被 rank 600 压住、又关不掉的技能)。
所有动作写 `audit`(`enable_skill`/`disable_skill`/`delete_skill`)。
## 验证记录
### A. 安全加固(临时 admin 会话,R4 模板)
| 用例 | 结果 |
|---|---|
| symlink 包(`skill-sym/evil-link -> /etc/shadow`) | **HTTP 400** `技能包不允许包含符号链接/设备/管道文件:skill-sym/evil-link` |
| zip bomb(220MB 解压规模 / 224KB 压缩体) | **HTTP 400** `技能包解压后体积超限(221MB > 200MB)` |
| 正常包 | **HTTP 200** 安装成功(正常路径未受影响) |
| `GET /api/skills/mine` | `skill-demo \| source=shared \| enabled=true \| locked=true` |
### B. 用户侧启停(注册→approve→验证→DELETE 一次性用户,全程未碰真实账号)
| 步骤 | 结果 |
|---|---|
| 上传 | 200;落 `$DSH_HOME/skills/skill-mine`;`GET` → `source=user enabled=true locked=false` |
| 禁用 | 200;文件移至 `home/skills-library/skill-mine`;`GET` → `enabled=false` |
| 启用 | 200;文件回到 `skills/skill-mine` |
| 删除 | 200;`skills/` 与 `skills-library/` 均空 |
| admin 投放同名后用户上传 | **409** |
| 用户禁用 / 删除共享技能 | **409**(两条) |
| 清理 | 用户已删;剩余用户仅 `admin/guest`;共享层 0 项;临时会话已删 |
## 事故 / 踩坑记录
- ⚠️ **撤回我上一轮的一个错判**:我曾报「`scanDir` 只告警不拦截(注释与实现不一致)」——
**不成立**。`scanDir` 内部对 `BLOCK_PATTERNS`(P0)**会 throw 400**,调用点记录的只是 P1 findings。
当时只看了调用点没读被调函数。
- ⚠️ **symlink 攻击绕过了所有内容级扫描**:`BLOCK_PATTERNS` 里有 `/etc/(passwd|shadow|sudoers)`、
`/var/lib/dshs`、`.credentials.yaml` 等,但攻击载荷在 **zip 元数据**里,
文件内容里什么敏感串都没有 → 内容扫描天然看不见。**校验必须同时覆盖"元数据/文件类型"维度。**
- ⚠️ **Linux 无 `fs.lchmod`**(ENOSYS)→ 对 symlink 只能"跳过",不能"不跟随地改权限"。
- ⚠️ 追加一次实证:**给用户的"安全建议"必须区分「谁在执行」**。同样是"用户提供的代码",
跑在用户沙箱里 = 自伤(可接受);被平台以 root 处理 = 平台级风险(必须防)。
## 回滚 / 注意
- **回滚**:`cp src/web/routes/skills.ts.bak-<ts> src/web/routes/skills.ts && npm run build && systemctl restart dshs`
- **兼容性**:`GET /api/skills/mine` 响应由 `{skills:[SkillSummary]}` 变为**每项多了
`source`/`enabled`/`locked` 三个字段**(原字段全保留)→ 旧前端不会崩,但需更新以展示状态。
- **仍未做(UI)**:门户 `#/skills` 与实例「功能中心」尚未展示/操作这些字段与接口;
用户目前**无界面**可用,只有 API。→ 下一步(档案 42)。
- **仍未做(更彻底)**:`setpriv` 降权解压(让整条链路都不在 root 下)。