Skip to content

[Security sensored by AI] /task-control 路由未鉴权,且用户提示词与工具参数明文落盘,及其他 #11

Open

Description

@frostblade-CN

我在使用前让dsh审查了一下,我想或许结果对作者或其他人是有价值的,在这里分享一下仅供参考

[Security] /task-control 路由未鉴权,且用户提示词与工具参数明文落盘

中文

这个插件整体是干净的(无 child_process、无 eval、无外呼、无安装期脚本),但有三类问题值得在发布前修掉:

  1. 新增的 /task-control/* HTTP 路由完全不做鉴权,也不做 Host/Origin 校验,绕开了 DSH 传输层对 /、/connect、/api 的准入控制。它们可以读出暂停状态(含最后一条用户提示词、被中断工具的完整参数),也可以写入全局暂停设置;同机任意进程、局域网部署下的任意主机都能命中。
  2. 暂停快照把完整用户提示词与工具参数明文写到磁盘,而且路径用的是 process.env.HOME || process.cwd():Windows 上 HOME 通常不存在,文件会落到工作目录(本项目目录)而不是文档声称的 ~/.dsh/task-control/,同时完全忽略 DSH 官方支持的 DSH_HOME。
  3. 恢复流程默认重跑被中断的工具,并且把“请重新执行该工具”的指令以 user 角色注入会话。对非幂等命令(git push、迁移、删除、MCP 支付类工具)会二次执行;“评估副作用”只靠模型自觉。

另有几项功能/健壮性问题:暂停时把 goal 置为 paused 但恢复时从不恢复;安全暂停的“待生效”状态只存在内存,重启即丢;依赖 DSH 内部事件形状,形状变化时会静默绕过重跑确认。

English summary

  • Unauthenticated HTTP surface: the plugin registers /task-control/state and /task-control/settings on the shared host webserver with no auth and no Host/Origin check, bypassing the fence that DSH applies to /, /connect and /api. State responses include the last user prompt and interrupted-tool arguments; settings writes are CSRF-able with a text/plain simple request.
  • Sensitive data at rest: pause snapshots persist the full last user prompt plus tool arguments. Path resolution uses process.env.HOME || process.cwd(), so on Windows the files land in the working directory and DSH_HOME is ignored.
  • Unsafe resume default: /resume confirm defaults to re-running the interrupted tool and injects that instruction as a user message; non-idempotent tools can be executed twice.
  • Functional: a paused goal is never resumed; pending safe pauses are memory-only; event-shape drift silently disables the rerun confirmation gate.

环境

项 值
插件版本 0.1.0(package.json)
DSH 0.1.x / 0.2.x(README 声明;本次对照的是本机安装的 DSH 宿主实现)
部署形态 默认 127.0.0.1:3080(回环);dsh web --host <LAN IP> 或反向代理时对外可达
安装方式 dsh plugin --profile web add github:p2coder/dsh-task-control

F1[High,条件性]/task-control/* 无鉴权、无 Host/Origin 校验

证据

lib/index.js:750-792 的 createStateHandler 只看 method 与 pathname:

if (method === "GET" && route === "/task-control/state") { /* 直接返回状态 */ }
if (method === "GET" && route === "/task-control/settings") { /* 直接返回设置 */ }
if (method === "POST" && route === "/task-control/settings") {
  const body = await readJsonBody(req);   // 不看 content-type
  ...
}

lib/index.js:847-853 把它注册为共享 webserver 的 prefix 路由:

ctx.effect(() => ctx.webServer.register({
  kind: "prefix",
  path: "/task-control",
  handler: createStateHandler(ctx, state)
}));

而宿主 dsh-host-webserver 的 README 明确写着 “No server-wide authentication or origin policy — route owners … enforce their own request policy”;DSH 自己的传输层在 dsh-client-connection 中对 /、/connect、/api 做了 Host/Origin 围栏与浏览器 token/cookie 认证。这个 prefix 路由是另一条独立入口,完全跳过上述准入。

影响

GET /task-control/state?session=<id> 返回(lib/index.js:761-771):

{ "ok": true, "state": {
  "status": "running", "paused": true, "forced": true,
  "interruptedTool": { "name": "bash", "arguments": "...", "callId": "..." },
  "deferredTools": [ { "name": "...", "arguments": "..." } ],
  "resumeContent": [ { "type": "text", "text": "<最后一条用户提示词全文>" } ]
} }

POST /task-control/settings 可以改写全局默认暂停模式(safe → force),影响之后每一次裸 /pause:

可达场景 后果
默认回环部署 同机任意进程、甚至同机其他 OS 用户都能直接请求,无需登录
--host <LAN IP> / 反向代理 / 隧道 同网段任何人可读设置;拿到 sessionId 即可读该会话的用户提示词与工具参数
CSRF text/plain 的 POST 属于“简单请求”,不触发预检;handler 不校验 Origin,写入成功
DNS rebinding 不校验 Host,外部域名重绑定到 127.0.0.1 后可同源读取(读状态仍需 sessionId)

需要说明的限制:状态读取需要知道 sessionId(看起来是 UUID,无法枚举),因此读取的门槛是“拿到 id”;但设置写入不需要任何 id。也正因如此,这一条定级为“回环下 Medium、对外暴露时 High”,而不是无条件 High。

最小复现(回环部署即可)

# 1) 未认证读取:状态 + 最后一条用户提示词 + 工具参数
curl -s 'http://127.0.0.1:3080/task-control/state?session=<已暂停的 sessionId>'

# 2) 未认证写入:把全局暂停默认改成 force(不带 Cookie / Authorization)
curl -s -X POST 'http://127.0.0.1:3080/task-control/settings' \
  -H 'content-type: text/plain' \
  --data '{"defaultMode":"force","safeReasoning":"stop"}'

按代码路径,这两个请求都应返回 200(本次为静态审查,未在真实安装上执行)。第 2 步也可以由任意网页发起(不触发 CORS 预检):

<script>
fetch('http://127.0.0.1:3080/task-control/settings', {
  method: 'POST',
  mode: 'no-cors',
  headers: { 'content-type': 'text/plain' },
  body: JSON.stringify({ defaultMode: 'force', safeReasoning: 'stop' })
});
</script>

建议修复

  1. 首选:把这两项数据挪到 Connection 已认证的通道(/api 的 session controller),不要自己开 prefix 路由。
  2. 若必须保留 prefix 路由,至少实现与宿主等价的围栏:
    • Host 的 hostname 必须是回环(127.0.0.0/8、::1、localhost)或监听地址本身,否则 403;
    • 请求带 Origin 时必须与 Host 同 authority,否则 403;
    • POST /settings 强制要求 content-type: application/json(这一条单独就能挡掉 text/plain 的 CSRF),并限制 body 大小(如 64 KiB);
    • 响应加 Cache-Control: no-store;
    • 非回环绑定时在启动日志里明确警告“/task-control 无鉴权”。
  3. readJsonBody 增加长度上限(见 F4)。

备注:isTrustedApiRequest 目前在 dsh-client-connection 内部、未对外导出。可以向上游提 PR 导出该判定,或在插件里按同一规则实现。

F2[Medium]用户提示词与工具参数明文落盘,且 Windows 路径错误

证据

lib/index.js:98-104:

function stateRootDir() {
  return process.env.DSH_TASK_CONTROL_STATE_DIR
    || join(process.env.HOME || process.cwd(), ".dsh", "task-control");
}

lib/index.js:141-153 的 markPaused 写入的快照包含:

{
  sessionId,
  paused: true,
  resumeContent,                 // 最后一条 user/message 的完整 content 数组
  forced, interruptedTool,       // { name, arguments, callId } —— 完整工具参数
  deferredTools,                 // [{ name, arguments, callId }]
  updatedAt
}

影响

  • resumeContent 是用户提示词原文;interruptedTool.arguments / deferredTools[].arguments 是工具调用参数,bash/pwsh 命令里经常带 token、内网地址、SQL、文件内容。
  • 路径回退到 process.cwd():Windows 默认没有 HOME(实测 HOME=undefined),文件会写到 [dshworkspace]\.dsh\task-control\*.json,也就是工作目录/项目目录,可能被 git add、被云盘同步、被共享;README 声称的 ~/.dsh/task-control/ 与实际不符。
  • 同时忽略 DSH 官方支持的 DSH_HOME,配了自定义 home 的用户找不到、也清不掉这些文件。
  • 写入没有收紧权限(writeFileSync 未传 mode,POSIX 下按 umask 常见 0644)。

复现

# Windows:HOME 未设置时,状态目录落在当前工作目录
node -e "const {join}=require('path');console.log(join(process.env.HOME||process.cwd(),'.dsh','task-control'))"
# => ...\.dsh\task-control

建议修复

import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";

function stateRootDir() {
  return process.env.DSH_TASK_CONTROL_STATE_DIR
    || join(resolveDshHome(), "task-control");
}
  • writeFileSync(tmp, data, { mode: 0o600 })。
  • 删除 resumeContent:恢复流程从不使用它(见 F8),保留它只有隐私成本。
  • 若确实需要留痕,只存长度/哈希,不存原文。

F3[Medium]恢复默认重跑被中断的工具,且以 user 身份注入指令

证据

lib/index.js:413-422 用 createUserMessage 注入恢复消息:

const source = typeof agent.session.snapshotEvents === "function"
  ? { kind: "plugin:task-control" }
  : { kind: "plugin", plugin: "task-control" };
const followup = (blocks) => agent.followup(createUserMessage({ content: blocks, source }));

lib/index.js:478-485(强制暂停 + 工具状态未知时的默认分支):

followup([{ type: "text", text:
  `任务已恢复。上次暂停时工具 ${describeTool(tool)} 没有执行完成(可能已部分执行并产生副作用,状态未知)。` +
  `你选择重新执行:请先评估/清理该工具可能产生的部分副作用,再重新执行该工具调用,然后继续任务。` }]);

lib/index.js:822-828:confirm 只是命令文本里的 token,客户端菜单点一下就会带上:

confirm: tokens.includes("confirm"),
choice: tokens.includes("skip") ? "skip" : "rerun"

影响

  • 角色是 user、来源是插件:模型侧等同用户指令,界面上是否有气泡取决于 UI 对 source 的渲染。也就是说插件可以代用户下达“执行工具”的指令。
  • 默认行为是 rerun。对 git push、数据库迁移、rm、发消息/支付类 MCP 工具会二次执行;“先评估/清理副作用”完全依赖模型自觉,不是硬保证。
  • 工具名/参数被拼进指令文本(describeTool / describeToolPurpose,lib/index.js:224-242、284-287)。若会话日志来自导入/伪造,或参数本身含指令性文字,这些内容会被抬升为“恢复指令”的一部分。

建议修复

  • 恢复消息不要伪装成用户输入:把“要重跑”的指令放在可见的系统/上下文行,或由宿主的结构化动作驱动;至少把 source 标成非用户来源,避免模型把插件文本当作用户授权(agent.inject() 不唤醒驱动器,单独用它无法恢复任务,需要配合一次显式 resume 动作)。
  • 只引用 callId 与工具名,不把参数原文写进消息。
  • 非幂等/未知工具默认 skip;“重新执行”必须是显式选择,并在 UI 上给出“该工具可能已部分执行”的阻断提示。
  • 若要保留 confirm 语义,用一个不可伪造的确认结果(例如服务端记录的一次性决策),而不是命令文本 token。

F4[Medium,可用性]无鉴权接口 + 无上限缓存 → 可刷内存

lib/index.js:132-139:

const stored = readStoredState(sessionId);
const merged = ...;
state.cache.set(sessionId, merged);   // 任意 sessionId 都会进缓存,无淘汰

state 路由对任意 session 查询串都会走到这里(lib/index.js:756-760)。配合 F1 的“无鉴权”,任何能访问端口的人可以用随机 sessionId 持续请求,让 state.cache 无上限增长;readJsonBody 也没有 body 大小上限(lib/index.js:729-743)。

建议:缓存设上限/LRU,并给 body 加长度上限。

F5[Medium,功能]暂停时暂停了 goal,恢复时从不恢复

lib/index.js:289-302:

function pauseSessionGoal(ctx, agent) {
  const goal = goals.get(agent);
  if (goal !== void 0 && goal.phase === "active") goals.pause(agent, { id: goal.id, revision: goal.revision });
}

调用点:applyPauseNow(lib/index.js:319)与强制暂停(lib/index.js:354)。但全文件没有任何 goals.resume(...) 调用,resumeTask、cancelTask 都不碰 goal。

后果:用户暂停 → 恢复后,会话看起来继续了,但 goal 仍是 paused,goal-round 驱动不再续跑该目标;/cancel 同样留下一个 paused goal。用户会误以为任务在继续,实际自动续跑已经停了。

建议:把被暂停的 { id, revision } 记进快照,恢复时调用 goals.resume();取消时也恢复或显式提示“goal 仍处于暂停”。

F6[Medium,功能/安全语义]“正在暂停”可能永远不落地

安全暂停在等边界时只写内存 pendingPause(lib/index.js:799-803),命令已经回 “task pausing…”。此时重启 DSH 或热重载插件,pending 状态丢失,任务照常继续跑,用户却以为它停了。

建议:把 pending 状态持久化到快照(如 pending: true),启动时做一次对账;或至少在检测到“paused 标记存在但会话仍在运行”时显式提示。

F7[Low-Medium]依赖内部事件形状,降级时静默绕过确认门

lib/index.js:86-89 双形态读取事件;lib/index.js:629-643 从 tool/call / tool/result 记录 in-flight。若未来 DSH 改了事件名或字段,in-flight 表会是空的 → 强制暂停记录不到 interruptedTool → /resume 走 tool === null 分支(lib/index.js:487-496),不再弹重跑/跳过确认,直接让模型“继续”,模型很可能自行重跑被中断的工具。

建议:探测不到预期事件形状时禁用按钮并提示不兼容(fail closed),而不是静默退化。

F8[Low]resumeContent 是只写不读的死数据

resumeContent 只在 markPaused 写入(lib/index.js:145)、在 state 服务与路由输出(lib/index.js:721、769);resumeTask 全程没有读过它(恢复依赖会话日志与 interruptedTool)。也就是说,磁盘上的用户提示词副本没有任何功能用途,纯增隐私与攻击面。建议直接删除(涉及对外 state() 返回结构的变更,可在 release note 说明)。

F9[Low]会话 ID 文件名编码有损、Windows 保留名会写失败

lib/index.js:94-96:

String(sessionId).replace(/[^a-zA-Z0-9._-]/g, "_");
  • a:b 与 a_b 会映射到同一个文件,跨会话状态串味;
  • Windows 保留名(CON/PRN/AUX/NUL,带任意扩展名同样保留)会写失败;
  • 没有路径穿越问题(.. 会变成 ...json 之类的普通文件名),这点是好的。

建议:文件名用 sha256(sessionId),文件内容里保存原始 sessionId 并在读取时校验。

F10[Low,供应链/打包]

  • 仓库只提交了 lib/ 构建产物,没有 src/、没有构建脚本,无法从源码复现或审阅;“发布出来的 JS”与“作者本地源码”之间没有可验证的对应关系。
  • README 的安装方式是 github:p2coder/dsh-task-control(可变分支 HEAD),重新安装可能拿到不同代码。建议在 README 里给出 #<commit sha> 的锁定安装方式,并打 tag。
  • npm 上的同名包 dsh-task-control 属于另一个作者(ningmengxr,功能是“下载卡死急停”),不是这个项目。按裸包名安装会装到完全不同的代码;建议改名或加 scope(如 @p2coder/dsh-task-control)。
  • lib/index.js:71 import { createUserMessage } from "@deepseek-ai/dsh-llm",但 package.json 的 peerDependencies 没有声明它,靠宿主 loader 的 bare-module 解析兜底;换安装形态可能直接加载失败。建议补声明。
  • 好消息:没有 prepare/postinstall,github: 安装不会执行构建脚本。

建议的修复优先级

优先级 项 说明
P0 F1 路由加 Host/Origin 围栏与写接口的 content-type/CSRF 防护,或迁移到已认证的 /api
P0 F2 resolveDshHome() + mode: 0o600 + 删除 resumeContent
P1 F3 恢复消息改 inject/system,非幂等工具默认 skip,显式确认 rerun
P1 F5 恢复 goal,取消时也处理 goal
P2 F4 / F6 / F7 缓存与 body 上限、pending 持久化、形状探测 fail closed
P3 F9 / F10 hash 文件名、补 peer 声明与源码

建议补的测试

  • 带非回环 Host(如 Host: evil.example)的 GET /task-control/state 应 403;带跨站 Origin 的请求应 403。
  • Origin: https://evil.example 的 POST /task-control/settings 应 403。
  • content-type: text/plain 的设置写入应 415/400。
  • HOME 未设置、DSH_HOME 已设置时,状态目录应落在 DSH_HOME 下。
  • 暂停 → 恢复后 goal 应为 active。
  • 模拟 pendingPause 后重启,状态应与实际运行情况一致。
  • 用未知/缺失的 tool/call 事件形状驱动,/resume 仍应要求确认(fail closed)。

已确认安全 / 做得好

  • lib/client.js 无 dangerouslySetInnerHTML、eval、new Function;无 localStorage/cookie 读取;唯一网络请求是同源相对 fetch(lib/client.js:177);工具名与错误信息都经 React 文本转义。
  • 宿主半没有 child_process、无出站网络请求、无动态 require/import。
  • 路由用 ctx.effect 注册并在卸载时注销(lib/index.js:847-853),热重载不会残留重复路由;测试里也覆盖了这点。
  • 设置读写对枚举值做了白名单校验(lib/index.js:174-175、780-781)。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions