Skip to content

fix(cli): stop cmd.exe mangling the OAuth URL on Windows (auth login silently fails) - #71

Merged
Ray-56 merged 2 commits into
CALLE-AI:mainfrom
EazyHood:fix/windows-oauth-url
Aug 4, 2026
Merged

fix(cli): stop cmd.exe mangling the OAuth URL on Windows (auth login silently fails)#71
Ray-56 merged 2 commits into
CALLE-AI:mainfrom
EazyHood:fix/windows-oauth-url

Conversation

@EazyHood

@EazyHood EazyHood commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

On Windows, calle auth login opens the browser through cmd:

spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" })

cmd treats & as a command separator, and an OAuth URL is full of them.

Measured

$ node -e ... spawnSync("cmd", ["/c", "echo", OAUTH_URL])

in    ...authorize?client_id=calle&redirect_uri=http://localhost:9999/cb&state=abc123&scope=mcp
out   ...authorize?client_id=calle
err   'redirect_uri' is not recognized as an internal or external command
      'state' is not recognized as an internal or external command

Two things go wrong at once:

  1. The browser gets a truncated authorize URL — no redirect_uri, no state, no scope — so login cannot complete.
  2. The remaining query fragments are handed to cmd as commands to run.

And stdio: "ignore" means the user sees neither. calle auth login appears to do nothing at all.

The command-execution half is not exploitable here — the URL comes from your own broker — but a URL should not reach a shell parser in the first place.

Fix

rundll32 url.dll,FileProtocolHandler <url> takes the URL as a single argument and never parses it. macOS (open) and Linux (xdg-open) paths are unchanged; both already pass the URL as one argv entry.

Also adds an error listener to the child. Without one, a spawn failure emits an unhandled error event, which is an uncaught exception — failing to open a browser should not take the CLI down when the URL is printed anyway.

Tests

Two cases, skipped off Windows: one pins the cmd truncation so the old approach cannot come back unnoticed, one asserts the URL survives as a single argv entry. Full suite: 113 passing.


Separate from #70, which fixes the CRLF and path-separator problems in the plugin validators. This one is user-facing rather than contributor-facing.

Disclosure: written with AI assistance. Every measurement above was run on my own Windows 11 machine, and I take responsibility for the change.

`auth login` opened the browser through cmd:

    spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" })

cmd treats `&` as a command separator, and an OAuth URL is full of them.
Measured with the real URL shape:

    in    ...authorize?client_id=calle&redirect_uri=http://localhost:9999/cb&state=abc&scope=mcp
    out   ...authorize?client_id=calle
    err   'redirect_uri' is not recognized as an internal or external command
          'state' is not recognized as an internal or external command

Two consequences. The browser receives a truncated authorize URL with no
redirect_uri, state or scope, so login cannot complete. And the remaining query
fragments are handed to cmd as commands to run. `stdio: "ignore"` means the user
sees neither -- `calle auth login` appears to do nothing.

The command-execution half is not exploitable here, since the URL comes from
your own broker, but a URL should never reach a shell parser in the first place.

Uses rundll32 url.dll,FileProtocolHandler instead, which takes the URL as a
single argument and never parses it. macOS and Linux paths are unchanged.

Also adds an 'error' listener to the child. Without one, a spawn failure emits
an unhandled 'error' event, which is an uncaught exception -- failing to open a
browser should not take the CLI down when the URL is printed anyway.

Tests: two cases pinning the cmd behaviour and the argv-passing that replaces
it, skipped off Windows. Full suite 113 passing.

@Ray-56 Ray-56 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the focused Windows fix. The URL is no longer parsed by cmd.exe, but this is not ready to merge yet.

[P1] Use a trusted absolute path for the Windows system opener. spawn("rundll32", ...) uses an unqualified executable name. Windows executable search checks the parent process current directory before the System32 directory, so running calle auth login from an untrusted checkout containing a planted rundll32.exe can execute attacker-controlled code. Use the fully qualified System32 executable path (derived from the trusted Windows system directory) or another shell-free API that does not search the working directory. Microsoft documents the search order and recommends fully qualified paths here: https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessa

[P2] Make the regression test exercise the production opener selection. The current tests demonstrate that cmd.exe mangles an ampersand and that Node preserves one argv value, but neither imports or invokes the code changed in cli.js. Reverting production code to cmd.exe would leave both tests green. Please extract a small command-selection helper or inject platform and spawnImpl, then assert the exact trusted executable and argv used by the real code path.

[P2] Add the required patch changeset for @call-e/cli. This changes published runtime behavior, so CONTRIBUTING.md requires a changeset. Run pnpm changeset, pnpm run check:versions, and the CLI pack dry-run.

The CLI check, test, and pack dry-run pass locally, but the two security/coverage items above still block merge.

…real path

Addresses the review on CALLE-AI#71.

[P1] spawn("rundll32", ...) used an unqualified executable name, so the
CreateProcess search order would look in the current working directory before
System32 and a planted rundll32.exe in an untrusted checkout would run. The
opener is now the fully qualified %SystemRoot%\System32\rundll32.exe.

[P2] The opener selection moved into an exported browserOpenCommand(url,
platform, env), and the tests now call it instead of demonstrating cmd.exe
behaviour beside it. Putting cmd back makes 4 of the 7 fail, on every platform.

[P2] Added the patch changeset for @call-e/cli.
@EazyHood

EazyHood commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Both fixed, thanks for the catch on the search order.

The opener is now named by its fully qualified path, built from the trusted system directory:

%SystemRoot%\System32\rundll32.exe

The selection moved into an exported browserOpenCommand(url, platform, env) and the tests call that instead of demonstrating cmd.exe beside it. Putting cmd /c start "" <url> back makes 4 of the 7 fail, on any platform, which was the gap you pointed at. Full CLI unit suite is 42/42, check:versions and the syntax check are clean, and the patch changeset for @call-e/cli is in.

@Ray-56 Ray-56 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current head resolves the previous findings: the Windows opener uses a fully qualified System32 path, the regression tests exercise the production command-selection helper, and the required @call-e/cli patch changeset is present. GitHub CI and the local CLI check, test, version validation, and package dry-run all pass. I did not find another merge blocker.

@Ray-56
Ray-56 merged commit bc70f48 into CALLE-AI:main Aug 4, 2026
1 check passed
@github-actions github-actions Bot mentioned this pull request Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants