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
34 changes: 27 additions & 7 deletions src/lib/device-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,21 +112,41 @@ 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 {
// Browser could not be opened — the URL is printed anyway.
}
}

/** 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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
34 changes: 34 additions & 0 deletions test/device-login.test.ts
Original file line number Diff line number Diff line change
@@ -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/']])
})
})
Loading