# 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-- `**。指向 `/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` → 新增**库目录** `/home/skills-library/` (在 `$DSH_HOME` 下、但**不在** `skills/` 内,故不被发现): | 动作 | 实现 | 生效 | |---|---|---| | 启用 | `skills-library/` → `$DSH_HOME/skills/`(`rename`,同文件系统) | **watch 即时,无需重启** | | 禁用 | `$DSH_HOME/skills/` → `skills-library/`(**保留文件**,可再启用) | 即时 | | 删除 | 两处一起 `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- 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 下)。