Skip to content
Draft
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
50 changes: 45 additions & 5 deletions packages/cli/src/lib/auth-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,31 +35,70 @@ describe('auth-flow', () => {
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
})
})

it('opens browser using start on win32 safely with spawn', async () => {
it('throws an error for unsupported protocols', async () => {
const mockApiRequest = vi.mocked(apiRequest)
mockApiRequest.mockResolvedValueOnce({ state: 'state123', authUrl: 'file:///etc/passwd' }) // Step 1

await expect(runLoginFlow('http://api')).rejects.toThrow('์ง€์›ํ•˜์ง€ ์•Š๋Š” ํ”„๋กœํ† ์ฝœ์ž…๋‹ˆ๋‹ค: file:///etc/passwd')
})

it('throws an error for invalid URLs', async () => {
const mockApiRequest = vi.mocked(apiRequest)
mockApiRequest.mockResolvedValueOnce({ state: 'state123', authUrl: 'not_a_url' }) // Step 1

await expect(runLoginFlow('http://api')).rejects.toThrow('์œ ํšจํ•˜์ง€ ์•Š์€ URL์ž…๋‹ˆ๋‹ค: not_a_url')
})

it('accepts case-insensitive HTTP schemes and opens the normalized URL', async () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
configurable: true,
})

const mockApiRequest = vi.mocked(apiRequest)
mockApiRequest.mockResolvedValueOnce({ state: 'state123', authUrl: 'HTTPS://EXAMPLE.COM/callback' }) // Step 1
mockApiRequest.mockResolvedValueOnce({ token: 'token123' }) // Step 3
mockApiRequest.mockResolvedValueOnce({ user: { id: 'u1', name: 'User1' } }) // Step 5

await runLoginFlow('http://api')

expect(childProcess.spawn).toHaveBeenCalledWith(
'xdg-open',
['https://example.com/callback'],
{ detached: true, stdio: 'ignore' }
)
})

it('opens browser using rundll32 on win32 safely', async () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
configurable: true,
})

const mockApiRequest = vi.mocked(apiRequest)
mockApiRequest.mockResolvedValueOnce({ state: 'state123', authUrl: 'http://example.com/&calc' }) // Step 1
const testUrl = 'http://example.com/?a=1&b=2|calc;echo<test>%APPDATA%'
const normalizedUrl = new URL(testUrl).href
mockApiRequest.mockResolvedValueOnce({ state: 'state123', authUrl: testUrl }) // Step 1
mockApiRequest.mockResolvedValueOnce({ token: 'token123' }) // Step 3
mockApiRequest.mockResolvedValueOnce({ user: { id: 'u1', name: 'User1' } }) // Step 5

await runLoginFlow('http://api')

expect(childProcess.spawn).toHaveBeenCalledWith(
'cmd.exe',
['/c', 'start', '""', 'http://example.com/^&calc'],
{ windowsVerbatimArguments: true, detached: true, stdio: 'ignore' }
'rundll32',
['url.dll,FileProtocolHandler', normalizedUrl],
{ detached: true, stdio: 'ignore' }
)
})

it('opens browser using open on darwin safely with spawn', async () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
})

const mockApiRequest = vi.mocked(apiRequest)
Expand All @@ -75,6 +114,7 @@ describe('auth-flow', () => {
it('opens browser using xdg-open on linux safely with spawn', async () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
configurable: true,
})

const mockApiRequest = vi.mocked(apiRequest)
Expand Down
26 changes: 20 additions & 6 deletions packages/cli/src/lib/auth-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,37 @@ import ora from 'ora'
import type { User, LoginResponse } from '@argos/shared'
import { apiRequest } from './api-client.js'

function openBrowser(url: string): void {
function openBrowser(targetUrl: string): void {
// URL Protocol Validation
// Allow only HTTP/HTTPS to prevent arbitrary protocol vulnerabilities (e.g. file://, javascript://)
let parsedUrl: URL
try {
parsedUrl = new URL(targetUrl)
} catch {
throw new Error(`์œ ํšจํ•˜์ง€ ์•Š์€ URL์ž…๋‹ˆ๋‹ค: ${targetUrl}`)
}

if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error(`์ง€์›ํ•˜์ง€ ์•Š๋Š” ํ”„๋กœํ† ์ฝœ์ž…๋‹ˆ๋‹ค: ${targetUrl}`)
}

const normalizedUrl = parsedUrl.href

// Command Injection ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด exec ๋Œ€์‹  spawn ์‚ฌ์šฉ
if (process.platform === 'win32') {
// Windows: cmd.exe ๋นŒํŠธ์ธ start ๋ช…๋ น์–ด ์‚ฌ์šฉ
const child = spawn('cmd.exe', ['/c', 'start', '""', url.replace(/&/g, '^&')], {
windowsVerbatimArguments: true,
// Windows: cmd.exe ๋ฅผ ํ”ผํ•˜๊ณ  rundll32.exe ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์•ˆ์ „ํ•˜๊ฒŒ URL ์—ด๊ธฐ
const child = spawn('rundll32', ['url.dll,FileProtocolHandler', normalizedUrl], {
detached: true,
stdio: 'ignore'
})
child.unref()
} else if (process.platform === 'darwin') {
// macOS
const child = spawn('open', [url], { detached: true, stdio: 'ignore' })
const child = spawn('open', [normalizedUrl], { detached: true, stdio: 'ignore' })
child.unref()
} else {
// Linux ๋“ฑ
const child = spawn('xdg-open', [url], { detached: true, stdio: 'ignore' })
const child = spawn('xdg-open', [normalizedUrl], { detached: true, stdio: 'ignore' })
child.unref()
}
}
Expand Down
Loading