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 一律写「远程服务器」。
This commit is contained in:
admin committed 2026-09-15 18:47:13 +08:00
1 parent c70d5d860e
commit 5ad755116e
173 files changed
+27632

No files matched your search

@@ -0,0 +1,117 @@
# 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 下)。