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
3 changes: 3 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ module.exports = {
coverageDirectory: '<rootDir>/coverage',
collectCoverageFrom: [
'<rootDir>/src/renderer/components/**/*.{js,jsx,ts,tsx}',
'<rootDir>/src/main/index.ts',
'<rootDir>/src/main/preload.ts',
'<rootDir>/src/main/security/navigation-guard.ts',
'<rootDir>/src/utils/**/*.{js,ts}',
'!<rootDir>/src/**/*.d.ts',
'!**/node_modules/**',
Expand Down
58 changes: 57 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from 'fs';
import { pathToFileURL } from 'node:url';
import path from 'path';

import { app, BrowserWindow, dialog, ipcMain, net, protocol } from 'electron';
import { app, BrowserWindow, dialog, ipcMain, net, protocol, shell } from 'electron';
import { autoUpdater } from 'electron-updater';

import { loadDefaultConfig } from '../utils/config-manager';
Expand All @@ -12,6 +12,10 @@ import { TokenCounter } from '../utils/token-counter';

import { getErrorMessage } from './errors';
import { initializeUpdaterFeatureFlags } from './feature-flags';
import {
isAllowedExternalNavigationUrl,
isAllowedInAppNavigationUrl,
} from './security/navigation-guard';
import {
isPathWithinRoot,
isPathWithinTempRoot,
Expand Down Expand Up @@ -122,8 +126,34 @@ let updaterService = createUpdaterService(

const APP_ROOT = path.resolve(__dirname, '../../..');
const RENDERER_INDEX_PATH = path.join(APP_ROOT, 'src', 'renderer', 'public', 'index.html');
const RENDERER_INDEX_URL = pathToFileURL(RENDERER_INDEX_PATH).toString();
const ASSETS_DIR = path.join(APP_ROOT, 'src', 'assets');
const createForbiddenAssetResponse = (): Response => new Response('Forbidden', { status: 403 });
const isTestEnvironment = process.env.NODE_ENV === 'test';

const resolveTestPathOverride = (envKey: string): string | null => {
if (!isTestEnvironment) {
return null;
}

const configuredPath = process.env[envKey];
if (typeof configuredPath !== 'string' || configuredPath.trim().length === 0) {
return null;
}

return path.resolve(configuredPath);
};

const openAllowedExternalUrl = (url: string) => {
if (!isAllowedExternalNavigationUrl(url)) {
console.warn(`Blocked external navigation URL: ${url}`);
return;
}

void shell.openExternal(url).catch((error) => {
console.error(`Failed to open external URL: ${url}`, error);
});
};

// Set environment
const isDevelopment = process.env.NODE_ENV === 'development';
Expand Down Expand Up @@ -162,6 +192,20 @@ async function createWindow() {
// Hide the menu bar completely in all modes
mainWindow.setMenu(null);

mainWindow.webContents.setWindowOpenHandler(({ url }) => {
openAllowedExternalUrl(url);
return { action: 'deny' };
});

mainWindow.webContents.on('will-navigate', (event, url) => {
if (isAllowedInAppNavigationUrl(url, RENDERER_INDEX_URL)) {
return;
}

event.preventDefault();
openAllowedExternalUrl(url);
});

// Load the index.html file
if (isDevelopment) {
await mainWindow.loadFile(RENDERER_INDEX_PATH);
Expand Down Expand Up @@ -290,6 +334,12 @@ ipcMain.handle(

// Select directory dialog
ipcMain.handle('dialog:selectDirectory', async () => {
const testDirectoryPath = resolveTestPathOverride('E2E_DIALOG_DIRECTORY_PATH');
if (testDirectoryPath) {
authorizedRootPath = testDirectoryPath;
return testDirectoryPath;
}

const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow ?? undefined, {
properties: ['openDirectory'],
});
Expand Down Expand Up @@ -393,6 +443,12 @@ ipcMain.handle(

// Save output to file
ipcMain.handle('fs:saveFile', async (_event, { content, defaultPath }: SaveFileOptions) => {
const testSavePath = resolveTestPathOverride('E2E_DIALOG_SAVE_PATH');
if (testSavePath) {
fs.writeFileSync(testSavePath, content);
return testSavePath;
}

const safeDefaultPath = typeof defaultPath === 'string' ? defaultPath : '';
const defaultExtension = safeDefaultPath ? path.extname(safeDefaultPath).toLowerCase() : '';
const filters =
Expand Down
17 changes: 16 additions & 1 deletion src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ import type {
UpdaterStatus,
} from '../types/ipc';

// Keep preload self-contained: sandboxed preload cannot reliably require local modules.
const isAllowedExternalNavigationUrl = (url: string): boolean => {
try {
const parsedUrl = new URL(url);
return parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'http:';
} catch {
return false;
}
};

type DevUtils = {
clearLocalStorage: () => boolean;
isDev: boolean;
Expand All @@ -38,7 +48,12 @@ const devUtils: DevUtils = {

const electronShellApi: ElectronShellApi = {
shell: {
openExternal: (url: string) => shell.openExternal(url),
openExternal: async (url: string) => {
if (!isAllowedExternalNavigationUrl(url)) {
throw new Error(`Blocked external URL: ${url}`);
}
await shell.openExternal(url);
},
},
};

Expand Down
35 changes: 35 additions & 0 deletions src/main/security/navigation-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export const isAllowedExternalNavigationUrl = (url: string): boolean => {
try {
const parsedUrl = new URL(url);
return parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'http:';
} catch {
return false;
}
};

const normalizeFilePathname = (pathname: string): string => {
const driveLetterMatch = pathname.match(/^\/([A-Za-z]):/);
if (!driveLetterMatch) {
return pathname;
}

const driveLetter = driveLetterMatch[1].toLowerCase();
return `/${driveLetter}:${pathname.slice(3)}`;
};

export const isAllowedInAppNavigationUrl = (url: string, rendererIndexUrl: string): boolean => {
try {
const targetUrl = new URL(url);
const rendererUrl = new URL(rendererIndexUrl);

if (targetUrl.protocol !== 'file:' || rendererUrl.protocol !== 'file:') {
return false;
}

return (
normalizeFilePathname(targetUrl.pathname) === normalizeFilePathname(rendererUrl.pathname)
);
} catch {
return false;
}
};
2 changes: 2 additions & 0 deletions tests/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ Purpose: quick map of what is covered, why it exists, and which command to run.
| `tests/unit/main/updater.test.ts` | `src/main/updater.ts` | Alpha/stable channel selection, platform gating, update-check result handling |
| `tests/unit/main/updater-smoke.test.ts` | `src/main/updater.ts` | Manual updater-check flow, stable-vs-alpha prerelease assertions, Linux-disabled guard, and structured updater check observability events |
| `tests/unit/main/feature-flags.test.ts` | `src/main/feature-flags.ts` | OpenFeature normalization, env/remote merge rules, secure remote fetch behavior |
| `tests/unit/main/navigation-guard.test.ts` | `src/main/security/navigation-guard.ts` | External URL allowlist checks and in-app navigation allow/deny behavior |
| `tests/unit/main/path-security.test.ts` | `src/main/security/path-guard.ts` | Root-path authorization, temp-root boundaries, symlink-aware realpath resolution |
| `tests/unit/main/preload.test.ts` | `src/main/preload.ts` | Preload bridge external URL protocol guard for `shell.openExternal` |
| `tests/unit/main/provider-connection.test.ts` | `src/main/services/provider-connection.ts` | Provider defaults, URL validation/normalization, request construction, timeout/error handling |
| `tests/unit/shared/provider-registry.test.ts` | `src/shared/provider-registry.ts` | Shared provider contract IDs, default base URLs, API-key requirement flags, and supported-provider guards |
| `tests/unit/main/directory-tree.test.ts` | `src/main/services/directory-tree.ts` | Exclude/include pattern merge, symlink skip policy, canonical recursion-loop guard, parse-failure fallback |
Expand Down
32 changes: 7 additions & 25 deletions tests/e2e/electron-process-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,27 +114,6 @@ const createFixtureProject = (browserName: string): string => {
return projectDir;
};

const stubNativeDialogs = async (
electronApp: ElectronApplication,
projectDir: string,
savePath: string
) => {
await electronApp.evaluate(
({ dialog }, { directoryPath, outputPath }) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: [directoryPath],
});

dialog.showSaveDialog = async () => ({
canceled: false,
filePath: outputPath,
});
},
{ directoryPath: projectDir, outputPath: savePath }
);
};

const configureFlowDefaults = async (
page: Page,
exportFormat: 'markdown' | 'xml' = 'markdown'
Expand All @@ -155,8 +134,11 @@ const configureFlowDefaults = async (

const openFixtureProject = async (page: Page, exportFormat: 'markdown' | 'xml' = 'markdown') => {
await configureFlowDefaults(page, exportFormat);
await page.getByRole('button', { name: 'Select Folder' }).click();
await expect(page.getByRole('tab', { name: 'Select Files' })).toHaveAttribute('aria-selected', 'true');
const selectFolderButton = page.getByRole('button', { name: 'Select Folder' });
const sourceTab = page.getByRole('tab', { name: 'Select Files' });

await selectFolderButton.click();
await expect(sourceTab).toHaveAttribute('aria-selected', 'true', { timeout: 15_000 });
await expect(page.getByLabel('Select All')).toBeVisible();
};

Expand Down Expand Up @@ -224,6 +206,8 @@ const test = base.extend<E2EFixtures>({
NODE_ENV: 'test',
ELECTRON_USER_DATA_PATH: userDataDir,
ELECTRON_DISABLE_SECURITY_WARNINGS: 'true',
E2E_DIALOG_DIRECTORY_PATH: projectDir,
E2E_DIALOG_SAVE_PATH: savePath,
};

if (process.platform === 'linux') {
Expand All @@ -236,8 +220,6 @@ const test = base.extend<E2EFixtures>({
env: launchEnv,
});

await stubNativeDialogs(electronApp, projectDir, savePath);

await use(electronApp);
await electronApp.close();
},
Expand Down
61 changes: 60 additions & 1 deletion tests/integration/main-process/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
const fs = require('fs');
const { pathToFileURL } = require('node:url');
const yaml = require('yaml');
const FAKE_GITHUB_TOKEN = ['ghp', 'AAAAAAAAAAAAAAAAAAAAAAAA'].join('_');

// Mock electron ipcMain
const mockIpcHandlers = {};
const mockProtocolHandlers = {};
const mockNetFetch = jest.fn();
let latestWindowOpenHandler = null;
let latestWillNavigateHandler = null;
let latestRendererIndexPath = null;
const mockAutoUpdater = {
checkForUpdates: jest.fn(),
setFeedURL: jest.fn(),
Expand All @@ -31,11 +35,22 @@ jest.mock('electron', () => ({
getVersion: jest.fn().mockReturnValue('0.2.0'),
},
BrowserWindow: jest.fn().mockImplementation(() => ({
loadFile: jest.fn().mockResolvedValue(null),
loadFile: jest.fn().mockImplementation(async (targetPath) => {
latestRendererIndexPath = targetPath;
return null;
}),
on: jest.fn(),
setMenu: jest.fn(),
webContents: {
openDevTools: jest.fn(),
setWindowOpenHandler: jest.fn((handler) => {
latestWindowOpenHandler = handler;
}),
on: jest.fn((eventName, handler) => {
if (eventName === 'will-navigate') {
latestWillNavigateHandler = handler;
}
}),
},
})),
ipcMain: mockIpcMain,
Expand All @@ -51,6 +66,9 @@ jest.mock('electron', () => ({
net: {
fetch: mockNetFetch,
},
shell: {
openExternal: jest.fn().mockResolvedValue(undefined),
},
}));

jest.mock('fs');
Expand Down Expand Up @@ -328,6 +346,47 @@ describe('Main Process IPC Handlers', () => {
});
});

describe('window navigation guards', () => {
test('should deny window-open requests and only allow http(s) in external opener', () => {
const { shell } = require('electron');
expect(latestWindowOpenHandler).toBeDefined();

expect(latestWindowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' });
expect(shell.openExternal).not.toHaveBeenCalled();

expect(latestWindowOpenHandler({ url: 'https://example.com/docs' })).toEqual({
action: 'deny',
});
expect(shell.openExternal).toHaveBeenCalledWith('https://example.com/docs');
});

test('should prevent disallowed will-navigate targets and open allowed external urls', () => {
const { shell } = require('electron');
expect(latestWillNavigateHandler).toBeDefined();
expect(latestRendererIndexPath).toBeDefined();
const rendererIndexUrl = pathToFileURL(latestRendererIndexPath).toString();

const allowedEvent = { preventDefault: jest.fn() };
latestWillNavigateHandler(allowedEvent, rendererIndexUrl);
expect(allowedEvent.preventDefault).not.toHaveBeenCalled();

const blockedEvent = { preventDefault: jest.fn() };
latestWillNavigateHandler(blockedEvent, 'https://example.com/security');
expect(blockedEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(shell.openExternal).toHaveBeenCalledWith('https://example.com/security');

const disallowedProtocolEvent = { preventDefault: jest.fn() };
latestWillNavigateHandler(disallowedProtocolEvent, 'javascript:alert(1)');
expect(disallowedProtocolEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(shell.openExternal).not.toHaveBeenCalledWith('javascript:alert(1)');

const aboutBlankEvent = { preventDefault: jest.fn() };
latestWillNavigateHandler(aboutBlankEvent, 'about:blank');
expect(aboutBlankEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(shell.openExternal).not.toHaveBeenCalledWith('about:blank');
});
});

describe('fs:getDirectoryTree', () => {
test('should filter directory tree based on config', async () => {
// Setup
Expand Down
5 changes: 5 additions & 0 deletions tests/integration/main-process/xml-export-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ describe('XML export end-to-end', () => {
setMenu: jest.fn(),
webContents: {
openDevTools: jest.fn(),
setWindowOpenHandler: jest.fn(),
on: jest.fn(),
},
})),
ipcMain: {
Expand All @@ -51,6 +53,9 @@ describe('XML export end-to-end', () => {
net: {
fetch: mockNetFetch,
},
shell: {
openExternal: jest.fn().mockResolvedValue(undefined),
},
protocol: {
handle: jest.fn(),
},
Expand Down
5 changes: 5 additions & 0 deletions tests/stress/main-process/ipc-latency.stress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jest.mock('electron', () => ({
setMenu: jest.fn(),
webContents: {
openDevTools: jest.fn(),
setWindowOpenHandler: jest.fn(),
on: jest.fn(),
},
})),
ipcMain: {
Expand All @@ -46,6 +48,9 @@ jest.mock('electron', () => ({
net: {
fetch: jest.fn().mockResolvedValue({ ok: true, status: 200, url: 'file:///mock/icon.png' }),
},
shell: {
openExternal: jest.fn().mockResolvedValue(undefined),
},
}));

jest.mock('fs');
Expand Down
Loading