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
5 changes: 5 additions & 0 deletions .changeset/desktop-navigation-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@open-codesign/desktop": patch
---

Harden desktop navigation so workspace Markdown links cannot replace the app window.
36 changes: 32 additions & 4 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { BRAND } from '@open-codesign/shared';
import type { BrowserWindow as ElectronBrowserWindow } from 'electron';
import { autoUpdater } from 'electron-updater';
Expand All @@ -21,6 +21,7 @@ import { getPendingUpdate, setupAutoUpdater } from './ipc/update';
import { registerLocaleIpc } from './locale-ipc';
import { getLogger, initLogger } from './logger';
import { registerMemoryIpc } from './memory-ipc';
import { isTrustedMainWindowNavigationUrl } from './navigation-policy';
import { loadConfigOnBoot, registerOnboardingIpc } from './onboarding-ipc';
import { isAllowedExternalUrl } from './open-external';
import { readPersisted as readPreferences, registerPreferencesIpc } from './preferences-ipc';
Expand Down Expand Up @@ -63,7 +64,23 @@ if (storageLocations.dataDir !== undefined) {
app.setPath('userData', storageLocations.dataDir);
}

type NavigationEvent = { preventDefault: () => void };

function handleMainWindowNavigation(
event: NavigationEvent,
url: string,
trustedAppUrl: string,
): void {
if (isTrustedMainWindowNavigationUrl(url, trustedAppUrl)) return;

event.preventDefault();
}

function createWindow(): void {
const rendererEntryPath = join(__dirname, '../renderer/index.html');
const rendererUrlOverride = process.env['ELECTRON_RENDERER_URL'];
const rendererEntryUrl = rendererUrlOverride || pathToFileURL(rendererEntryPath).href;

mainWindow = new BrowserWindow({
width: 1280,
height: 820,
Expand Down Expand Up @@ -101,6 +118,17 @@ function createWindow(): void {
return { action: 'deny' };
});

mainWindow.webContents.on('will-navigate', (event: NavigationEvent, url: string) => {
handleMainWindowNavigation(event, url, rendererEntryUrl);
});

mainWindow.webContents.on(
'will-redirect',
(event: NavigationEvent, url: string, _isInPlace: boolean, isMainFrame: boolean) => {
if (isMainFrame) handleMainWindowNavigation(event, url, rendererEntryUrl);
},
);

// Replay any update event that fired before this window was ready
// (macOS: user closed window, triggered a manual Check for Updates from
// the app menu, then reopened — the event would otherwise be lost).
Expand All @@ -111,10 +139,10 @@ function createWindow(): void {
}
});

if (process.env['ELECTRON_RENDERER_URL']) {
void mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
if (rendererUrlOverride) {
void mainWindow.loadURL(rendererEntryUrl);
} else {
void mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
void mainWindow.loadFile(rendererEntryPath);
}
}

Expand Down
45 changes: 45 additions & 0 deletions apps/desktop/src/main/navigation-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { pathToFileURL } from 'node:url';
import { describe, expect, it } from 'vitest';
import { isTrustedMainWindowNavigationUrl } from './navigation-policy';

describe('isTrustedMainWindowNavigationUrl', () => {
it('allows same-origin dev-server navigation', () => {
expect(
isTrustedMainWindowNavigationUrl(
'http://localhost:5173/dashboard?tab=files',
'http://localhost:5173/',
),
).toBe(true);
});

it('rejects remote navigation away from the dev app origin', () => {
expect(isTrustedMainWindowNavigationUrl('https://example.com', 'http://localhost:5173/')).toBe(
false,
);
});

it('rejects same-host navigation on a different port', () => {
expect(
isTrustedMainWindowNavigationUrl('http://localhost:3000/', 'http://localhost:5173/'),
).toBe(false);
});

it('allows hash navigation on the packaged renderer file', () => {
const trusted = pathToFileURL('/Applications/Open CoDesign.app/Contents/renderer/index.html');
const target = new URL('#workspace', trusted);

expect(isTrustedMainWindowNavigationUrl(target.href, trusted.href)).toBe(true);
});

it('rejects other file URLs when the packaged renderer is trusted', () => {
const trusted = pathToFileURL('/Applications/Open CoDesign.app/Contents/renderer/index.html');
const target = pathToFileURL('/Users/user/Documents/notes.md');

expect(isTrustedMainWindowNavigationUrl(target.href, trusted.href)).toBe(false);
});

it('rejects malformed URLs', () => {
expect(isTrustedMainWindowNavigationUrl('not a url', 'http://localhost:5173/')).toBe(false);
expect(isTrustedMainWindowNavigationUrl('https://example.com', 'not a url')).toBe(false);
});
});
37 changes: 37 additions & 0 deletions apps/desktop/src/main/navigation-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { fileURLToPath } from 'node:url';

function parseUrl(raw: string): URL | null {
try {
return new URL(raw);
} catch {
return null;
}
}

function sameHttpOrigin(a: URL, b: URL): boolean {
return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port;
}

function sameFilePath(a: URL, b: URL): boolean {
try {
return fileURLToPath(a) === fileURLToPath(b);
} catch {
return false;
}
}

export function isTrustedMainWindowNavigationUrl(rawUrl: string, trustedAppUrl: string): boolean {
const target = parseUrl(rawUrl);
const trusted = parseUrl(trustedAppUrl);
if (target === null || trusted === null) return false;

if (trusted.protocol === 'http:' || trusted.protocol === 'https:') {
return sameHttpOrigin(target, trusted);
}

if (trusted.protocol === 'file:') {
return target.protocol === 'file:' && sameFilePath(target, trusted);
}

return false;
}
38 changes: 37 additions & 1 deletion apps/desktop/src/renderer/src/components/FilesTabView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
RefreshCw,
} from 'lucide-react';
import {
type AnchorHTMLAttributes,
type ChangeEvent,
type KeyboardEvent,
lazy,
Expand All @@ -35,6 +36,7 @@ import {
useLazyDesignFileTree,
} from '../hooks/useDesignFiles';
import type { FileTreeNode } from '../lib/file-tree';
import { classifyMarkdownHref } from '../lib/markdown-links';
import { workspacePathComparisonKey } from '../lib/workspace-path';
import {
formatIframeError,
Expand Down Expand Up @@ -216,6 +218,38 @@ function escapeHtmlText(value: string): string {
.replace(/'/g, ''');
}

function MarkdownLink({
href,
children,
node: _node,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement> & { node?: unknown }) {
const action = classifyMarkdownHref(href);

if (action.kind === 'anchor') {
return (
<a {...props} href={action.href}>
{children}
</a>
);
}

if (action.kind === 'external') {
const handleClick = (event: ReactMouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
void window.codesign?.openExternal?.(action.url).catch(() => undefined);
};

return (
<a {...props} href={action.url} rel={props.rel ?? 'noreferrer'} onClick={handleClick}>
{children}
</a>
);
}

return <span>{children}</span>;
}

function WorkspaceSection({ files }: { files: DesignFileEntry[] }) {
const t = useT();
const currentDesignId = useCodesignStore((s) => s.currentDesignId);
Expand Down Expand Up @@ -1013,7 +1047,9 @@ function TextFilePreview({
</pre>
</details>
) : null}
<ReactMarkdown remarkPlugins={[remarkGfm]}>{markdown.body}</ReactMarkdown>
<ReactMarkdown components={{ a: MarkdownLink }} remarkPlugins={[remarkGfm]}>
{markdown.body}
</ReactMarkdown>
</article>
) : (
<pre
Expand Down
37 changes: 37 additions & 0 deletions apps/desktop/src/renderer/src/lib/markdown-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { classifyMarkdownHref } from './markdown-links';

describe('classifyMarkdownHref', () => {
it('keeps in-document anchors clickable', () => {
expect(classifyMarkdownHref('#details')).toEqual({ kind: 'anchor', href: '#details' });
});

it('routes https URLs through the safe external-open path', () => {
expect(
classifyMarkdownHref('https://github.com/OpenCoworkAI/open-codesign/issues/339'),
).toEqual({
kind: 'external',
url: 'https://github.com/OpenCoworkAI/open-codesign/issues/339',
});
});

it('normalizes surrounding whitespace on external URLs', () => {
expect(
classifyMarkdownHref(' https://github.com/OpenCoworkAI/open-codesign/releases '),
).toEqual({
kind: 'external',
url: 'https://github.com/OpenCoworkAI/open-codesign/releases',
});
});

it('blocks relative links that would otherwise navigate the app document', () => {
expect(classifyMarkdownHref('./other.md')).toEqual({ kind: 'blocked' });
expect(classifyMarkdownHref('/absolute/path')).toEqual({ kind: 'blocked' });
});

it('blocks unsafe protocols', () => {
expect(classifyMarkdownHref('javascript:alert(1)')).toEqual({ kind: 'blocked' });
expect(classifyMarkdownHref('file:///Users/user/.ssh/id_rsa')).toEqual({ kind: 'blocked' });
expect(classifyMarkdownHref('http://example.com')).toEqual({ kind: 'blocked' });
});
});
26 changes: 26 additions & 0 deletions apps/desktop/src/renderer/src/lib/markdown-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export type MarkdownHrefAction =
| { kind: 'anchor'; href: string }
| { kind: 'external'; url: string }
| { kind: 'blocked' };

export function classifyMarkdownHref(rawHref: string | undefined): MarkdownHrefAction {
const href = rawHref?.trim();
if (!href) return { kind: 'blocked' };

if (href.startsWith('#')) {
return { kind: 'anchor', href };
}

let parsed: URL;
try {
parsed = new URL(href);
} catch {
return { kind: 'blocked' };
}

if (parsed.protocol !== 'https:') {
return { kind: 'blocked' };
}

return { kind: 'external', url: parsed.href };
}
Loading