Files
dsh_shenxian/dsh-server-docs/04-调整方案/41-技能上传安全加固与用户技能启停.md
T
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

7.3 KiB
Raw Blame History

41-技能上传安全加固与用户技能启停(2026-09-11 落地)

背景与动机

用户质疑:「用户上传的在自己进程中运行不行吗,有什么风险呢」。

复核结论:用户对了一半,我上一轮把理由说错了。

  • ✅ 「运行」确实是自伤:技能 scripts/ 由 agent 经 bash 执行,跑在自己实例里 —— uid 隔离 + bwrap + cgroup(512M/150%/128)+ 档案 39 的 /etc 白名单 + H5(实例不能主动连宿主/其他租户)。 跑崩、偷写、乱连,最坏只弄坏自己那份 → 不该以「用户能跑任意代码」为禁止理由。
  • 🔴 真风险在「上传/解压」 —— 那一步是平台以 root 身份在跑:不可信输入 × 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 下)。