mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 09:17:53 +00:00
New browser launcher for MCP OAuth. (#5261)
This commit is contained in:
242
packages/core/src/utils/secure-browser-launcher.test.ts
Normal file
242
packages/core/src/utils/secure-browser-launcher.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { openBrowserSecurely } from './secure-browser-launcher.js';
|
||||
|
||||
// Create mock function using vi.hoisted
|
||||
const mockExecFile = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Mock modules
|
||||
vi.mock('node:child_process');
|
||||
vi.mock('node:util', () => ({
|
||||
promisify: () => mockExecFile,
|
||||
}));
|
||||
|
||||
describe('secure-browser-launcher', () => {
|
||||
let originalPlatform: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExecFile.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform);
|
||||
}
|
||||
});
|
||||
|
||||
function setPlatform(platform: string) {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe('URL validation', () => {
|
||||
it('should allow valid HTTP URLs', async () => {
|
||||
setPlatform('darwin');
|
||||
await openBrowserSecurely('http://example.com');
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'open',
|
||||
['http://example.com'],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow valid HTTPS URLs', async () => {
|
||||
setPlatform('darwin');
|
||||
await openBrowserSecurely('https://example.com');
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'open',
|
||||
['https://example.com'],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject non-HTTP(S) protocols', async () => {
|
||||
await expect(openBrowserSecurely('file:///etc/passwd')).rejects.toThrow(
|
||||
'Unsafe protocol',
|
||||
);
|
||||
await expect(openBrowserSecurely('javascript:alert(1)')).rejects.toThrow(
|
||||
'Unsafe protocol',
|
||||
);
|
||||
await expect(openBrowserSecurely('ftp://example.com')).rejects.toThrow(
|
||||
'Unsafe protocol',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid URLs', async () => {
|
||||
await expect(openBrowserSecurely('not-a-url')).rejects.toThrow(
|
||||
'Invalid URL',
|
||||
);
|
||||
await expect(openBrowserSecurely('')).rejects.toThrow('Invalid URL');
|
||||
});
|
||||
|
||||
it('should reject URLs with control characters', async () => {
|
||||
await expect(
|
||||
openBrowserSecurely('http://example.com\nmalicious-command'),
|
||||
).rejects.toThrow('invalid characters');
|
||||
await expect(
|
||||
openBrowserSecurely('http://example.com\rmalicious-command'),
|
||||
).rejects.toThrow('invalid characters');
|
||||
await expect(
|
||||
openBrowserSecurely('http://example.com\x00'),
|
||||
).rejects.toThrow('invalid characters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command injection prevention', () => {
|
||||
it('should prevent PowerShell command injection on Windows', async () => {
|
||||
setPlatform('win32');
|
||||
|
||||
// The POC from the vulnerability report
|
||||
const maliciousUrl =
|
||||
"http://127.0.0.1:8080/?param=example#$(Invoke-Expression([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('Y2FsYy5leGU='))))";
|
||||
|
||||
await openBrowserSecurely(maliciousUrl);
|
||||
|
||||
// Verify that execFile was called (not exec) and the URL is passed safely
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-WindowStyle',
|
||||
'Hidden',
|
||||
'-Command',
|
||||
`Start-Process '${maliciousUrl.replace(/'/g, "''")}'`,
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle URLs with special shell characters safely', async () => {
|
||||
setPlatform('darwin');
|
||||
|
||||
const urlsWithSpecialChars = [
|
||||
'http://example.com/path?param=value&other=$value',
|
||||
'http://example.com/path#fragment;command',
|
||||
'http://example.com/$(whoami)',
|
||||
'http://example.com/`command`',
|
||||
'http://example.com/|pipe',
|
||||
'http://example.com/>redirect',
|
||||
];
|
||||
|
||||
for (const url of urlsWithSpecialChars) {
|
||||
await openBrowserSecurely(url);
|
||||
// Verify the URL is passed as an argument, not interpreted by shell
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'open',
|
||||
[url],
|
||||
expect.any(Object),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should properly escape single quotes in URLs on Windows', async () => {
|
||||
setPlatform('win32');
|
||||
|
||||
const urlWithSingleQuotes =
|
||||
"http://example.com/path?name=O'Brien&test='value'";
|
||||
await openBrowserSecurely(urlWithSingleQuotes);
|
||||
|
||||
// Verify that single quotes are escaped by doubling them
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-WindowStyle',
|
||||
'Hidden',
|
||||
'-Command',
|
||||
`Start-Process 'http://example.com/path?name=O''Brien&test=''value'''`,
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Platform-specific behavior', () => {
|
||||
it('should use correct command on macOS', async () => {
|
||||
setPlatform('darwin');
|
||||
await openBrowserSecurely('https://example.com');
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'open',
|
||||
['https://example.com'],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use PowerShell on Windows', async () => {
|
||||
setPlatform('win32');
|
||||
await openBrowserSecurely('https://example.com');
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
expect.arrayContaining([
|
||||
'-Command',
|
||||
`Start-Process 'https://example.com'`,
|
||||
]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use xdg-open on Linux', async () => {
|
||||
setPlatform('linux');
|
||||
await openBrowserSecurely('https://example.com');
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'xdg-open',
|
||||
['https://example.com'],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on unsupported platforms', async () => {
|
||||
setPlatform('aix');
|
||||
await expect(openBrowserSecurely('https://example.com')).rejects.toThrow(
|
||||
'Unsupported platform',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle browser launch failures gracefully', async () => {
|
||||
setPlatform('darwin');
|
||||
mockExecFile.mockRejectedValueOnce(new Error('Command not found'));
|
||||
|
||||
await expect(openBrowserSecurely('https://example.com')).rejects.toThrow(
|
||||
'Failed to open browser',
|
||||
);
|
||||
});
|
||||
|
||||
it('should try fallback browsers on Linux', async () => {
|
||||
setPlatform('linux');
|
||||
|
||||
// First call to xdg-open fails
|
||||
mockExecFile.mockRejectedValueOnce(new Error('Command not found'));
|
||||
// Second call to gnome-open succeeds
|
||||
mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||
|
||||
await openBrowserSecurely('https://example.com');
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(2);
|
||||
expect(mockExecFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'xdg-open',
|
||||
['https://example.com'],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockExecFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'gnome-open',
|
||||
['https://example.com'],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user