From 6190ddd9fa26c6c22ba1c313fb631835c8bce96a Mon Sep 17 00:00:00 2001 From: CodeMash Date: Sat, 3 Oct 2026 11:13:01 +0300 Subject: [PATCH] fix(login): open the browser without a shell (CodeQL js/command-line-injection) openBrowser ran `cmd /c start "" ` on Windows, with the URL taken from the hub's device-login response. cmd parses `&`, `|` and `^` in its arguments, so a hostile or compromised --server could run commands on the user's machine. - only http(s) URLs are opened (parsed with URL, normalised href) - Windows uses `rundll32 url.dll,FileProtocolHandler `, no cmd - test/device-login.test.ts covers both Co-Authored-By: Claude Opus 5.5 --- src/lib/device-login.ts | 34 +++++++++++++++++++++++++++------- test/device-login.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 test/device-login.test.ts diff --git a/src/lib/device-login.ts b/src/lib/device-login.ts index 88b61f4..0e2c7ca 100644 --- a/src/lib/device-login.ts +++ b/src/lib/device-login.ts @@ -112,14 +112,17 @@ export async function pollDeviceToken( throw new Error('The login code expired. Run `norbix login` again.') } -/** Open a URL in the default browser — macOS, Linux, Windows. */ +/** + * Open a URL in the default browser — macOS, Linux, Windows. + * + * The URL comes from the hub's response, so it is untrusted: only http(s) is + * opened, and Windows uses rundll32 instead of `cmd /c start`, whose parser + * would run `&`, `|` and `^` in the URL as shell syntax. + */ export function openBrowser(url: string): void { - const [cmd, args] = - process.platform === 'darwin' - ? ['open', [url]] - : process.platform === 'win32' - ? ['cmd', ['/c', 'start', '', url]] - : ['xdg-open', [url]] + const href = toHttpUrl(url) + if (!href) return + const [cmd, args] = browserCommand(href, process.platform) try { spawn(cmd, args, {detached: true, stdio: 'ignore'}).unref() } catch { @@ -127,6 +130,23 @@ export function openBrowser(url: string): void { } } +/** The URL normalised, or undefined when it is not an http(s) URL. */ +export function toHttpUrl(url: string): string | undefined { + try { + const parsed = new URL(url) + return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : undefined + } catch { + return undefined + } +} + +/** The command that opens `href` in the default browser on `platform`. */ +export function browserCommand(href: string, platform: NodeJS.Platform): [string, string[]] { + if (platform === 'darwin') return ['open', [href]] + if (platform === 'win32') return ['rundll32', ['url.dll,FileProtocolHandler', href]] + return ['xdg-open', [href]] +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/test/device-login.test.ts b/test/device-login.test.ts new file mode 100644 index 0000000..68abc3d --- /dev/null +++ b/test/device-login.test.ts @@ -0,0 +1,34 @@ +import {describe, expect, it} from 'vitest' + +import {browserCommand, toHttpUrl} from '../src/lib/device-login.js' + +/** + * Opening the login page. The URL comes from the hub's response, so it must + * never reach a shell: only http(s) is opened, and Windows does not go + * through `cmd /c start`. + */ +describe('openBrowser', () => { + it('keeps http and https URLs', () => { + expect(toHttpUrl('https://hub.norbix.ai/device?code=AB-CD')).toBe('https://hub.norbix.ai/device?code=AB-CD') + expect(toHttpUrl('http://localhost:5000/device')).toBe('http://localhost:5000/device') + }) + + it('refuses other schemes and non-URLs', () => { + expect(toHttpUrl('file:///etc/passwd')).toBeUndefined() + expect(toHttpUrl('javascript:alert(1)')).toBeUndefined() + expect(toHttpUrl('calc.exe')).toBeUndefined() + expect(toHttpUrl('')).toBeUndefined() + }) + + it('does not use cmd on Windows, so & and | stay part of the URL', () => { + const href = toHttpUrl('https://hub.norbix.ai/device?a=1&b=2|calc')! + const [cmd, args] = browserCommand(href, 'win32') + expect(cmd).toBe('rundll32') + expect(args).toEqual(['url.dll,FileProtocolHandler', href]) + }) + + it('uses open on macOS and xdg-open elsewhere', () => { + expect(browserCommand('https://x.test/', 'darwin')).toEqual(['open', ['https://x.test/']]) + expect(browserCommand('https://x.test/', 'linux')).toEqual(['xdg-open', ['https://x.test/']]) + }) +})