Skip to content

[Bug] 设置→插件市场「安装插件市场」按钮静默失败:dsh-desktop:// 自定义协议源的 mutation POST 过不了 Origin 信任链,请求从未到达后台(0.13.1) #846

Open

Description

@momolee-gg

环境 / Environment

  • DSH Desktop 0.13.1(Windows 10 19045 x64;Electron 44.7.0 / Chromium 152.0.7977.130 / node 24.21.0)
  • dshmarket:尚未安装(问题发生在点击「安装插件市场」装它之前;按钮目标 ^1.66.47)
  • harness @deepseek-ai/dsh 0.2.2-alpha.2
  • 界面加载方式:桌面窗口从 dsh-desktop://harness/ 自定义协议加载

现象 / What happened

设置 → 插件市场 → 点击「安装插件市场」:

  1. 「正在安装插件市场…」一闪而过,同一帧还出现「重试」按钮;
  2. 随后 UI 静默复位回未安装状态,没有任何错误提示停留;
  3. 安装从未开始:harness.log 在点击时刻零记录、profile node_modules 无变化、无 pnpm 进程。

重装应用、清空全部配置、更换网络均无效——这不是环境问题(见根因)。

根因 / Root cause(已定位,并有对照实验证实)

桌面窗口的 UI 运行在自定义协议 dsh-desktop://harness 源上,而「安装」是 mutation POST,必须通过一条要求 http: Origin 的信任链;自定义协议的 POST 拿不到合格的 Origin,请求在中途即被 403,永远到不了后台。GET(状态轮询)不做 mutation 校验,所以页面渲染、状态刷新全部正常——只有 POST 必死。

代码链条(0.13.1 app.asar):

  1. 主窗口加载 HARNESS_SHELL_URL = "dsh-desktop://harness/",页面所有 API 请求经主进程 HarnessShell.handle() 代理转发到 http://127.0.0.1:<port>;

  2. shell 代理的信任规则(out/main/index.js,handle() 入口):

    const origin = request.headers.get("origin");
    if (origin !== null && origin !== SHELL_ORIGIN) return new Response("Forbidden", { status: 403 });
    // …转发前:
    if (origin === SHELL_ORIGIN) headers.set("origin", backend.origin); // 仅当 Origin 恰好等于 "dsh-desktop://harness"
  3. dsh-desktop-market-installer 后端的 isTrustedRequest(req, mutation=true) 要求:Origin 头存在、可解析为 http: 协议、且与 Host 一致,否则 403 {"error":"Request rejected."};

  4. Chromium 对非 http(s) scheme 的 fetch POST 无法给出 dsh-desktop://harness 形态的 Origin(序列化为 null 或不发)。于是 POST 要么在代理处被 403(纯文本 "Forbidden"),要么转发后因 Origin 不合格被后端 403——两条路都到不了 runInstall(),所以后台零日志;

  5. UI 侧行为完全吻合(dsh-desktop-market-installer/client.js):install() 先本地置 phase='installing',随后 fetch 失败(403 纯文本还会让 response.json() 抛 SyntaxError)→ error 态与本地 installing 残留并存 → 「正在安装…」与「重试」同框;850ms 状态轮询把页面拉回 absent → 「下载显示一下就直接消失」。

对照实验 / Evidence(同一按钮、同一后端实例,仅请求来源不同)

场景 Origin 路径 结果
桌面端点击按钮 dsh-desktop://harness 自定义协议代理 失败:POST 未抵达后端(后台零日志),UI 静默复位,多次复现
curl -X POST -H "Origin: http://127.0.0.1:43129" .../install 直接 http 成功:202,约 12s 装完 dshmarket 1.73.9
浏览器打开 http://127.0.0.1:43129/?token=… 后点同一个按钮 页面同源 http 成功:页面内请求监视录得 POST → 202,约 20s 装完 1.73.9

唯一变量是 Origin 的可达形态,结果翻转——后台安装链路本身完全正常。

复现步骤 / Steps to reproduce

  1. Windows 全新安装 DSH Desktop 0.13.1(或卸载 dshmarket 回到占位按钮状态);
  2. 设置 → 插件市场 → 点击「安装插件市场」;
  3. 观察上述静默失败;后台 harness.log 无任何安装记录。

修复建议 / Suggested fixes(任一即可)

  1. HarnessShell.handle:转发到 loopback 后端时,对 Origin 为 null/缺失的 mutation 请求也补写 origin: backend.origin(目前只有恰好等于 SHELL_ORIGIN 才改写);
  2. 或 isTrustedRequest:对已携带有效会话 cookie(backend.cookie)的 loopback 请求放行 mutation,不依赖 http Origin;
  3. 或与 dsh-market#1031 的方向合并:Origin 校验改为「scheme 白名单(http: + 应用自有 scheme)+ host 匹配」,而非严格要求 http:。

临时绕过 / Workaround(均已验证)

  • 每次启动后从 logs/harness.log 取 dsh web: http://127.0.0.1:<port>/?token=…,在任意浏览器打开 → 设置 → 插件市场 → 安装(202 成功);
  • 或 curl -X POST -H "Origin: http://127.0.0.1:43129" http://127.0.0.1:43129/dsh-desktop/market-installer/install

相关 / Related

Activity

  1. eatmoreduck commented on Nov 3, 2026

    @eatmoreduck
    Contributor

    Read the whole chain in source on current main — the report's mechanism checks out, and there's one more fact worth surfacing before a fix is chosen: the strict Origin posture is a pinned, unit-tested invariant, not an accident.

    HarnessShell.handle() (harness-shell.ts) translates the Origin only when it equals dsh-desktop://harness exactly, and test/harness-shell.test.ts has a dedicated case — "translates only a trusted Origin and never upgrades absent or foreign origins" — which asserts that an absent Origin stays absent on forward and that the literal Origin: null is rejected with 403 alongside genuinely foreign origins. So all three suggested fixes (rewrite null/absent at the proxy, cookie-based trust in isTrustedRequest, or the scheme whitelist) reverse a deliberately tested security decision rather than repairing an oversight — they need a maintainer call, not a drive-by patch.

    One direction that changes no posture: make the shell page emit a real Origin. The scheme is already registered with standard + secure + supportFetchAPI + corsEnabled (desktop-protocol.ts), so a dsh-desktop://harness page fetching a same-origin relative URL should serialize Origin: dsh-desktop://harness — the exact value the gate wants. The report's on-machine evidence (Origin arrives null/absent on Electron 44.7/Windows) then points at Electron's network stack not populating Origin for same-scheme fetches on custom standard schemes, which would make this an upstream quirk to work around rather than a trust-rule to relax. Two cheap discriminating probes on the affected machine:

    1. In the shell page's devtools, fetch('/dsh-desktop/market-installer/status', {method:'POST', credentials:'same-origin'}) and inspect the request's Origin in the network tab — does Chromium even generate the header for same-scheme POSTs here?
    2. navigator.origin / self.origin in the same console — confirm the page itself reports a real origin (dsh-desktop://harness) rather than null.

    If (1) shows the header genuinely never leaves the renderer, the fix has to be main-side after all — and the least-bad shape I see is keeping the 403 for foreign origins but treating absent (not "null"-string) as shell-originated for mutations only, justified by the channel being app-private (protocol.handle only receives this app's renderer traffic; the session cookie is already injected unconditionally on forward, so the absent-Origin strictness isn't what guards the credential). That's still a conscious amendment to the pinned test and should be the maintainer's pick between it and dsh-market#1031's whitelist direction.

    UI-side note: the "installing… + retry flash, then silent reset" the report describes is the same state machine #831/#833 addresses — once a fix lands here, #833's checking/failed states would make whatever the backend answers visible instead of flashing.

  2. momolee-gg commented on Nov 4, 2026

    @momolee-gg
    Author

    两个判别实验都做了,用的是与 DSH Desktop 0.13.1 完全相同的 Electron 44.7.0 最小复现(同样的 scheme 注册参数 standard + secure + supportFetchAPI + corsEnabled + stream + codeCache,protocol.handle 直收,无任何代理层):

    ① self.origin 正常:页面报告 dsh-desktop://harness —— 页面持有正确的 origin。

    ② Origin 头从未生成:页面内 fetch('/echo', { method: 'POST', credentials: 'same-origin' }) 发出的 POST 到达 protocol.handle 时,request.headers.has('origin') === false。GET(页面导航)、/echo POST、/report POST 全部无 Origin 头。

    实测 main 侧收到的原始数据:

    {
      "mainSide": [
        { "url": "dsh-desktop://harness/",     "method": "GET",  "originHeader": null, "hasOriginHeader": false },
        { "url": "dsh-desktop://harness/echo", "method": "POST", "originHeader": null, "hasOriginHeader": false },
        { "url": "dsh-desktop://harness/report", "method": "POST", "originHeader": null, "hasOriginHeader": false }
      ],
      "pageReport": "{\"selfOrigin\":\"dsh-desktop://harness\",\"echo\":{\"seenOriginHeader\":null,\"hasOriginHeader\":false}}"
    }

    结论(按你的推理链收口):

    • 页面有合法 origin、fetch 语义上是同源,但 Chromium/Electron 44.7.0 网络栈对自定义 standard scheme 的同源 POST 不序列化 Origin 头 → 门禁想要的值在渲染进程就不存在,主进程侧无论如何配置都收不到;
    • 这不是 DSH 桌面代码的缺陷,而是 Electron 上游行为(对比:https 站点的同源 POST 会带 Origin)。所以修复只能在 main 侧兜底,同时值得上报 Electron 上游;
    • 因此支持你提出的"最小伤害"形态:外来 Origin 继续严格 403;mutation 请求缺失 Origin 头(严格区分于字面 "null" 字符串)时,在 mutation 场景视为 shell 来源放行。理由如你所列——protocol.handle 只接收本应用渲染进程的流量、session cookie 本来就无条件注入,"缺失即拒"并没有保护任何东西,只是把上游怪癖变成了功能门禁。这是对已钉死测试的有意识修订,由维护者拍板,同时建议上游跟进 Electron。

    最小复现(Electron 44.7.0,Win10 19045 x64,main.js 全文):

    const { app, BrowserWindow, protocol } = require('electron');
    const fs = require('node:fs');
    const path = require('node:path');
    const RESULT_FILE = path.join(__dirname, 'result.json');
    const SCHEME = 'dsh-desktop';
    const seen = [];
    protocol.registerSchemesAsPrivileged([
      { scheme: SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, stream: true, codeCache: true } }
    ]);
    app.whenReady().then(async () => {
      protocol.handle(SCHEME, async (request) => {
        const url = new URL(request.url);
        seen.push({ url: request.url, method: request.method,
          originHeader: request.headers.get('origin'),
          hasOriginHeader: request.headers.has('origin') });
        if (url.pathname === '/echo') {
          return new Response(JSON.stringify({ seenOriginHeader: request.headers.get('origin'),
            hasOriginHeader: request.headers.has('origin') }),
            { headers: { 'content-type': 'application/json' } });
        }
        if (url.pathname === '/report') {
          let body = '';
          try { body = await request.text(); } catch {}
          fs.writeFileSync(RESULT_FILE, JSON.stringify({ mainSide: seen, pageReport: body }, null, 2));
          app.exit(0);
          return new Response('ok');
        }
        return new Response(`<!doctype html><html><head></head><body><script>
    (async () => {
      const out = { selfOrigin: self.origin };
      try {
        const res = await fetch('/echo', { method: 'POST', credentials: 'same-origin' });
        out.echo = await res.json();
      } catch (e) { out.fetchError = String(e); }
      try { await fetch('/report', { method: 'POST', body: JSON.stringify(out) }); } catch {}
    })();
    </script></body></html>`, { headers: { 'content-type': 'text/html; charset=utf-8' } });
      });
      const win = new BrowserWindow({ show: false, width: 420, height: 320 });
      try { await win.loadURL(SCHEME + '://harness/'); } catch (e) {
        fs.writeFileSync(RESULT_FILE, JSON.stringify({ loadError: String(e), mainSide: seen }, null, 2));
        app.exit(1);
      }
      setTimeout(() => { fs.writeFileSync(RESULT_FILE, JSON.stringify({ timeout: true, mainSide: seen }, null, 2)); app.exit(2); }, 15000);
    });

    运行后 result.json 如上——协议处理器收到的 POST 永远没有 Origin 头,而页面 self.origin 是完整合法的 dsh-desktop://harness。任何人装同版本 Electron 就能复现。

  3. eatmoreduck commented on Nov 4, 2026

    @eatmoreduck
    Contributor

    两个判别实验做得很扎实,结果把链条完全钉死了:页面持有正确的 origin(self.origin 正常),但 Electron 44.7 的网络栈对自定义 standard scheme 的同 scheme POST 就是不生成 Origin 头。这意味着:

    1. 我之前指出的「严格 Origin 姿态是被单测钉死的 invariant」依然成立,但它的前提在这条技术栈上是空集——HarnessShell.handle() 里 origin === SHELL_ORIGIN 的精确匹配分支对壳页面自己发出的请求永远命不中(头根本不存在),等于死代码。最小复现(同样的 scheme 注册参数、protocol.handle 直收)同时也排除了本仓代理层引入差异的可能。
    2. 修复决策因此收敛为「怎么改 invariant」而不是「要不要改」:应用私有通道(protocol.handle 只收本 app 渲染进程的请求,外部进程进不来)+ 会话 cookie 本就在转发时由主进程无条件注入,Origin 门禁并非凭据防线。最小改法=mutation 且 Origin 缺席 → 转发时补写 origin: backend.origin,同时保留 'null' 字面量与外来 Origin 的 403(单测里那条 foreign 名单原样保留,只改 absent 分支的语义);对应的 translates only a trusted Origin 用例需要同步改两条断言。
    3. 值得把这个最小复现提到 Electron 上游(同 scheme、standard+secure+supportFetchAPI 注册下 POST 无 Origin 头,是否符合预期);无论上游怎么答,应用侧都需要上面这个补丁兜底。

    方向定了的话我可以直接出补丁(含 harness-shell 单测的 absent-mutation 用例修订),维护者拍板即可。

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions