Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,13 @@ function openExternal(url: string, platform: NodeJS.Platform = process.platform,
const child = launch('open', [url], { stdio, detached: true });
child.unref();
} else if (platform === 'win32') {
const child = launch('cmd', ['/s', '/c', `start "" "${url}"`], {
// Shell-free: do not pass the OAuth URL through cmd.exe. Command metacharacters
// such as `&` in query strings are still parsed by cmd even when argv is split.
// rundll32 FileProtocolHandler treats the URL as a document path, not command text.
const child = launch('rundll32', ['url.dll,FileProtocolHandler', url], {
stdio,
detached: true,
windowsVerbatimArguments: true,
windowsHide: true,
});
child.unref();
} else {
Expand Down
23 changes: 20 additions & 3 deletions tests/oauth-open-external.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,36 @@ describe('openExternal', () => {
expect(child.unref).toHaveBeenCalled();
});

it('quotes OAuth URLs when launching cmd.exe on Windows', () => {
it('opens the browser via rundll32 FileProtocolHandler on Windows (no cmd.exe)', () => {
const child = new EventEmitter() as EventEmitter & { unref: () => void };
child.unref = vi.fn();
const launch = vi.fn(() => child as unknown as ReturnType<typeof import('node:child_process').spawn>);
const url = 'https://example.com/auth?client_id=abc&redirect_uri=http://127.0.0.1:1234/callback';

__oauthInternals.openExternal(url, 'win32', launch as unknown as typeof import('node:child_process').spawn);

expect(launch).toHaveBeenCalledWith('cmd', ['/s', '/c', `start "" "${url}"`], {
expect(launch).toHaveBeenCalledWith('rundll32', ['url.dll,FileProtocolHandler', url], {
stdio: 'ignore',
detached: true,
windowsVerbatimArguments: true,
windowsHide: true,
});
expect(child.unref).toHaveBeenCalled();
});

it('does not pass quote- or ampersand-bearing OAuth URLs through cmd.exe on Windows', () => {
const child = new EventEmitter() as EventEmitter & { unref: () => void };
child.unref = vi.fn();
const launch = vi.fn(() => child as unknown as ReturnType<typeof import('node:child_process').spawn>);
const url = 'https://example.com/auth?q="evil"&redirect_uri=http://127.0.0.1:1234/callback';

__oauthInternals.openExternal(url, 'win32', launch as unknown as typeof import('node:child_process').spawn);

const [exe, args] = launch.mock.calls[0] as [string, string[]];
expect(exe).toBe('rundll32');
expect(args).toEqual(['url.dll,FileProtocolHandler', url]);
// Must not use cmd /c start (command-interpreter boundary).
expect(exe.toLowerCase()).not.toContain('cmd');
expect(args.some((a) => a === '/c' || a.toLowerCase() === 'start')).toBe(false);
expect(child.unref).toHaveBeenCalled();
});
});