diff --git a/packages/cli/src/lib/auth-flow.test.ts b/packages/cli/src/lib/auth-flow.test.ts index 020b0cd3..50b81b73 100644 --- a/packages/cli/src/lib/auth-flow.test.ts +++ b/packages/cli/src/lib/auth-flow.test.ts @@ -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%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) @@ -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) diff --git a/packages/cli/src/lib/auth-flow.ts b/packages/cli/src/lib/auth-flow.ts index 1274609a..aedf5e93 100644 --- a/packages/cli/src/lib/auth-flow.ts +++ b/packages/cli/src/lib/auth-flow.ts @@ -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() } }