多用户与数据隔离机制

August 25, 2026 · View on GitHub

本文档说明 dsh-server-deployment 网关如何为每个用户提供独立的 DSH 实例OS 级数据隔离,以及文件访问助手的安全模型(含 issue #1 TOCTOU 竞态的修复方案)。

1. 威胁模型

网关部署在远程服务器上,多个用户通过浏览器(公网域名 + HTTPS 反向代理)访问各自的会话与交付文件。需要防范:

  • 用户间越权:用户 A 读取 / 修改用户 B 的会话、文件或 API Key。
  • 文件逃逸:用户通过下载 / 上传接口访问 /opt/deepseek-harness 之外的任意路径(如 /etc/passwd、其他用户的目录)。
  • 网关沦陷放大:一旦网关进程被攻破,攻击者不应直接获得任意用户的文件读写能力。
  • 竞态(TOCTOU):root 助手在“校验参数”与“实际读写”之间被并发替换符号链接 / 路径,导致越界访问。

核心原则:隔离以 OS 账号为边界,而非以网关代码为边界。网关对用户数据零权限,所有文件操作经由 sudo 助手以目标用户身份执行。

2. 隔离层次

浏览器 ──https──▶ TLS 反代 ──▶ dsh-gateway ──▶ 每用户 DSH 实例 dsh-<name>

                                    └─ 文件操作经 sudo 助手降权为 dsh-<name> 执行
层次机制说明
网络边界仅监听 127.0.0.1网关与所有实例不暴露公网端口,公网只走 TLS 反代
回环隔离iptables DSH_LOOPBACK_GUARD每个租户 OS 账号只能连自己的实例端口;跨租户端口与网关端口被 REJECT(防伪造 Host 直连他人实例窃取 Key)
实例隔离每用户独立 DSH 实例、独立端口userctl.js add <name> 自动分配端口并启动实例
账号隔离每用户独立 OS 账号 dsh-<name>DSH_HOME 指向该用户的 0700 私有目录
资源限制systemd TasksMax / MemoryMax / CPUQuota单个租户的失控 agent 不至于拖垮整机与其他租户
凭据隔离.credentials.yaml 0600 属主仅本人API Key 经回环 RPC 写入用户私有目录,DSH 启动时强制检查 assertOwnerOnly
会话隔离HMAC 签名会话 Cookie(内嵌 pwdVer)+ 每用户实例绑定Cookie 防篡改;改密后 pwdVer 递增,旧令牌全部失效
文件访问sudo 助手 + runuser 降权root 仅校验参数,文件操作以 dsh-<name> 自身身份执行(见下)

3. 文件访问助手的安全模型(issue #1 TOCTOU 修复)

历史方案中 root 助手被授予“校验 + 读写”双重职责:root 先做 realpath 前缀校验,若通过则以 root 身份读写。这存在 TOCTOU 竞态——校验与读写之间的路径可被并发替换(如符号链接指向越界路径)。

当前方案(自 2026-08 起)

网关 ──sudo──▶ root 助手(仅做参数字符串校验)──runuser -u dsh-<name>──▶ 以用户身份执行实际文件操作
  • bin/dsh-file-{list,stat,read,put} 在 root 下仅校验参数形态dsh-?* 账号命名、禁止非法字符),然后 runuser -u dsh-<name> -- <实际操作>
  • 实际读写以目标用户自身身份执行,权限边界由 OS 强制:该用户无法读取他人 0700 目录,也无法访问 root 拥有的文件。
  • 助手内保留 realpath 前缀校验,但其作用仅剩保持退出码语义(如越界拒绝 exit=3),不再是安全边界。
  • 依赖 util-linux 的 runuser

配套约束:

  • bin/ 目录必须保持 root:root,否则服务账号可替换助手脚本提权。
  • sudoers 采用固定路径白名单(如 /etc/sudoers.d/dsh-upload),只放行 dsh-file-putdsh-file-statdsh-file-readdsh-file-list 四个助手,禁止 shell 通配。
  • 网关对用户目录零权限:会话、历史、Key、文件都只能经由助手与实例访问。

4. 回环租户隔离(2026-08 新增)

DSH 实例把「Host 头是 127.0.0.1」当作特权请求的信任凭证(网关自己也是这么用的),但所有实例都监听在同一个 127.0.0.1 上,且每个租户的 agent 本就能执行任意 shell 命令——文件系统隔离拦不住回环 HTTP。没有回环防火墙时,租户 A 可以:

curl -H 'Host: 127.0.0.1:3102' http://127.0.0.1:3102/api/credentials.set ...

直接以「回环客户端」身份读写租户 B 实例的凭据、设置与会话。

防护(bin/dsh-loopback-guard,经 units/dsh-loopback-guard.service 开机应用、userctl 增删用户时即时刷新):

iptables OUTPUT 链 DSH_LOOPBACK_GUARD:
  ACCEPT  lo tcp uid=dsh-<name> dport=<自己端口>
  REJECT  lo tcp uid=dsh-<name> dport=<其他租户端口>   (逐目标端口,非按 uid 全量)
  REJECT  lo tcp uid=dsh-<name> dport=<网关端口>
  RETURN

要点:

  • REJECT 必须按目标端口逐条下规则。按 uid 全量 REJECT 会把内核代表该用户实例发出的回包(sport=自己端口)一并掐断,表现为所有租户连接静默超时。
  • 规则刷新经 iptables-restore --noflush 一次性提交(声明链即在单次 commit 内清空并重建),刷新期间不存在「旧规则已清、新规则未上」的防护空窗。
  • 网关端口解析顺序:GW_PORT 环境变量 > 网关启动时写入的 gateway/state-port.json > 默认 3100。PORT 被覆盖时无需手动同步 GW_PORT(前提:网关至少已成功启动过一次)。
  • root、网关服务账号与普通用户不受影响。
  • 验证:runuser -u dsh-admin -- curl http://127.0.0.1:3102/ 应被拒(tcp-reset),连 3101 正常。

5. 凭据与密钥

  • 口令以 scrypt 哈希存储(兼容旧版 APR1),写入 users.json(原子写:临时文件 + rename)。
  • 会话令牌为无状态 HMAC 签名,无服务端撤销名单/logout 只清除浏览器 cookie,已签发的令牌在 TTL(默认 12 小时)内仍然有效。令牌一旦泄露,唯一的全量失效手段是改密(pwdVer 递增使该用户所有旧令牌立即失效)。对安全敏感的部署建议缩短 SESSION_TTL 或对敏感操作引入二次确认。
  • users.json 的并发写入(网关写 keyConfigured 与 userctl 增删用户)经 gateway/store.js 的 mtime 复检 + 重放控制,不再可能互相覆盖。
  • API Key 仅存在于该用户私有目录的 .credentials.yaml(0600),网关不落库、不代理。
  • 所有运行状态与密钥文件(secretusers.jsonstate-cwd.jsonstate-port.json.credentials.yaml.local-run/)均在 .gitignore 中,严禁提交

6. 验证清单

在服务器上以 <服务账号> 执行(把 <user> 换成真实用户名):

H=/opt/deepseek-harness/users/<user>

# 目录列表
sudo -n /opt/deepseek-harness/bin/dsh-file-list "$H" ''

# 写入 / 读取 / 统计(v2 长度协议:首行 "BYTES <n>" + 精确 n 字节;短流 exit=6 不落盘)
printf 'BYTES 6\nhello\n' | sudo -n /opt/deepseek-harness/bin/dsh-file-put "$H" "$H/workspace" t.txt
sudo -n /opt/deepseek-harness/bin/dsh-file-read  "$H" "$H/workspace/t.txt"   # 输出 hello
sudo -n /opt/deepseek-harness/bin/dsh-file-stat  "$H" "$H/workspace/t.txt"   # 输出 6

# 越界拒绝(应 exit=3)
sudo -n /opt/deepseek-harness/bin/dsh-file-read  "$H" /etc/passwd; echo "exit=$?"

# 降权生效:子进程应为 dsh-<user> 而非 root
ps -ef | grep -E 'runuser.*dsh-'

# 回环租户隔离(把端口换成另一租户的实例端口,应被拒绝)
runuser -u dsh-<user> -- curl -s --connect-timeout 3 -o /dev/null -w "%{http_code}\n" http://127.0.0.1:<其他租户端>/   # 期望 000
runuser -u dsh-<user> -- curl -s --connect-timeout 3 -o /dev/null -w "%{http_code}\n" http://127.0.0.1:<自己端>/       # 期望 200

ps 输出中文件操作子进程的用户是 root,说明助手未正确降权,禁止上线

7. 运维注意

  • 网关必须以专用账号 dsh-gateway(无 shell、sudo 仅限四个文件助手)运行;切勿用带 NOPASSWD 全量 sudo 的云镜像账号。
  • 整棵安装树不得带 group/other 写位(含 DSH monorepo 源码、node_modules/.agents/ 技能库——所有租户实例共享执行这份代码,可写点即跨租户注入点)。自检:find /opt/deepseek-harness -not -path '*/users*' -perm /022 | wc -l 必须为 0。
  • gateway/ 目录 root:dsh-gateway 0770(网关做 tmp+rename 原子写),目录内代码文件归 root:root 0644;users.json(0640)/secret(0600)属主为 dsh-gateway
  • 网关 systemd 单元不可设置 NoNewPrivileges=yes(会阻断 sudo 调 root 文件助手)。
  • 不要给用户目录添加任何 ACL 读取授权--DSH 的 assertOwnerOnly 检查会拒绝实例启动(曾因此触发)。
  • 自定义安装前缀时,必须同步修改 sudoers 白名单与网关的 UPLOAD_HELPER / FILE_STAT_HELPER / FILE_READ_HELPER / FILE_LIST_HELPER 四个环境变量;回环防护的网关端口自动取自网关写入的 state-port.json(显式覆盖仍用 GW_PORT)。
  • 上传大小有两道上限:网关 UPLOAD_MAX_MB(默认 100MB)与 root 助手 DSH_UPLOAD_MAX_BYTES(默认 110000000 字节 ≈ 105MB,经 sudoers env_keep 传入)。调大上限时两处都要改。
  • 回环防护规则由 dsh-loopback-guard.service 开机应用,userctl 增删用户即时刷新;服务器上存在 docker/1Panel 等 nftables 使用方,规则以 iptables-nft 混合模式共存,勿手动 flush filter 表。
  • 反向代理必须用 proxy_set_header X-Forwarded-For $remote_addr; 覆盖客户端可能伪造的 XFF,否则网关 IP 限流可被绕过。
  • 升级 DSH 后如需客户端行为修补(如 settings 持久化作用域),请自行评估,本仓库不修改 npm 包。