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 一律写「远程服务器」。
7.3 KiB
7.3 KiB
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)
- 拒绝任何非普通文件条目:新增
findSpecialEntry()(symlink / 设备 / FIFO / socket), 在unzip解压后、scanDir与任何chown/chmod之前调用 → 400。 - 解压后规模上限:新增
sumUncompressedSize()(解析unzip -l表格区)MAX_SKILL_FILES = 2000、MAX_SKILL_UNCOMPRESSED_BYTES = 200MB→ 400。
- 纵深防御:
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 下)。