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:
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 下)。
|
||||
Reference in new issue
Block a user