summaryrefslogtreecommitdiff
path: root/packages/core/src/utils/secure-browser-launcher.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core/src/utils/secure-browser-launcher.test.ts')
-rw-r--r--packages/core/src/utils/secure-browser-launcher.test.ts242
1 files changed, 242 insertions, 0 deletions
diff --git a/packages/core/src/utils/secure-browser-launcher.test.ts b/packages/core/src/utils/secure-browser-launcher.test.ts
new file mode 100644
index 00000000..de27ce6f
--- /dev/null
+++ b/packages/core/src/utils/secure-browser-launcher.test.ts
@@ -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),
+ );
+ });
+ });
+});