Skip to content

fix(desktop): hand WSL external URLs to rundll32, not cmd.exe - #129631

Open
Froraut wants to merge 2 commits into
NousResearch:mainfrom
Froraut:security/wsl-external-open-rundll32
Open

Froraut wants to merge 2 commits into
NousResearch:mainfrom
Froraut:security/wsl-external-open-rundll32

Conversation

@Froraut

@Froraut Froraut commented Sep 30, 2026

Copy link
Copy Markdown

Important

Security fix. Public counterpart of the private advisory GHSA-333f-mm3q-xg95 (severity High, CWE-78). It was reported privately first; the maintainers asked for the fix to be posted as a public PR.

Supersedes #126974. It cherry-picks @liuhao1024's commit unchanged and adds regression tests that close gaps in that PR's coverage. Fixes #126939.

What does this PR do?

When Hermes Desktop runs inside WSL, openExternalUrl() hands every http:/https:/mailto: link to Windows as cmd.exe /c start "" <url>. cmd.exe parses that command line again, so &, |, ^ and %VAR% inside a valid URL are treated as command syntax. A link such as https://example.com/?q=1&calc.exe starts calc.exe when the user clicks it, and ordinary ?a=1&b=2 links fail to open.

The first commit is @liuhao1024's fix from #126974, unchanged:

  • The URL goes as a single argument to rundll32.exe url.dll,FileProtocolHandler, which is not a shell.
  • A fail-closed guard refuses the rare URL that could still split that argument (quotes, whitespace, control characters).

The second commit adds regression tests for two changes that #126974's tests do not catch: adding shell: true to the spawn, and removing the guard call from openViaWsl(). It also makes the new tests independent of the Node version.

Severity High
Weakness CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
Component apps/desktop/electron/external-open.ts
Affected Hermes Desktop running inside WSL: every link opened through hermes:openExternal (rendered links, previews)
Tested on main @ 95ee74db64

Impact

  • A crafted link in content that Hermes renders (model output, a fetched page, a tool result, an imported session) runs an extra Windows command with the user's privileges when the user clicks it.
  • One click is enough and there is no further prompt. The link passes the scheme allowlist, because & and | are legal URL characters and new URL() keeps them as they are.

Root cause

apps/desktop/electron/external-open.ts::openViaWsl on main:

const proc = deps.spawn('cmd.exe', ['/c', 'start', '""', url], {
  detached: true,
  stdio: 'ignore',
  windowsHide: true
})

spawn keeps the URL as one argv element, but cmd.exe /c takes the rest of its command line as a single string and parses it again. Quoting the URL would not help, because cmd.exe still expands %VAR% inside quotes.

Related Issue

Fixes #126939. It was also reported privately as GHSA-333f-mm3q-xg95 (visible to maintainers only).

Supersedes #126974. That PR's commit is included unchanged, with @liuhao1024 kept as its author.

Other open attempts on the same path:

Type of Change

  • 🔒 Security fix
  • ✅ Tests (regression tests for the fixed path)

Changes Made

Commit 1, @liuhao1024 (from #126974, unchanged): fix(desktop): hand WSL external URLs to rundll32 instead of cmd.exe start

  • apps/desktop/electron/external-open.ts:
    • openViaWsl() spawns rundll32.exe url.dll,FileProtocolHandler <url>. The fallback to xdg-open when the spawn fails is unchanged.
    • New isUnsafeWslLaunchArgument() guard. URLs containing quotes, whitespace or control characters resolve as invalid, and nothing is spawned.
  • apps/desktop/electron/external-open.test.ts: tests for the exact rundll32 argv, for a URL with & and ^ staying one argument, and unit tests for the guard helper.

Commit 2, FroRaut: test(desktop): pin the WSL rundll32 hand-off end to end. Changes to apps/desktop/electron/external-open.test.ts only:

  • Spawn options pinned ({ detached, stdio, windowsHide }, no shell). Under WSL the Electron process runs on Linux, so shell: true would pass the URL to /bin/sh, which also splits on &. With shell: true added, every fix(desktop): hand WSL external URLs to rundll32 instead of cmd.exe start #126974 test still passed; only this new test fails.
  • Guard tested through openExternalUrl(). After URL normalization, the guard can only be reached by a mailto: address that keeps a quote or space: http(s) URLs get those characters percent-encoded, and mailto: addresses do not. The new test checks that such a link is refused, nothing is spawned, and nothing falls back to xdg-open. With the guard call removed from openViaWsl(), all of fix(desktop): hand WSL external URLs to rundll32 instead of cmd.exe start #126974's tests still passed, because they only test the helper. The new test fails in that case.
  • Raw input covered, independent of Node version. The input contains a quote, CR/LF, &, |, ^ and %PATH%. The expected argument is built with new URL(), because Node releases differ on ^ in paths (older ones keep it, newer ones write %5E). The test also checks that the quote is encoded, CR/LF are gone, and &, | and %PATH% remain URL data.
  • mailto: query covered. & and | in a mailto: query reach rundll32 unchanged.
  • Formatting. Prettier applied to the test file; fix(desktop): hand WSL external URLs to rundll32 instead of cmd.exe start #126974's widened import line did not pass prettier --check.

How to Test

cd apps/desktop
npx vitest run electron/external-open.test.ts electron/preload-external-open.test.ts
npx eslint electron/external-open.ts electron/external-open.test.ts
npx prettier --check electron/external-open.ts electron/external-open.test.ts
npx tsc -p tsconfig.electron.json --noEmit

Tested on macOS 27.2 with Node 22.23.2, focused suites only. Tests replace the spawn function with a stub. I did not run a click-through on a real WSL/Windows host.

Reproduction on main (before this fix)

This script calls the real openExternalUrl() with isWsl: true and a spawn stub that prints its arguments (run with node --experimental-strip-types):

import { EventEmitter } from 'node:events'
import { openExternalUrl } from './external-open.ts'

await openExternalUrl('https://example.com/?q=1&calc.exe&x=2', {
  isWsl: true,
  spawn: (command, args) => { console.log(JSON.stringify([command, ...args])); return new EventEmitter() as any },
  openExternal: async () => {}, openFile: async () => {}, openLocalPath: async () => true,
  notifyFailure: () => {}, log: () => {}
} as any)

Output on main:

["cmd.exe","/c","start","\"\"","https://example.com/?q=1&calc.exe&x=2"]

On Windows, cmd.exe reads this as three commands: start "" https://example.com/?q=1, then calc.exe, then x=2.

Output with this PR:

["rundll32.exe","url.dll,FileProtocolHandler","https://example.com/?q=1&calc.exe&x=2"]

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes
  • I've tested on my platform: macOS 27.2

Documentation & Housekeeping

  • I've updated relevant documentation — N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS): only the WSL branch changes. macOS, Linux and native Windows still use shell.openExternal.
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Acknowledgements

Based on work by @liuhao1024 (#126974): the rundll32 hand-off and the launch-argument guard are theirs, cherry-picked with authorship preserved.

Source and authorship

If this change is salvaged or reimplemented: please preserve the existing commit authorship. Keep @liuhao1024 as the author of the first commit. For FroRaut's contribution, cherry-pick the authored commit(s) or include:

Co-authored-by: FroRaut <79215537+Froraut@users.noreply.github.com>

liuhao1024 and others added 2 commits September 30, 2026 23:25
…tart

The WSL branch of the external-open chokepoint spawned
`cmd.exe /c start "" <url>`. cmd.exe parses `&` in a query string as a
command separator and Node's Windows argument escaping never quotes it,
so an agent-produced markdown link like `https://example.com/x&calc`
executed `calc` on the Windows host, and every ordinary `?a=1&b=2` URL
failed to open on WSL (NousResearch#126939).

Quoting the argument is not a fix: cmd.exe still expands `%VAR%` inside
quotes, and `%` is pervasive in percent-encoded URLs. Replace the sink
with `rundll32.exe url.dll,FileProtocolHandler`, which is not a shell:
it never treats `&`, `|`, or `^` as separators and never expands `%VAR%`,
so the URL travels as a single argv element to the default protocol
handler. A fail-closed guard additionally rejects quotes, whitespace,
and control characters — the only characters that could split or escape
the launch argument for this sink.

The spawn-error fallback to xdg-open is unchanged.
- Pin the spawn options. Under WSL the Electron process is Linux, so a
  `shell: true` would route the URL through /bin/sh, which also splits on
  `&`. No existing test failed with that mutation.
- Exercise the launch guard through openExternalUrl(). After URL
  normalization it is reachable only for a mailto address (opaque path)
  that keeps a quote or a space; the helper-level tests still passed with
  the guard call removed from openViaWsl().
- Cover raw quote/CRLF/`%PATH%` input with an expectation built from
  `new URL()`, so the test holds on every supported Node line (older
  releases keep `^` in paths, newer WHATWG parsers encode it as %5E).
- Cover mailto query metacharacters reaching rundll32 unchanged.
- Apply Prettier to the test file (the widened import line).
@kiramakes

Copy link
Copy Markdown

🤖 Hermes Agent automated review: PR reviewed. Diff analyzed (237 lines changed). Check CI status and manual review recommended.

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P2 Medium — degraded but workaround exists comp/desktop Electron desktop app (apps/desktop/*) platform/windows sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows labels Sep 30, 2026
@alt-glitch alt-glitch added os/windows Native Windows behaviour or breakage: PowerShell install, Windows paths, cmd.exe, WSL-vs-native diff and removed platform/windows labels Oct 17, 2026

This branch has not been deployed

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

Labels

comp/desktop Electron desktop app (apps/desktop/*) os/windows Native Windows behaviour or breakage: PowerShell install, Windows paths, cmd.exe, WSL-vs-native diff P2 Medium — degraded but workaround exists sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Security] WSL external-link path hands a URL to cmd.exe with only a scheme allowlist, enabling &-delimited command execution

4 participants