Skip to content

Commit 9abcfea

Browse files
fix(auth): caret-escape cmd metacharacters when opening the browser on WSL (#61)
* fix(auth): caret-escape cmd metacharacters when opening the browser on WSL `openViaCmdExe` wrapped the URL in double quotes to stop `&` from splitting the `cmd.exe /c start` command line. Under WSL that doesn't work: the interop layer re-quotes argv entries itself, mangling the literal quotes, so `&` leaks through and acts as a statement separator — only the prefix up to the first `&` reaches `start`, and Windows tries to open that fragment as a path ("Windows cannot find '\https://…'"). OAuth authorize URLs (several `&`, percent-encoded params) hit this every time. Two compounding bugs: - The surrounding quotes don't survive WSL interop, so `&` was never protected. - `url.replaceAll('%','%%')` does not collapse on the `cmd /c` command line (only inside batch files), so it would have corrupted every `%HH` byte even when the command did run. Fix: caret-escape cmd's metacharacters (`& | < > ^ ( ) "`) — which survives interop since there are no quotes to mangle — and leave `%` alone (OAuth URLs are `%HH`, which never matches `%NAME%` env-expansion). Extracted as a testable `escapeUrlForCmd`; verified the round-trip through a real `cmd.exe` on WSL. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): open the WSL browser via rundll32 instead of cmd.exe Addresses review: caret-escaping cmd metacharacters still left `%` exposed — a percent-encoded multi-byte UTF-8 byte like `%C3%A9` (é) contains `%C3%`, which `cmd /c` would treat as `%VAR%` env-expansion and mangle. Since runOAuthFlow is public API for custom AuthProviders, that's a real hazard, not just a built-in concern. Bypass the shell entirely: launch via `rundll32.exe url.dll,FileProtocolHandler <url>`. CreateProcess hands the single argv to the protocol handler verbatim — no cmd parsing pass — so neither `&` (statement separator) nor `%HH` (env-expansion) can corrupt the URL, and there's no fragile escaping to maintain. Drops the now-unneeded escapeUrlForCmd helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7718e11 commit 9abcfea

2 files changed

Lines changed: 31 additions & 32 deletions

File tree

src/auth/flow.test.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -488,13 +488,11 @@ describe('runOAuthFlow default opener selection', () => {
488488
Object.defineProperty(process, 'platform', { value, configurable: true })
489489
}
490490

491-
it('routes WSL via cmd.exe with the URL quoted and `%` doubled', async () => {
492-
// Two escapes are load-bearing for cmd.exe:
493-
// 1. quote the URL so `&` doesn't split the command line
494-
// 2. double `%` so cmd.exe doesn't try to expand percent-encoded
495-
// OAuth params (`%3A`, `%2F`, …) as env-var references.
496-
// Use an authorize URL with both `&` and `%` so the assertion
497-
// exercises both escapes.
491+
it('routes WSL via rundll32, passing the URL verbatim (no shell, no escaping)', async () => {
492+
// rundll32 is not a shell, so the URL is handed to the protocol handler
493+
// as-is: `&` can't split the command and `%HH` (incl. multi-byte UTF-8
494+
// like `%C3%A9`) can't be mistaken for `%VAR%` env-expansion. The URL
495+
// here carries `&`, ASCII `%HH`, and an encoded `é` to lock that in.
498496
stubPlatform('linux')
499497
vi.mocked(readFileSync).mockReturnValue('Linux 5.15 #1 SMP microsoft-WSL2')
500498
const execFileMock = vi.mocked(execFile)
@@ -510,7 +508,7 @@ describe('runOAuthFlow default opener selection', () => {
510508

511509
const { provider, getRedirect } = instrument({
512510
authorize: async (input) => ({
513-
authorizeUrl: `https://example.com/oauth/authorize?state=${input.state}&redirect_uri=http%3A%2F%2Flocalhost%3A8080`,
511+
authorizeUrl: `https://example.com/oauth/authorize?state=${input.state}&redirect_uri=http%3A%2F%2Flocalhost%3A8080&name=Andr%C3%A9`,
514512
handshake: { codeVerifier: 'v1' },
515513
}),
516514
})
@@ -522,11 +520,11 @@ describe('runOAuthFlow default opener selection', () => {
522520
expect(execFileMock).toHaveBeenCalledTimes(1)
523521
const [cmd, args] = execFileMock.mock.calls[0]
524522
const url = onAuthorizeUrl.mock.calls[0][0] as string
525-
expect(cmd).toBe('cmd.exe')
526-
expect(args).toEqual(['/c', 'start', '""', `"${url.replaceAll('%', '%%')}"`])
527-
// Sanity: the URL we built does contain both special chars.
528-
expect(url).toMatch(/&/)
529-
expect(url).toMatch(/%/)
523+
expect(cmd).toBe('rundll32.exe')
524+
// URL is passed verbatim — no quoting, no caret-escaping, no `%%`.
525+
expect(args).toEqual(['url.dll,FileProtocolHandler', url])
526+
expect(url).toContain('&')
527+
expect(url).toContain('%C3%A9')
530528
})
531529

532530
it('skips the default opener entirely on headless Linux', async () => {

src/auth/flow.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -484,9 +484,9 @@ async function openOrFallback(
484484

485485
async function loadDefaultOpener(): Promise<((url: string) => Promise<void>) | null> {
486486
// WSL check must run before the headless check: WSL is `platform === 'linux'`
487-
// and often has no DISPLAY, but `cmd.exe` does work and reaches the user's
488-
// real Windows browser, so it's worth the spawn.
489-
if (isWsl()) return openViaCmdExe
487+
// and often has no DISPLAY, but the Windows interop opener does work and
488+
// reaches the user's real Windows browser, so it's worth the spawn.
489+
if (isWsl()) return openViaWindows
490490
if (isHeadlessLinux()) return null
491491
try {
492492
const mod = (await import('open')) as { default: (url: string) => Promise<unknown> }
@@ -500,20 +500,21 @@ async function loadDefaultOpener(): Promise<((url: string) => Promise<void>) | n
500500

501501
const execFileAsync = promisify(execFile)
502502

503-
// Two layers of escaping are needed because `cmd.exe /c` is a shell:
504-
// 1. Wrap the URL in literal double quotes. WSL interop only auto-quotes
505-
// args that contain spaces; an OAuth URL (no spaces, plenty of `&`s)
506-
// would otherwise be re-parsed by cmd.exe with `&` acting as a
507-
// statement separator, so only the prefix up to the first `&` would
508-
// reach `start`.
509-
// 2. Double every `%` to `%%`. cmd.exe expands `%NAME%` even inside
510-
// quoted strings; OAuth URLs are full of percent-encoded bytes
511-
// (`%3A`, `%2F`, …) and a chance match against a defined env var
512-
// (`%PATH%`, `%TEMP%`, …) would silently mangle the URL.
513-
// `start ""` — the empty title arg is mandatory; otherwise `start` consumes
514-
// the URL as a window title and never launches a browser. (`execFile`'s
515-
// no-shell guarantee doesn't apply when the target is itself a shell.)
516-
async function openViaCmdExe(url: string): Promise<void> {
517-
const escaped = url.replaceAll('%', '%%')
518-
await execFileAsync('cmd.exe', ['/c', 'start', '""', `"${escaped}"`], { windowsHide: true })
503+
// Launch the Windows default browser through rundll32's URL protocol handler.
504+
// Crucially this is NOT a shell, so the URL is passed as a single argv that
505+
// `CreateProcess` hands to the handler verbatim — there is no `cmd.exe` parsing
506+
// pass, so `&` can't split the command and percent-encoded bytes (including
507+
// multi-byte UTF-8 like `%C3%A9` for `é`) can't be mistaken for `%VAR%`
508+
// env-expansion.
509+
//
510+
// The previous `cmd.exe /c start "" "<url>"` approach was doubly broken on WSL:
511+
// its literal double-quotes don't survive WSL interop's own argv re-quoting (so
512+
// `&` leaked through and Windows opened the truncated fragment as a path —
513+
// "Windows cannot find '\https://…'"), and `%`→`%%` doubling doesn't collapse on
514+
// the `cmd /c` command line, so it corrupted every `%HH` byte. Avoiding the
515+
// shell sidesteps both, with no fragile escaping to maintain.
516+
async function openViaWindows(url: string): Promise<void> {
517+
await execFileAsync('rundll32.exe', ['url.dll,FileProtocolHandler', url], {
518+
windowsHide: true,
519+
})
519520
}

0 commit comments

Comments
 (0)