Repository navigation
Description
我在使用前让dsh审查了一下,我想或许结果对作者或其他人是有价值的,在这里分享一下仅供参考
[Security] /task-control 路由未鉴权,且用户提示词与工具参数明文落盘
中文
这个插件整体是干净的(无 child_process、无 eval、无外呼、无安装期脚本),但有三类问题值得在发布前修掉:
- 新增的
/task-control/*HTTP 路由完全不做鉴权,也不做 Host/Origin 校验,绕开了 DSH 传输层对/、/connect、/api的准入控制。它们可以读出暂停状态(含最后一条用户提示词、被中断工具的完整参数),也可以写入全局暂停设置;同机任意进程、局域网部署下的任意主机都能命中。 - 暂停快照把完整用户提示词与工具参数明文写到磁盘,而且路径用的是
process.env.HOME || process.cwd():Windows 上HOME通常不存在,文件会落到工作目录(本项目目录)而不是文档声称的~/.dsh/task-control/,同时完全忽略 DSH 官方支持的DSH_HOME。 - 恢复流程默认重跑被中断的工具,并且把“请重新执行该工具”的指令以
user角色注入会话。对非幂等命令(git push、迁移、删除、MCP 支付类工具)会二次执行;“评估副作用”只靠模型自觉。
另有几项功能/健壮性问题:暂停时把 goal 置为 paused 但恢复时从不恢复;安全暂停的“待生效”状态只存在内存,重启即丢;依赖 DSH 内部事件形状,形状变化时会静默绕过重跑确认。
English summary
- Unauthenticated HTTP surface: the plugin registers
/task-control/stateand/task-control/settingson the shared host webserver with no auth and noHost/Origincheck, bypassing the fence that DSH applies to/,/connectand/api. State responses include the last user prompt and interrupted-tool arguments; settings writes are CSRF-able with atext/plainsimple 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 andDSH_HOMEis ignored. - Unsafe resume default:
/resume confirmdefaults to re-running the interrupted tool and injects that instruction as ausermessage; 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>建议修复
- 首选:把这两项数据挪到 Connection 已认证的通道(
/api的 session controller),不要自己开 prefix 路由。 - 若必须保留 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无鉴权”。
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:71import { 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